From 90cc6fbb9c169f4ff5d402c3fe95fe2210788ce5 Mon Sep 17 00:00:00 2001
From: danbaruka <46156791+danbaruka@users.noreply.github.com>
Date: Sun, 4 Oct 2026 19:00:10 +0300
Subject: [PATCH] feat: add Zunia extension and WalletConnect adapters
Connect dApps to window.zunia. WalletConnect opens the phone app with the zunia://wc?uri= link the app already pairs, and asks for the Cosmos methods it registers.
---
pnpm-lock.yaml | 25 ++++
wallets/zunia-extension/CHANGELOG.md | 10 ++
wallets/zunia-extension/README.md | 47 ++++++++
.../zunia-extension/__tests__/zunia.test.ts | 77 +++++++++++++
wallets/zunia-extension/jest.config.js | 21 ++++
wallets/zunia-extension/package.json | 44 +++++++
wallets/zunia-extension/src/constant.ts | 3 +
wallets/zunia-extension/src/cosmos.ts | 34 ++++++
wallets/zunia-extension/src/index.ts | 20 ++++
wallets/zunia-extension/src/registry.ts | 42 +++++++
wallets/zunia-extension/tsconfig.esm.json | 9 ++
wallets/zunia-extension/tsconfig.json | 14 +++
wallets/zunia-extension/tsconfig.test.json | 11 ++
wallets/zunia-mobile/CHANGELOG.md | 10 ++
wallets/zunia-mobile/README.md | 52 +++++++++
wallets/zunia-mobile/__tests__/zunia.test.ts | 108 ++++++++++++++++++
wallets/zunia-mobile/jest.config.js | 21 ++++
wallets/zunia-mobile/package.json | 46 ++++++++
wallets/zunia-mobile/src/constant.ts | 3 +
wallets/zunia-mobile/src/cosmos.ts | 75 ++++++++++++
wallets/zunia-mobile/src/index.ts | 11 ++
wallets/zunia-mobile/src/registry.ts | 39 +++++++
wallets/zunia-mobile/src/wallet.ts | 62 ++++++++++
wallets/zunia-mobile/src/wc-link.ts | 38 ++++++
wallets/zunia-mobile/tsconfig.esm.json | 9 ++
wallets/zunia-mobile/tsconfig.json | 14 +++
wallets/zunia-mobile/tsconfig.test.json | 11 ++
27 files changed, 856 insertions(+)
create mode 100644 wallets/zunia-extension/CHANGELOG.md
create mode 100644 wallets/zunia-extension/README.md
create mode 100644 wallets/zunia-extension/__tests__/zunia.test.ts
create mode 100644 wallets/zunia-extension/jest.config.js
create mode 100644 wallets/zunia-extension/package.json
create mode 100644 wallets/zunia-extension/src/constant.ts
create mode 100644 wallets/zunia-extension/src/cosmos.ts
create mode 100644 wallets/zunia-extension/src/index.ts
create mode 100644 wallets/zunia-extension/src/registry.ts
create mode 100644 wallets/zunia-extension/tsconfig.esm.json
create mode 100644 wallets/zunia-extension/tsconfig.json
create mode 100644 wallets/zunia-extension/tsconfig.test.json
create mode 100644 wallets/zunia-mobile/CHANGELOG.md
create mode 100644 wallets/zunia-mobile/README.md
create mode 100644 wallets/zunia-mobile/__tests__/zunia.test.ts
create mode 100644 wallets/zunia-mobile/jest.config.js
create mode 100644 wallets/zunia-mobile/package.json
create mode 100644 wallets/zunia-mobile/src/constant.ts
create mode 100644 wallets/zunia-mobile/src/cosmos.ts
create mode 100644 wallets/zunia-mobile/src/index.ts
create mode 100644 wallets/zunia-mobile/src/registry.ts
create mode 100644 wallets/zunia-mobile/src/wallet.ts
create mode 100644 wallets/zunia-mobile/src/wc-link.ts
create mode 100644 wallets/zunia-mobile/tsconfig.esm.json
create mode 100644 wallets/zunia-mobile/tsconfig.json
create mode 100644 wallets/zunia-mobile/tsconfig.test.json
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 726cfe3f6..fa1e9d7d4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1130,6 +1130,31 @@ importers:
version: 0.1.12
publishDirectory: dist
+ wallets/zunia-extension:
+ dependencies:
+ '@interchain-kit/core':
+ specifier: workspace:*
+ version: link:../../packages/core/dist
+ devDependencies:
+ makage:
+ specifier: ^0.1.12
+ version: 0.1.12
+ publishDirectory: dist
+
+ wallets/zunia-mobile:
+ dependencies:
+ '@interchain-kit/core':
+ specifier: workspace:*
+ version: link:../../packages/core/dist
+ '@interchainjs/amino':
+ specifier: 1.21.0
+ version: 1.21.0(@chain-registry/v2-types@0.53.146)(@chain-registry/v2@1.71.237)
+ devDependencies:
+ makage:
+ specifier: ^0.1.12
+ version: 0.1.12
+ publishDirectory: dist
+
packages:
'@adobe/css-tools@4.4.4':
resolution:
diff --git a/wallets/zunia-extension/CHANGELOG.md b/wallets/zunia-extension/CHANGELOG.md
new file mode 100644
index 000000000..bfe7e8767
--- /dev/null
+++ b/wallets/zunia-extension/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+
+# 0.11.0
+
+### Features
+
+- add the Zunia browser extension adapter (`window.zunia`)
diff --git a/wallets/zunia-extension/README.md b/wallets/zunia-extension/README.md
new file mode 100644
index 000000000..e90160681
--- /dev/null
+++ b/wallets/zunia-extension/README.md
@@ -0,0 +1,47 @@
+# @interchain-kit/zunia-extension
+
+Zunia browser extension adapter for [Interchain Kit](https://github.com/hyperweb-io/interchain-kit).
+
+Zunia is a non-custodial Cosmos wallet. This package connects a dApp to `window.zunia`. On a phone browser, where the extension is not injected, it opens the Zunia app over WalletConnect.
+
+## Install
+
+```sh
+npm install @interchain-kit/zunia-extension @interchain-kit/react
+```
+
+## Usage
+
+```tsx
+import { ChainProvider } from '@interchain-kit/react';
+import { zuniaWallet } from '@interchain-kit/zunia-extension';
+import { assetList as cosmoshubAssetList, chain as cosmoshubChain } from 'chain-registry/mainnet/cosmoshub';
+
+export function App() {
+ return (
+
+ {/* your app */}
+
+ );
+}
+```
+
+Use the same chain id the extension has. Pass every chain the dApp should connect to in `chains`.
+
+## What it calls
+
+| | |
+|---|---|
+| Window | `window.zunia` |
+| Account change | `zunia_keystorechange` |
+| Connect | `enable`, then `experimentalSuggestChain` when the chain is unknown, then `enable` again |
+| Sign | `signAmino`, `signDirect`, `signArbitrary` |
+| Broadcast | The dApp broadcasts. `sendTx` is refused. |
+
+Chrome 0.1.2: [Chrome Web Store](https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk). Install page: [zunialab.com](https://zunialab.com).
+
+There is no Ethereum provider. Do not set an `eip155` chain on this wallet.
+
+## Phone browser
+
+The mobile link is `zunia://wc?uri=` (Android intent package `com.zuniawallet.zunia_mobile`). That is the URI the Zunia app pairs. For a desktop or in-app WalletConnect session, use `@interchain-kit/zunia-mobile`.
diff --git a/wallets/zunia-extension/__tests__/zunia.test.ts b/wallets/zunia-extension/__tests__/zunia.test.ts
new file mode 100644
index 000000000..1af2f0750
--- /dev/null
+++ b/wallets/zunia-extension/__tests__/zunia.test.ts
@@ -0,0 +1,77 @@
+import { zuniaWallet } from '../src';
+import { ZuniaCosmosWallet } from '../src/cosmos';
+import { ZUNIA_ANDROID_PACKAGE, ZUNIA_WC_LINK } from '../src/registry';
+
+const WC_URI = 'wc:abc123@2?relay-protocol=irn&symKey=ff';
+
+function uriParam(link: string): string | null {
+ const withoutHash = link.split('#')[0];
+ const query = withoutHash.slice(withoutHash.indexOf('?') + 1);
+ return new URLSearchParams(query).get('uri');
+}
+
+describe('Zunia extension', () => {
+ it('talks to window.zunia and not to an Ethereum provider', () => {
+ expect(zuniaWallet.info.name).toBe('zunia-extension');
+ expect(zuniaWallet.info.prettyName).toBe('Zunia');
+ expect(zuniaWallet.info.mode).toBe('extension');
+ expect(zuniaWallet.info.windowKey).toBe('zunia');
+ expect(zuniaWallet.info.cosmosKey).toBe('zunia');
+ expect(zuniaWallet.info.keystoreChange).toBe('zunia_keystorechange');
+ expect(zuniaWallet.info.ethereumKey).toBeUndefined();
+ expect(zuniaWallet.info.downloads).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ browser: 'chrome',
+ link: 'https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk',
+ }),
+ expect.objectContaining({
+ link: 'https://zunialab.com',
+ }),
+ ])
+ );
+ });
+
+ it('opens the phone with the uri query the app reads', () => {
+ const encoded = encodeURIComponent(WC_URI);
+ const ios = ZUNIA_WC_LINK.ios.replace('{wc-uri}', encoded);
+ const android = ZUNIA_WC_LINK.android.replace('{wc-uri}', encoded);
+
+ expect(ios.startsWith('zunia://wc?uri=')).toBe(true);
+ expect(uriParam(ios)).toBe(WC_URI);
+ expect(android).toContain(`package=${ZUNIA_ANDROID_PACKAGE}`);
+ expect(android).toContain('scheme=zunia');
+ expect(uriParam(android)).toBe(WC_URI);
+ });
+
+ it('asks again to connect after a chain suggestion', async () => {
+ const wallet = new ZuniaCosmosWallet(zuniaWallet.info);
+ const enable = jest
+ .fn()
+ .mockRejectedValueOnce(new Error('Zunia does not know the chain osmosis-1. Add it with experimentalSuggestChain first.'))
+ .mockResolvedValueOnce(undefined);
+ wallet.client = { enable };
+ jest.spyOn(wallet, 'addSuggestChain').mockResolvedValue(undefined);
+
+ await wallet.connect('osmosis-1');
+
+ expect(wallet.addSuggestChain).toHaveBeenCalledWith('osmosis-1');
+ expect(enable).toHaveBeenCalledTimes(2);
+ });
+
+ it('leaves a rejection alone', async () => {
+ const wallet = new ZuniaCosmosWallet(zuniaWallet.info);
+ wallet.client = { enable: jest.fn().mockRejectedValue(new Error('Request rejected')) };
+ const suggest = jest.spyOn(wallet, 'addSuggestChain').mockResolvedValue(undefined);
+
+ await expect(wallet.connect('cosmoshub-4')).rejects.toThrow('Request rejected');
+ expect(suggest).not.toHaveBeenCalled();
+ });
+
+ it('does not broadcast a dApp transaction', async () => {
+ const wallet = new ZuniaCosmosWallet(zuniaWallet.info);
+ await expect(wallet.sendTx('cosmoshub-4', new Uint8Array(), 'sync' as never)).rejects.toThrow(
+ 'does not broadcast'
+ );
+ });
+});
diff --git a/wallets/zunia-extension/jest.config.js b/wallets/zunia-extension/jest.config.js
new file mode 100644
index 000000000..c3f17d7c5
--- /dev/null
+++ b/wallets/zunia-extension/jest.config.js
@@ -0,0 +1,21 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ babelConfig: false,
+ tsconfig: 'tsconfig.test.json',
+ },
+ ],
+ },
+ transformIgnorePatterns: [`/node_modules/*`],
+ testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ modulePathIgnorePatterns: ['dist/*'],
+ moduleNameMapper: {
+ '^@interchain-kit/core$': '/../../packages/core/src',
+ },
+};
diff --git a/wallets/zunia-extension/package.json b/wallets/zunia-extension/package.json
new file mode 100644
index 000000000..8c9303c60
--- /dev/null
+++ b/wallets/zunia-extension/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@interchain-kit/zunia-extension",
+ "version": "0.11.0",
+ "author": "Zunia Lab ",
+ "description": "Zunia browser extension wallet adapter for Interchain Kit",
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "homepage": "https://github.com/hyperweb-io/interchain-kit",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/hyperweb-io/interchain-kit"
+ },
+ "bugs": {
+ "url": "https://github.com/hyperweb-io/interchain-kit/issues"
+ },
+ "scripts": {
+ "clean": "makage clean",
+ "build": "makage build",
+ "build:dev": "npm run clean; tsc --declarationMap; tsc -p tsconfig.esm.json; npm run copy",
+ "lint": "eslint . --fix",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "watch:dev": "tsc -w -p tsconfig.esm.json & tsc -w",
+ "prepack": "npm run build"
+ },
+ "keywords": [
+ "zunia",
+ "cosmos",
+ "wallet",
+ "interchain-kit"
+ ],
+ "dependencies": {
+ "@interchain-kit/core": "workspace:*"
+ },
+ "devDependencies": {
+ "makage": "^0.1.12"
+ }
+}
diff --git a/wallets/zunia-extension/src/constant.ts b/wallets/zunia-extension/src/constant.ts
new file mode 100644
index 000000000..77d1ae8ff
--- /dev/null
+++ b/wallets/zunia-extension/src/constant.ts
@@ -0,0 +1,3 @@
+// Zunia mark, red tile. Same drawing as zunia-brand/svg/icon/zunia-icon-red-tile.svg, without embedded metadata.
+export const ICON =
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cmVjdCB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgcng9IjEyMCIgZmlsbD0iI0ZGMUIwQyIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDk5LjEgNTkuOCkgc2NhbGUoMy4yNjkyKSI+PGRlZnM+PG1hc2sgaWQ9Im1nIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4PSIwIiB5PSIwIiB3aWR0aD0iOTYiIGhlaWdodD0iMTIwIj48cmVjdCB3aWR0aD0iOTYiIGhlaWdodD0iMTIwIiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTI2IDIwIDcwIDQ2IDI2IDcyIiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvbWFzaz48L2RlZnM+PGcgbWFzaz0idXJsKCNtZykiPjxwYXRoIGQ9Ik0yNiA0OCA3MCA3NCAyNiAxMDAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9nPjxwYXRoIGQ9Ik0yNiAyMCA3MCA0NiAyNiA3MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PC9zdmc+';
diff --git a/wallets/zunia-extension/src/cosmos.ts b/wallets/zunia-extension/src/cosmos.ts
new file mode 100644
index 000000000..da003c9df
--- /dev/null
+++ b/wallets/zunia-extension/src/cosmos.ts
@@ -0,0 +1,34 @@
+import { BroadcastMode, CosmosWallet } from '@interchain-kit/core';
+
+function errorMessage(error: unknown): string {
+ if (error instanceof Error) return error.message;
+ if (typeof error === 'object' && error && 'message' in error) {
+ return String((error as { message: unknown }).message);
+ }
+ return '';
+}
+
+/**
+ * Zunia's `experimentalSuggestChain` saves the chain and does not grant the site.
+ * The stock connector returns after the suggestion, so the next `getKey` is unauthorized.
+ * Connect again once the chain is known.
+ *
+ * `sendTx` is refused in the extension. The dApp signs, then broadcasts.
+ */
+export class ZuniaCosmosWallet extends CosmosWallet {
+ async connect(chainId: string): Promise {
+ try {
+ await this.client.enable(chainId);
+ } catch (error) {
+ if (errorMessage(error).includes('rejected')) throw error;
+ await this.addSuggestChain(chainId);
+ await this.client.enable(chainId);
+ }
+ }
+
+ async sendTx(_chainId: string, _tx: Uint8Array, _mode: BroadcastMode): Promise {
+ throw new Error(
+ 'Zunia does not broadcast dApp transactions. Sign with signAmino or signDirect and broadcast from the dApp.'
+ );
+ }
+}
diff --git a/wallets/zunia-extension/src/index.ts b/wallets/zunia-extension/src/index.ts
new file mode 100644
index 000000000..b0ce197d3
--- /dev/null
+++ b/wallets/zunia-extension/src/index.ts
@@ -0,0 +1,20 @@
+import { ExtensionWallet, selectWalletByPlatform, WCMobileWebWallet } from '@interchain-kit/core';
+
+import { ZuniaCosmosWallet } from './cosmos';
+import { zuniaExtensionInfo } from './registry';
+
+export * from './registry';
+
+const web = new ExtensionWallet(zuniaExtensionInfo);
+web.setNetworkWallet('cosmos', new ZuniaCosmosWallet(zuniaExtensionInfo));
+
+const zuniaWallet = selectWalletByPlatform(
+ {
+ mobileBrowser: new WCMobileWebWallet(zuniaExtensionInfo),
+ inAppBrowser: web,
+ desktopBrowser: web,
+ },
+ zuniaExtensionInfo
+);
+
+export { zuniaWallet };
diff --git a/wallets/zunia-extension/src/registry.ts b/wallets/zunia-extension/src/registry.ts
new file mode 100644
index 000000000..701d6434d
--- /dev/null
+++ b/wallets/zunia-extension/src/registry.ts
@@ -0,0 +1,42 @@
+import { Wallet } from '@interchain-kit/core';
+
+import { ICON } from './constant';
+
+/**
+ * Android application id and the custom scheme the Zunia app registers.
+ * The app only treats a link as WalletConnect when the `uri` query is a `wc:` URI.
+ * `zunia://wcV2?` is ignored.
+ */
+export const ZUNIA_ANDROID_PACKAGE = 'com.zuniawallet.zunia_mobile';
+
+export const ZUNIA_WC_LINK = {
+ android: `intent://wc?uri={wc-uri}#Intent;package=${ZUNIA_ANDROID_PACKAGE};scheme=zunia;end;`,
+ ios: 'zunia://wc?uri={wc-uri}',
+};
+
+export const zuniaExtensionInfo: Wallet = {
+ name: 'zunia-extension',
+ prettyName: 'Zunia',
+ logo: ICON,
+ mode: 'extension',
+ windowKey: 'zunia',
+ cosmosKey: 'zunia',
+ keystoreChange: 'zunia_keystorechange',
+ description: 'Non-custodial Cosmos wallet. One key, every chain.',
+ downloads: [
+ {
+ device: 'desktop',
+ browser: 'chrome',
+ link: 'https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk',
+ },
+ {
+ device: 'desktop',
+ browser: 'firefox',
+ link: 'https://addons.mozilla.org/en-US/firefox/addon/zunia-wallet/',
+ },
+ {
+ link: 'https://zunialab.com',
+ },
+ ],
+ walletConnectLink: ZUNIA_WC_LINK,
+};
diff --git a/wallets/zunia-extension/tsconfig.esm.json b/wallets/zunia-extension/tsconfig.esm.json
new file mode 100644
index 000000000..800d7506d
--- /dev/null
+++ b/wallets/zunia-extension/tsconfig.esm.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist/esm",
+ "module": "es2022",
+ "rootDir": "src/",
+ "declaration": false
+ }
+}
diff --git a/wallets/zunia-extension/tsconfig.json b/wallets/zunia-extension/tsconfig.json
new file mode 100644
index 000000000..7ff4de526
--- /dev/null
+++ b/wallets/zunia-extension/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src/",
+ "baseUrl": ".",
+ "paths": {
+ "@interchain-kit/core": ["../../packages/core/dist"],
+ "@interchain-kit/core/*": ["../../packages/core/dist/*"]
+ }
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"]
+}
diff --git a/wallets/zunia-extension/tsconfig.test.json b/wallets/zunia-extension/tsconfig.test.json
new file mode 100644
index 000000000..aa9fc1d81
--- /dev/null
+++ b/wallets/zunia-extension/tsconfig.test.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@interchain-kit/core": ["../../packages/core/src"],
+ "@interchain-kit/core/*": ["../../packages/core/src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "__tests__/**/*.ts"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/wallets/zunia-mobile/CHANGELOG.md b/wallets/zunia-mobile/CHANGELOG.md
new file mode 100644
index 000000000..f8f94caab
--- /dev/null
+++ b/wallets/zunia-mobile/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+
+# 0.11.0
+
+### Features
+
+- add the Zunia WalletConnect adapter
diff --git a/wallets/zunia-mobile/README.md b/wallets/zunia-mobile/README.md
new file mode 100644
index 000000000..103257277
--- /dev/null
+++ b/wallets/zunia-mobile/README.md
@@ -0,0 +1,52 @@
+# @interchain-kit/zunia-mobile
+
+Zunia WalletConnect adapter for [Interchain Kit](https://github.com/hyperweb-io/interchain-kit).
+
+Use this when the dApp should pair the Zunia phone app. The browser extension is `@interchain-kit/zunia-extension`.
+
+## Install
+
+```sh
+npm install @interchain-kit/zunia-mobile @interchain-kit/react
+```
+
+## Usage
+
+```tsx
+import { ChainProvider } from '@interchain-kit/react';
+import { zuniaMobile } from '@interchain-kit/zunia-mobile';
+import { assetList as cosmoshubAssetList, chain as cosmoshubChain } from 'chain-registry/mainnet/cosmoshub';
+
+export function App() {
+ return (
+
+ {/* your app */}
+
+ );
+}
+```
+
+Pass Cosmos chains only. An `eip155` or Solana chain is refused.
+
+## Session
+
+The proposal asks for the methods the app registers:
+
+- `cosmos_getAccounts`
+- `cosmos_signAmino`
+- `cosmos_signDirect`
+- `cosmos_signArbitrary`
+
+Events: `accountsChanged`, `chainChanged`.
+
+On a phone, the pairing URI opens `zunia://wc?uri=`. Android uses an intent for `com.zuniawallet.zunia_mobile`. On a desktop browser the same URI is the QR code.
+
+`cosmos_getAccounts` keeps the algo the wallet returns, including `eth_secp256k1`.
+
+Zunia is not in the WalletConnect Explorer, so this package does not invent an explorer project id. Pairing uses the dApp project id already configured by Interchain Kit, and the app is opened with its own link.
+
+Suggesting a new chain is not part of WalletConnect. Add the chain in the extension, or with `@interchain-kit/zunia-extension`.
+
+## App
+
+The Zunia app pairs this URI and can approve the session. Returning `cosmos_getAccounts` and the sign methods from that session is still being finished in the app. Until that response is sent, a signature waits. The link and the method list already match the app.
diff --git a/wallets/zunia-mobile/__tests__/zunia.test.ts b/wallets/zunia-mobile/__tests__/zunia.test.ts
new file mode 100644
index 000000000..2c90f973d
--- /dev/null
+++ b/wallets/zunia-mobile/__tests__/zunia.test.ts
@@ -0,0 +1,108 @@
+import { zuniaMobile } from '../src';
+import { ZuniaWCCosmosWallet } from '../src/cosmos';
+import { ZuniaMobileWallet } from '../src/wallet';
+import { ZUNIA_COSMOS_WC_METHODS, zuniaWalletConnectUrlFor } from '../src/wc-link';
+
+const WC_URI = 'wc:abc123@2?relay-protocol=irn&symKey=ff';
+
+function uriParam(link: string): string | null {
+ const withoutHash = link.split('#')[0];
+ const query = withoutHash.slice(withoutHash.indexOf('?') + 1);
+ return new URLSearchParams(query).get('uri');
+}
+
+describe('Zunia mobile', () => {
+ it('is a WalletConnect wallet for Cosmos', () => {
+ expect(zuniaMobile.info.name).toBe('zunia-mobile');
+ expect(zuniaMobile.info.prettyName).toBe('Zunia');
+ expect(zuniaMobile.info.mode).toBe('wallet-connect');
+ expect(zuniaMobile.info.ethereumKey).toBeUndefined();
+ expect(zuniaMobile.info.walletconnect?.requiredNamespaces?.methods).toEqual([
+ ...ZUNIA_COSMOS_WC_METHODS,
+ ]);
+ });
+
+ it('builds a link the app can pair', () => {
+ for (const os of ['ios', 'android'] as const) {
+ expect(uriParam(zuniaWalletConnectUrlFor(os, WC_URI))).toBe(WC_URI);
+ }
+ expect(zuniaWalletConnectUrlFor('android', WC_URI)).toContain('package=com.zuniawallet.zunia_mobile');
+ expect(zuniaWalletConnectUrlFor('ios', WC_URI).startsWith('zunia://wc?uri=')).toBe(true);
+ });
+
+ it('asks the session for the methods the app registers', async () => {
+ const memory = new Map();
+ Object.defineProperty(global, 'localStorage', {
+ configurable: true,
+ value: {
+ getItem: (key: string) => memory.get(key) ?? null,
+ setItem: (key: string, value: string) => memory.set(key, value),
+ removeItem: (key: string) => memory.delete(key),
+ },
+ });
+
+ const wallet = new ZuniaMobileWallet(zuniaMobile.info);
+ wallet.setChainMap([
+ { chainId: 'cosmoshub-4', chainType: 'cosmos', chainName: 'cosmoshub' } as never,
+ ]);
+ const connect = jest.fn().mockResolvedValue({ namespaces: { cosmos: { accounts: [], chains: ['cosmos:cosmoshub-4'], methods: [], events: [] } } });
+ wallet.provider = { session: null, connect } as never;
+
+ await wallet.connect('cosmoshub-4');
+
+ expect(connect).toHaveBeenCalledWith({
+ namespaces: {
+ cosmos: {
+ methods: [...ZUNIA_COSMOS_WC_METHODS],
+ chains: ['cosmos:cosmoshub-4'],
+ events: ['accountsChanged', 'chainChanged'],
+ },
+ },
+ });
+ });
+
+ it('refuses a non-Cosmos chain', async () => {
+ const wallet = new ZuniaMobileWallet(zuniaMobile.info);
+ wallet.setChainMap([{ chainId: '1', chainType: 'eip155', chainName: 'ethereum' } as never]);
+ wallet.provider = { session: null, connect: jest.fn() } as never;
+
+ await expect(wallet.connect('1')).rejects.toThrow('Cosmos chains');
+ });
+
+ it('keeps the algo the wallet reports', async () => {
+ const cosmos = new ZuniaWCCosmosWallet();
+ cosmos.provider = {
+ request: jest.fn().mockResolvedValue([
+ {
+ address: 'inj1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqe2hm49',
+ algo: 'eth_secp256k1',
+ pubkey: btoa('pubkey-bytes'),
+ },
+ ]),
+ } as never;
+
+ const account = await cosmos.getAccount('injective-1');
+ expect(account.algo).toBe('eth_secp256k1');
+ expect(account.pubkey).toEqual(Uint8Array.from(Buffer.from('pubkey-bytes')));
+ });
+
+ it('sends cosmos_signArbitrary as base64', async () => {
+ const cosmos = new ZuniaWCCosmosWallet();
+ const request = jest.fn().mockResolvedValue({ pub_key: { type: 'tendermint/PubKeySecp256k1', value: 'AA==' }, signature: 'BB==' });
+ cosmos.provider = { request } as never;
+
+ await cosmos.signArbitrary('cosmoshub-4', 'cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq', 'hello');
+
+ expect(request).toHaveBeenCalledWith(
+ {
+ method: 'cosmos_signArbitrary',
+ params: {
+ signerAddress: 'cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
+ data: btoa('hello'),
+ },
+ },
+ 'cosmos:cosmoshub-4',
+ undefined
+ );
+ });
+});
diff --git a/wallets/zunia-mobile/jest.config.js b/wallets/zunia-mobile/jest.config.js
new file mode 100644
index 000000000..c3f17d7c5
--- /dev/null
+++ b/wallets/zunia-mobile/jest.config.js
@@ -0,0 +1,21 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ babelConfig: false,
+ tsconfig: 'tsconfig.test.json',
+ },
+ ],
+ },
+ transformIgnorePatterns: [`/node_modules/*`],
+ testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ modulePathIgnorePatterns: ['dist/*'],
+ moduleNameMapper: {
+ '^@interchain-kit/core$': '/../../packages/core/src',
+ },
+};
diff --git a/wallets/zunia-mobile/package.json b/wallets/zunia-mobile/package.json
new file mode 100644
index 000000000..14185034c
--- /dev/null
+++ b/wallets/zunia-mobile/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@interchain-kit/zunia-mobile",
+ "version": "0.11.0",
+ "author": "Zunia Lab ",
+ "description": "Zunia WalletConnect adapter for Interchain Kit",
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "homepage": "https://github.com/hyperweb-io/interchain-kit",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/hyperweb-io/interchain-kit"
+ },
+ "bugs": {
+ "url": "https://github.com/hyperweb-io/interchain-kit/issues"
+ },
+ "scripts": {
+ "clean": "makage clean",
+ "build": "makage build",
+ "build:dev": "npm run clean; tsc --declarationMap; tsc -p tsconfig.esm.json; npm run copy",
+ "lint": "eslint . --fix",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "watch:dev": "tsc -w -p tsconfig.esm.json & tsc -w",
+ "prepack": "npm run build"
+ },
+ "keywords": [
+ "zunia",
+ "cosmos",
+ "wallet",
+ "walletconnect",
+ "interchain-kit"
+ ],
+ "dependencies": {
+ "@interchain-kit/core": "workspace:*",
+ "@interchainjs/amino": "1.21.0"
+ },
+ "devDependencies": {
+ "makage": "^0.1.12"
+ }
+}
diff --git a/wallets/zunia-mobile/src/constant.ts b/wallets/zunia-mobile/src/constant.ts
new file mode 100644
index 000000000..b1f9cdcc9
--- /dev/null
+++ b/wallets/zunia-mobile/src/constant.ts
@@ -0,0 +1,3 @@
+// Zunia mark, red tile. Same drawing as the extension package.
+export const ICON =
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj48cmVjdCB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgcng9IjEyMCIgZmlsbD0iI0ZGMUIwQyIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDk5LjEgNTkuOCkgc2NhbGUoMy4yNjkyKSI+PGRlZnM+PG1hc2sgaWQ9Im1nIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiB4PSIwIiB5PSIwIiB3aWR0aD0iOTYiIGhlaWdodD0iMTIwIj48cmVjdCB3aWR0aD0iOTYiIGhlaWdodD0iMTIwIiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTI2IDIwIDcwIDQ2IDI2IDcyIiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvbWFzaz48L2RlZnM+PGcgbWFzaz0idXJsKCNtZykiPjxwYXRoIGQ9Ik0yNiA0OCA3MCA3NCAyNiAxMDAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9nPjxwYXRoIGQ9Ik0yNiAyMCA3MCA0NiAyNiA3MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PC9zdmc+';
diff --git a/wallets/zunia-mobile/src/cosmos.ts b/wallets/zunia-mobile/src/cosmos.ts
new file mode 100644
index 000000000..4db58bb15
--- /dev/null
+++ b/wallets/zunia-mobile/src/cosmos.ts
@@ -0,0 +1,75 @@
+import { WalletAccount, WCCosmosWallet } from '@interchain-kit/core';
+import { StdSignature } from '@interchainjs/amino';
+
+function messageBytes(data: string | Uint8Array): Uint8Array {
+ if (typeof data === 'string') return new TextEncoder().encode(data);
+ return data;
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+}
+
+function base64ToBytes(value: string): Uint8Array {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+/**
+ * Cosmos WalletConnect peer for Zunia.
+ * Account algo is taken from the wallet: `secp256k1`, or `eth_secp256k1` on Injective-style chains.
+ * `cosmos_signArbitrary` is one of the methods the app registers. The stock connector does not send it.
+ */
+export class ZuniaWCCosmosWallet extends WCCosmosWallet {
+ async getAccount(chainId: string): Promise {
+ if (!this.provider) {
+ throw new Error('Provider not initialized');
+ }
+
+ const accounts = await this.provider.request(
+ {
+ method: 'cosmos_getAccounts',
+ params: [],
+ },
+ `cosmos:${chainId}`
+ ) as Array<{ address?: string; algo?: string; pubkey?: string }>;
+
+ const account = accounts[0];
+ if (!account?.address || !account.pubkey) {
+ throw new Error('Zunia did not return a Cosmos account');
+ }
+
+ const algo = account.algo === 'eth_secp256k1' ? 'eth_secp256k1' : 'secp256k1';
+
+ return {
+ address: account.address,
+ algo,
+ pubkey: base64ToBytes(account.pubkey),
+ username: '',
+ isNanoLedger: false,
+ isSmartContract: false,
+ };
+ }
+
+ async signArbitrary(chainId: string, signer: string, data: string | Uint8Array): Promise {
+ if (!this.provider) {
+ throw new Error('Provider not initialized');
+ }
+
+ return await this.provider.request(
+ {
+ method: 'cosmos_signArbitrary',
+ params: {
+ signerAddress: signer,
+ data: bytesToBase64(messageBytes(data)),
+ },
+ },
+ `cosmos:${chainId}`,
+ this.wcWallet?.options?.signingRequestExpiry
+ ) as StdSignature;
+ }
+}
diff --git a/wallets/zunia-mobile/src/index.ts b/wallets/zunia-mobile/src/index.ts
new file mode 100644
index 000000000..c572af4a9
--- /dev/null
+++ b/wallets/zunia-mobile/src/index.ts
@@ -0,0 +1,11 @@
+import { ZuniaWCCosmosWallet } from './cosmos';
+import { zuniaMobileInfo } from './registry';
+import { ZuniaMobileWallet } from './wallet';
+
+export * from './registry';
+export * from './wc-link';
+
+const zuniaMobile = new ZuniaMobileWallet(zuniaMobileInfo);
+zuniaMobile.setNetworkWallet('cosmos', new ZuniaWCCosmosWallet());
+
+export { zuniaMobile };
diff --git a/wallets/zunia-mobile/src/registry.ts b/wallets/zunia-mobile/src/registry.ts
new file mode 100644
index 000000000..05f65a37f
--- /dev/null
+++ b/wallets/zunia-mobile/src/registry.ts
@@ -0,0 +1,39 @@
+import { Wallet } from '@interchain-kit/core';
+
+import { ICON } from './constant';
+import { ZUNIA_COSMOS_WC_EVENTS, ZUNIA_COSMOS_WC_METHODS, ZUNIA_WC_LINK } from './wc-link';
+
+export const zuniaMobileInfo: Wallet = {
+ name: 'zunia-mobile',
+ prettyName: 'Zunia',
+ logo: ICON,
+ mode: 'wallet-connect',
+ description: 'Zunia on a phone, over WalletConnect.',
+ downloads: [
+ {
+ link: 'https://zunialab.com',
+ },
+ ],
+ walletConnectLink: ZUNIA_WC_LINK,
+ walletconnect: {
+ name: 'Zunia',
+ // Zunia is not listed in the WalletConnect Explorer, so there is no explorer project id.
+ // Pairing uses the dApp project id from Interchain Kit. These links open the app directly.
+ projectId: '',
+ mobile: {
+ native: {
+ ios: 'zunia:',
+ android: 'zunia:',
+ },
+ universal: 'https://zunialab.com/wc',
+ },
+ requiredNamespaces: {
+ methods: [...ZUNIA_COSMOS_WC_METHODS],
+ events: [...ZUNIA_COSMOS_WC_EVENTS],
+ },
+ formatNativeUrl: (_appUrl, wcUri, os) => {
+ if (os === 'android') return ZUNIA_WC_LINK.android.replace('{wc-uri}', encodeURIComponent(wcUri));
+ return ZUNIA_WC_LINK.ios.replace('{wc-uri}', encodeURIComponent(wcUri));
+ },
+ },
+};
diff --git a/wallets/zunia-mobile/src/wallet.ts b/wallets/zunia-mobile/src/wallet.ts
new file mode 100644
index 000000000..602322e04
--- /dev/null
+++ b/wallets/zunia-mobile/src/wallet.ts
@@ -0,0 +1,62 @@
+import { WCWallet } from '@interchain-kit/core';
+
+import { ZUNIA_COSMOS_WC_EVENTS, ZUNIA_COSMOS_WC_METHODS, zuniaWalletConnectUrl } from './wc-link';
+
+/**
+ * WalletConnect session for Zunia.
+ * Only Cosmos chains are proposed. The session includes `cosmos_signArbitrary`, which the app registers.
+ * On a phone, the pairing URI is opened with `zunia://wc?uri=`.
+ */
+export class ZuniaMobileWallet extends WCWallet {
+ async init(): Promise {
+ await super.init();
+ this.provider.on('display_uri', (uri: string) => {
+ const url = zuniaWalletConnectUrl(uri);
+ if (!url || typeof window === 'undefined') return;
+ window.open(url, '_blank', 'noopener,noreferrer');
+ });
+ }
+
+ async connect(_chainIds: string): Promise {
+ const chainIds = Array.from(this.chainMap.keys());
+ const cosmosChainNS: string[] = [];
+
+ for (const chainId of chainIds) {
+ const chain = this.getChainById(chainId);
+ if (chain.chainType !== 'cosmos') {
+ throw new Error(`Zunia WalletConnect supports Cosmos chains. ${chainId} is ${chain.chainType}.`);
+ }
+ cosmosChainNS.push(`cosmos:${chainId}`);
+ }
+
+ if (this.provider?.session) {
+ const sessionChains = Object.values(this.provider.session.namespaces).flatMap((ns) => ns.chains ?? []);
+ const allIncluded = cosmosChainNS.every((chain) => sessionChains.includes(chain));
+ if (allIncluded && cosmosChainNS.length > 0) return;
+ await this.disconnect();
+ }
+
+ if (cosmosChainNS.length === 0) {
+ throw new Error('Zunia WalletConnect needs at least one Cosmos chain.');
+ }
+
+ try {
+ const session = await this.provider.connect({
+ namespaces: {
+ cosmos: {
+ methods: [...ZUNIA_COSMOS_WC_METHODS],
+ chains: cosmosChainNS,
+ events: [...ZUNIA_COSMOS_WC_EVENTS],
+ },
+ },
+ });
+ this.session = session;
+ this.pairingUri = null;
+ this.onPairingUriCreated && this.onPairingUriCreated(null);
+ localStorage.setItem('wc-session', JSON.stringify(this.session));
+ } catch (error) {
+ console.log('wc connect error', error);
+ throw error;
+ }
+ }
+}
diff --git a/wallets/zunia-mobile/src/wc-link.ts b/wallets/zunia-mobile/src/wc-link.ts
new file mode 100644
index 000000000..6d11b9d5a
--- /dev/null
+++ b/wallets/zunia-mobile/src/wc-link.ts
@@ -0,0 +1,38 @@
+import { isAndroid, isIOS } from '@interchain-kit/core';
+
+/** Android application id from the Zunia mobile manifest. */
+export const ZUNIA_ANDROID_PACKAGE = 'com.zuniawallet.zunia_mobile';
+
+/**
+ * Methods the Zunia app registers for a Cosmos WalletConnect session.
+ * Kept in this order so a dApp session matches the wallet.
+ */
+export const ZUNIA_COSMOS_WC_METHODS = [
+ 'cosmos_getAccounts',
+ 'cosmos_signAmino',
+ 'cosmos_signDirect',
+ 'cosmos_signArbitrary',
+] as const;
+
+export const ZUNIA_COSMOS_WC_EVENTS = ['accountsChanged', 'chainChanged'] as const;
+
+/**
+ * Same templates `WCMobileWebWallet` fills. `{wc-uri}` is replaced with an encoded `wc:` URI.
+ * The app reads the `uri` query. A bare `zunia://wcV2?` does not pair.
+ */
+export const ZUNIA_WC_LINK = {
+ android: `intent://wc?uri={wc-uri}#Intent;package=${ZUNIA_ANDROID_PACKAGE};scheme=zunia;end;`,
+ ios: 'zunia://wc?uri={wc-uri}',
+};
+
+export function zuniaWalletConnectUrlFor(os: 'android' | 'ios', wcUri: string): string {
+ return ZUNIA_WC_LINK[os].replace('{wc-uri}', encodeURIComponent(wcUri));
+}
+
+/** Deep link for the current phone browser. Desktop keeps the QR code. */
+export function zuniaWalletConnectUrl(wcUri: string): string | undefined {
+ if (typeof window === 'undefined') return undefined;
+ if (isAndroid()) return zuniaWalletConnectUrlFor('android', wcUri);
+ if (isIOS()) return zuniaWalletConnectUrlFor('ios', wcUri);
+ return undefined;
+}
diff --git a/wallets/zunia-mobile/tsconfig.esm.json b/wallets/zunia-mobile/tsconfig.esm.json
new file mode 100644
index 000000000..800d7506d
--- /dev/null
+++ b/wallets/zunia-mobile/tsconfig.esm.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist/esm",
+ "module": "es2022",
+ "rootDir": "src/",
+ "declaration": false
+ }
+}
diff --git a/wallets/zunia-mobile/tsconfig.json b/wallets/zunia-mobile/tsconfig.json
new file mode 100644
index 000000000..7ff4de526
--- /dev/null
+++ b/wallets/zunia-mobile/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src/",
+ "baseUrl": ".",
+ "paths": {
+ "@interchain-kit/core": ["../../packages/core/dist"],
+ "@interchain-kit/core/*": ["../../packages/core/dist/*"]
+ }
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"]
+}
diff --git a/wallets/zunia-mobile/tsconfig.test.json b/wallets/zunia-mobile/tsconfig.test.json
new file mode 100644
index 000000000..aa9fc1d81
--- /dev/null
+++ b/wallets/zunia-mobile/tsconfig.test.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@interchain-kit/core": ["../../packages/core/src"],
+ "@interchain-kit/core/*": ["../../packages/core/src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "__tests__/**/*.ts"],
+ "exclude": ["dist", "node_modules"]
+}