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
6 changes: 6 additions & 0 deletions .changeset/noir-wallet-zcash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@swapkit/wallet-extensions": minor
"@swapkit/wallets": minor
---

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). Requires `@swapkit/helpers` with `WalletOption.NOIR_WALLET` and the `wallet_noir_wallet_*` error codes.
6 changes: 6 additions & 0 deletions packages/wallet-extensions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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,127 @@
// @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 delegates to zcash_sendTransaction without memo/type fields", 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 throws (OP_RETURN routes unsupported)", async () => {
const wallet = await connectAndGetWallet();
const assetValue = AssetValue.from({ chain: Chain.Zcash, value: "0.5" });

expect(() => wallet.transfer({ assetValue, memo: "=:BTC.BTC:bc1q...", recipient: "t1RecipientAddr" })).toThrow(
"wallet_noir_wallet_memo_not_supported",
);
});

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");
});
});
86 changes: 86 additions & 0 deletions packages/wallet-extensions/src/noir-wallet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { AssetValue, Chain, type GenericTransferParams, SwapKitError, WalletOption } from "@swapkit/helpers";
import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core";
import type { NoirWalletZcashProvider } from "../types";
import type { ExtensionWallet } from "../walletTypes";

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

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

return provider;
}

export const noirWallet: ExtensionWallet<"connectNoirWallet"> = 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 only supported towards shielded recipients,
* which rules out OP_RETURN-based routes (e.g. Maya/THORChain) —
* deposit-address routes such as NEAR Intents work.
*/
transfer: ({ recipient, assetValue, memo }: GenericTransferParams) => {
if (memo) {
throw new SwapKitError("wallet_noir_wallet_memo_not_supported");
}

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 }],
});
},
walletType,
});

return true;
},
// Raw-sign RPC is not exposed by the extension; transactions are built and
// signed inside the wallet.
directSigningSupport: {},
name: "connectNoirWallet",
supportedChains: [Chain.Zcash],
walletType: WalletOption.NOIR_WALLET,
});

export const NOIR_WALLET_SUPPORTED_CHAINS = getWalletSupportedChains(noirWallet);
export type NoirWalletSupportedChain = (typeof NOIR_WALLET_SUPPORTED_CHAINS)[number];
9 changes: 9 additions & 0 deletions packages/wallet-extensions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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>;
};

type CtrlInjectedProviders = {
binance: Eip1193Provider;
bitcoin: Eip1193Provider;
Expand Down Expand Up @@ -52,6 +59,8 @@ declare global {
ctrl?: CtrlInjectedProviders;
xfi?: CtrlInjectedProviders;

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

vultisig?: {
bitcoin: Eip1193Provider;
bitcoincash: Eip1193Provider;
Expand Down
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 { petraWallet } from "@swapkit/wallet-extensions/petra";
Expand Down Expand Up @@ -41,6 +42,7 @@ export type SKWallets = {
[WalletOption.LEAP]: typeof keplrWallet;
[WalletOption.LEDGER]: 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 @@ -117,6 +119,7 @@ export type SKWalletsSupportedChains = {
[WalletOption.LEAP]: typeof keplrWallet.connectKeplr.supportedChains;
[WalletOption.LEDGER]: 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 @@ -45,6 +45,7 @@ export async function loadWallet<W extends keyof SKWallets>(walletOption: W): Pr
.with(WalletOption.LEDGER, async () => (await import("@swapkit/wallet-hardware/ledger")).ledgerWallet)
.with(WalletOption.PASSKEYS, WalletOption.PASSKEY_WALLET, async () => (await import("./passkeys")).passkeysWallet)
.with(WalletOption.PETRA, async () => (await import("@swapkit/wallet-extensions/petra")).petraWallet)
.with(WalletOption.NOIR_WALLET, async () => (await import("@swapkit/wallet-extensions/noir-wallet")).noirWallet)
.with(WalletOption.PHANTOM, async () => (await import("@swapkit/wallet-extensions/phantom")).phantomWallet)
.with(WalletOption.POLKADOT_JS, async () => (await import("@swapkit/wallet-extensions/polkadotjs")).polkadotWallet)
.with(WalletOption.RADIX_WALLET, async () => (await import("./radix")).radixWallet)
Expand Down
Loading