Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/noir-wallet-zcash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@swapkit/helpers": minor
"@swapkit/wallet-extensions": minor
"@swapkit/wallets": minor
"@swapkit/sdk": minor
"@swapkit/ui": patch
---

Add Noir Wallet connector for Zcash (`connectNoirWallet`). Noir Wallet is a shielded-first Zcash browser extension; the connector delegates balance and transaction building to the extension and supports deposit-address swap routes (e.g. NEAR Intents).
6 changes: 6 additions & 0 deletions packages/helpers/src/modules/swapKitError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@ const errorCodes = {
wallet_vultisig_asset_not_defined: 22103,
wallet_vultisig_send_transaction_no_address: 22104,

/**
* Wallets - Noir Wallet
*/
wallet_noir_wallet_not_found: 22201,
wallet_noir_wallet_connection_failed: 22203,

/**
* Wallets - Xaman
*/
Expand Down
1 change: 1 addition & 0 deletions packages/helpers/src/types/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export enum WalletOption {
LEDGER = "LEDGER",
LEDGER_LIVE = "LEDGER_LIVE",
METAMASK = "METAMASK",
NOIR_WALLET = "NOIR_WALLET",
OKX = "OKX",
OKX_MOBILE = "OKX_MOBILE",
ONEKEY = "ONEKEY",
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { keplrWallet } from "@swapkit/wallets/keplr";
import { keystoreWallet } from "@swapkit/wallets/keystore";
import { ledgerWallet } from "@swapkit/wallets/ledger";
import { walletSelectorWallet } from "@swapkit/wallets/near-wallet-selector";
import { noirWallet } from "@swapkit/wallets/noir-wallet";
import { okxWallet } from "@swapkit/wallets/okx";
import { onekeyWallet } from "@swapkit/wallets/onekey";
import { passkeysWallet } from "@swapkit/wallets/passkeys";
Expand Down Expand Up @@ -68,6 +69,7 @@ export {
keplrWallet,
keystoreWallet,
ledgerWallet,
noirWallet,
okxWallet,
onekeyWallet,
passkeysWallet,
Expand Down Expand Up @@ -105,6 +107,7 @@ export const defaultWallets = {
...keplrWallet,
...keystoreWallet,
...ledgerWallet,
...noirWallet,
...okxWallet,
...onekeyWallet,
...phantomWallet,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const availableChainsByWallet: Record<WalletOption, Chain[] | readonly Ch
[WalletOption.LEAP]: [Chain.Cosmos, Chain.Kujira],
[WalletOption.LEDGER]: AllChainsSupported,
[WalletOption.METAMASK]: EVMChains,
[WalletOption.NOIR_WALLET]: [Chain.Zcash],
[WalletOption.OKX_MOBILE]: EVMChains,
[WalletOption.ONEKEY]: [
Chain.Arbitrum,
Expand Down
6 changes: 6 additions & 0 deletions packages/wallet-extensions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
"require": "./dist/src/keplr/index.cjs",
"types": "./dist/types/keplr/index.d.ts"
},
"./noir-wallet": {
"bun": "./src/noir-wallet/index.ts",
"default": "./dist/src/noir-wallet/index.js",
"require": "./dist/src/noir-wallet/index.cjs",
"types": "./dist/types/noir-wallet/index.d.ts"
},
"./okx": {
"bun": "./src/okx/index.ts",
"default": "./dist/src/okx/index.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// @ts-nocheck - Test file with intentional mocking of browser globals
import { beforeEach, describe, expect, test } from "bun:test";
import { AssetValue, Chain, WalletOption } from "@swapkit/helpers";

import { noirWallet } from "../index";

const TRANSPARENT_ADDRESS = "t1XVXWCvpMgBvUaed4XDqWtgQgJSu1Ghz7F";

function mockNoirWallet({ balance = {}, requests = [] } = {}) {
globalThis.window = {
noirwallet: {
isNoirWallet: true,
version: "1.0.25",
zcash: {
disconnect: async () => undefined,
on: () => undefined,
removeListener: () => undefined,
request: ({ method, params }) => {
requests.push({ method, params });

switch (method) {
case "zcash_requestAccounts":
return Promise.resolve({ accounts: [], shielded: "zs1mock", transparent: TRANSPARENT_ADDRESS });
case "zcash_getBalance":
return Promise.resolve({
available: "1.42",
shielded: "1.5",
spendable: "1.45",
total: "1.6",
transparent: "0.1",
...balance,
});
case "zcash_sendTransaction":
return Promise.resolve("mocktxid123");
case "zcash_signMessage":
return Promise.resolve({
address: TRANSPARENT_ADDRESS,
pubkey: "02aa",
signature: "sigabc",
signingMode: "current",
});
default:
return Promise.reject(new Error(`unexpected method ${method}`));
}
},
},
},
};

return requests;
}

async function connectAndGetWallet() {
let addedWallet = null;
const connect = noirWallet.connectNoirWallet.connectWallet({
addChain: (wallet) => {
addedWallet = wallet;
},
});

await connect([Chain.Zcash]);

return addedWallet;
}

describe("noirWallet", () => {
let requests = [];

beforeEach(() => {
requests = mockNoirWallet();
});

test("exposes Zcash as the only supported chain", () => {
expect(noirWallet.connectNoirWallet.supportedChains).toEqual([Chain.Zcash]);
});

test("connects via zcash_requestAccounts and registers the transparent address", async () => {
const wallet = await connectAndGetWallet();

expect(requests.some(({ method }) => method === "zcash_requestAccounts")).toBe(true);
expect(wallet.address).toBe(TRANSPARENT_ADDRESS);
expect(wallet.chain).toBe(Chain.Zcash);
expect(wallet.walletType).toBe(WalletOption.NOIR_WALLET);
});

test("getBalance reads the wallet's spendable (shielded pool) balance", async () => {
const wallet = await connectAndGetWallet();
const [balance] = await wallet.getBalance();

expect(balance.chain).toBe(Chain.Zcash);
expect(balance.getValue("string")).toBe("1.42");
});

test("transfer without memo omits memo field", async () => {
const wallet = await connectAndGetWallet();
const assetValue = AssetValue.from({ chain: Chain.Zcash, value: "0.5" });

const txid = await wallet.transfer({ assetValue, recipient: "t1RecipientAddr" });

expect(txid).toBe("mocktxid123");
const sendCall = requests.find(({ method }) => method === "zcash_sendTransaction");
expect(sendCall.params).toEqual([{ amount: "0.5", to: "t1RecipientAddr" }]);
});

test("transfer with memo passes it to the extension", async () => {
const wallet = await connectAndGetWallet();
const assetValue = AssetValue.from({ chain: Chain.Zcash, value: "0.5" });

const txid = await wallet.transfer({ assetValue, memo: "test memo", recipient: "zs1shieldedAddr" });

expect(txid).toBe("mocktxid123");
const sendCall = requests.find(({ method }) => method === "zcash_sendTransaction");
expect(sendCall.params).toEqual([{ amount: "0.5", memo: "test memo", to: "zs1shieldedAddr" }]);
});

test("signMessage returns the signature string", async () => {
const wallet = await connectAndGetWallet();

expect(await wallet.signMessage("hello swapkit")).toBe("sigabc");
});

test("throws when the extension is not installed", () => {
globalThis.window = {};

const connect = noirWallet.connectNoirWallet.connectWallet({ addChain: () => undefined });

expect(connect([Chain.Zcash])).rejects.toThrow("wallet_noir_wallet_not_found");
});
});
78 changes: 78 additions & 0 deletions packages/wallet-extensions/src/noir-wallet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { AssetValue, Chain, type GenericTransferParams, SwapKitError, WalletOption } from "@swapkit/helpers";
import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core";
import type { NoirWalletZcashProvider } from "../types";

export function getNoirWalletZcashProvider(): NoirWalletZcashProvider {
const provider = window.noirwallet?.zcash;

if (!provider) {
throw new SwapKitError("wallet_noir_wallet_not_found");
}

return provider;
}

export const noirWallet = createWallet({
connect: ({ addChain, walletType }) =>
async function connectNoirWallet(_chains?: Chain[]) {
const provider = getNoirWalletZcashProvider();

// Opens the extension's connect-approval popup and returns the primary
// account's addresses.
const account = await provider.request({ method: "zcash_requestAccounts" });

if (!account?.transparent) {
throw new SwapKitError("wallet_noir_wallet_connection_failed");
}

const { getUtxoToolbox } = await import("@swapkit/toolboxes/utxo");
const toolbox = await getUtxoToolbox(Chain.Zcash);

addChain({
...toolbox,
address: account.transparent,
chain: Chain.Zcash,
/**
* Noir Wallet spends from the shielded pool, so the spendable balance
* comes from the wallet itself rather than the transparent address'
* UTXO set.
*/
getBalance: async () => {
const balance = await provider.request({ method: "zcash_getBalance" });
const value = balance?.available ?? balance?.spendable ?? balance?.total ?? "0";

return [AssetValue.from({ chain: Chain.Zcash, value })];
},
signMessage: async (message: string) => {
const { signature } = await provider.request({ method: "zcash_signMessage", params: [message, {}] });

return signature;
},
/**
* Transaction building, fee selection and signing happen inside the
* extension. Memos are supported for shielded recipients (zs/u1);
* the extension rejects memos to transparent addresses (t1/t3),
* which means OP_RETURN-based routes (Maya/THORChain) will fail at
* the wallet level while deposit-address routes (NEAR Intents) work.
*/
transfer: ({ recipient, assetValue, memo }: GenericTransferParams) => {
if (!(recipient && assetValue)) {
throw new SwapKitError("wallet_missing_params", { params: { assetValue, recipient } });
}

return provider.request({
method: "zcash_sendTransaction",
params: [{ amount: assetValue.getValue("string"), to: recipient, ...(memo && { memo }) }],
});
},
walletType,
});

return true;
},
name: "connectNoirWallet",
supportedChains: [Chain.Zcash],
walletType: WalletOption.NOIR_WALLET,
});

export const NOIR_WALLET_SUPPORTED_CHAINS = getWalletSupportedChains(noirWallet);
9 changes: 9 additions & 0 deletions packages/wallet-extensions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export type VultisigCosmosProvider = {
request(request: { method: string; params?: any[] | Record<string, any> }, callback?: Callback): Promise<any>;
};

export type NoirWalletZcashProvider = {
request(request: { method: string; params?: any[] }): Promise<any>;
on(event: string, handler: (...args: any[]) => void): void;
removeListener?(event: string, handler: (...args: any[]) => void): void;
disconnect(): Promise<any>;
};

declare global {
interface Window {
injectedWeb3?: SubstrateInjectedExtension;
Expand Down Expand Up @@ -48,6 +55,8 @@ declare global {
near: NearBrowserWalletProvider;
};

noirwallet?: { isNoirWallet: boolean; version?: string; zcash: NoirWalletZcashProvider };

vultisig?: {
bitcoin: Eip1193Provider;
bitcoincash: Eip1193Provider;
Expand Down
6 changes: 6 additions & 0 deletions packages/wallets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@
"require": "./dist/src/near-wallet-selector/index.cjs",
"types": "./dist/types/near-wallet-selector/index.d.ts"
},
"./noir-wallet": {
"bun": "./src/noir-wallet.ts",
"default": "./dist/src/noir-wallet.js",
"require": "./dist/src/noir-wallet.cjs",
"types": "./dist/types/noir-wallet.d.ts"
},
"./okx": {
"bun": "./src/okx.ts",
"default": "./dist/src/okx.js",
Expand Down
1 change: 1 addition & 0 deletions packages/wallets/src/noir-wallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "@swapkit/wallet-extensions/noir-wallet";
3 changes: 3 additions & 0 deletions packages/wallets/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ctrlWallet } from "@swapkit/wallet-extensions/ctrl";
import type { evmWallet } from "@swapkit/wallet-extensions/evm-extensions";
import type { keepkeyBexWallet } from "@swapkit/wallet-extensions/keepkey-bex";
import type { keplrWallet } from "@swapkit/wallet-extensions/keplr";
import type { noirWallet } from "@swapkit/wallet-extensions/noir-wallet";
import type { okxWallet } from "@swapkit/wallet-extensions/okx";
import type { onekeyWallet } from "@swapkit/wallet-extensions/onekey";
import type { phantomWallet } from "@swapkit/wallet-extensions/phantom";
Expand Down Expand Up @@ -40,6 +41,7 @@ export type SKWallets = {
[WalletOption.LEDGER]: typeof ledgerWallet;
[WalletOption.LEDGER_LIVE]: typeof ledgerWallet;
[WalletOption.METAMASK]: typeof evmWallet;
[WalletOption.NOIR_WALLET]: typeof noirWallet;
[WalletOption.OKX]: typeof okxWallet;
[WalletOption.OKX_MOBILE]: typeof evmWallet;
[WalletOption.ONEKEY]: typeof onekeyWallet;
Expand Down Expand Up @@ -76,6 +78,7 @@ export type SKWalletsSupportedChains = {
[WalletOption.LEDGER]: typeof ledgerWallet.connectLedger.supportedChains;
[WalletOption.LEDGER_LIVE]: typeof ledgerWallet.connectLedger.supportedChains;
[WalletOption.METAMASK]: typeof evmWallet.connectEVMWallet.supportedChains;
[WalletOption.NOIR_WALLET]: typeof noirWallet.connectNoirWallet.supportedChains;
[WalletOption.OKX]: typeof okxWallet.connectOkx.supportedChains;
[WalletOption.OKX_MOBILE]: typeof evmWallet.connectEVMWallet.supportedChains;
[WalletOption.ONEKEY]: typeof onekeyWallet.connectOnekeyWallet.supportedChains;
Expand Down
1 change: 1 addition & 0 deletions packages/wallets/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export async function loadWallet<W extends WalletOption>(walletOption: W): Promi
.with(WalletOption.CTRL, async () => (await import("@swapkit/wallet-extensions/ctrl")).ctrlWallet)
.with(WalletOption.VULTISIG, async () => (await import("@swapkit/wallet-extensions/vultisig")).vultisigWallet)
.with(WalletOption.OKX, async () => (await import("@swapkit/wallet-extensions/okx")).okxWallet)
.with(WalletOption.NOIR_WALLET, async () => (await import("@swapkit/wallet-extensions/noir-wallet")).noirWallet)
.with(WalletOption.ONEKEY, async () => (await import("@swapkit/wallet-extensions/onekey")).onekeyWallet)
.with(WalletOption.EXODUS, async () => (await import("./passkeys")).passkeysWallet)
.with(WalletOption.KEEPKEY, async () => (await import("@swapkit/wallet-hardware/keepkey")).keepkeyWallet)
Expand Down
4 changes: 4 additions & 0 deletions playgrounds/vite/src/WalletPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { KEEPKEY_SUPPORTED_CHAINS } from "@swapkit/wallets/keepkey";
import { KEEPKEY_BEX_SUPPORTED_CHAINS } from "@swapkit/wallets/keepkey-bex";
import { KEPLR_SUPPORTED_CHAINS } from "@swapkit/wallets/keplr";
import { decryptFromKeystore, KEYSTORE_SUPPORTED_CHAINS } from "@swapkit/wallets/keystore";
import { NOIR_WALLET_SUPPORTED_CHAINS } from "@swapkit/wallets/noir-wallet";
import { OKX_SUPPORTED_CHAINS } from "@swapkit/wallets/okx";
import { ONEKEY_WALLET_SUPPORTED_CHAINS } from "@swapkit/wallets/onekey";
import { PASSKEYS_SUPPORTED_CHAINS } from "@swapkit/wallets/passkeys";
Expand Down Expand Up @@ -51,6 +52,7 @@ export const availableChainsByWallet = {
[WalletOption.LEAP]: KEPLR_SUPPORTED_CHAINS,
[WalletOption.LEDGER]: LEDGER_SUPPORTED_CHAINS,
[WalletOption.METAMASK]: EVMChains,
[WalletOption.NOIR_WALLET]: NOIR_WALLET_SUPPORTED_CHAINS,
[WalletOption.OKX]: OKX_SUPPORTED_CHAINS,
[WalletOption.OKX_MOBILE]: EVMChains,
[WalletOption.ONEKEY]: ONEKEY_WALLET_SUPPORTED_CHAINS,
Expand Down Expand Up @@ -134,6 +136,8 @@ export const WalletPicker = ({ skClient, setWallet, setPhrase }: Props) => {
return skClient.connectVultisig?.(chainsToConnect);
case WalletOption.OKX:
return skClient.connectOkx?.(chainsToConnect);
case WalletOption.NOIR_WALLET:
return skClient.connectNoirWallet?.(chainsToConnect);
case WalletOption.POLKADOT_JS:
return skClient.connectPolkadotJs?.(chainsToConnect as Chain.Polkadot[]);

Expand Down
Loading