diff --git a/.changeset/noir-wallet-zcash.md b/.changeset/noir-wallet-zcash.md new file mode 100644 index 0000000..816e18c --- /dev/null +++ b/.changeset/noir-wallet-zcash.md @@ -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. diff --git a/packages/wallet-extensions/package.json b/packages/wallet-extensions/package.json index 09c3b7a..915629b 100644 --- a/packages/wallet-extensions/package.json +++ b/packages/wallet-extensions/package.json @@ -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", diff --git a/packages/wallet-extensions/src/noir-wallet/__tests__/noir-wallet.test.ts b/packages/wallet-extensions/src/noir-wallet/__tests__/noir-wallet.test.ts new file mode 100644 index 0000000..d11f55e --- /dev/null +++ b/packages/wallet-extensions/src/noir-wallet/__tests__/noir-wallet.test.ts @@ -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"); + }); +}); diff --git a/packages/wallet-extensions/src/noir-wallet/index.ts b/packages/wallet-extensions/src/noir-wallet/index.ts new file mode 100644 index 0000000..0c82d3f --- /dev/null +++ b/packages/wallet-extensions/src/noir-wallet/index.ts @@ -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]; diff --git a/packages/wallet-extensions/src/types.ts b/packages/wallet-extensions/src/types.ts index 138a9a1..c27ead4 100644 --- a/packages/wallet-extensions/src/types.ts +++ b/packages/wallet-extensions/src/types.ts @@ -22,6 +22,13 @@ export type VultisigCosmosProvider = { request(request: { method: string; params?: any[] | Record }, callback?: Callback): Promise; }; +export type NoirWalletZcashProvider = { + request(request: { method: string; params?: any[] }): Promise; + on(event: string, handler: (...args: any[]) => void): void; + removeListener?(event: string, handler: (...args: any[]) => void): void; + disconnect(): Promise; +}; + type CtrlInjectedProviders = { binance: Eip1193Provider; bitcoin: Eip1193Provider; @@ -52,6 +59,8 @@ declare global { ctrl?: CtrlInjectedProviders; xfi?: CtrlInjectedProviders; + noirwallet?: { isNoirWallet: boolean; version?: string; zcash: NoirWalletZcashProvider }; + vultisig?: { bitcoin: Eip1193Provider; bitcoincash: Eip1193Provider; diff --git a/packages/wallets/src/types.ts b/packages/wallets/src/types.ts index 9df2c6b..51bad1e 100644 --- a/packages/wallets/src/types.ts +++ b/packages/wallets/src/types.ts @@ -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"; @@ -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; @@ -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; diff --git a/packages/wallets/src/utils.ts b/packages/wallets/src/utils.ts index c2c1c1a..ef6ac1b 100644 --- a/packages/wallets/src/utils.ts +++ b/packages/wallets/src/utils.ts @@ -45,6 +45,7 @@ export async function loadWallet(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)