diff --git a/.github/workflows/pr-validate.yaml b/.github/workflows/pr-validate.yaml index fbe0c464..15ad545f 100644 --- a/.github/workflows/pr-validate.yaml +++ b/.github/workflows/pr-validate.yaml @@ -59,7 +59,7 @@ jobs: # Test files (*.test.ts) are excluded — their docblocks legitimately name the # forbidden tokens to document the policy; they cannot themselves move funds. if grep -rE --include='*.ts' --include='*.vue' --exclude='*.test.ts' --exclude='*.spec.ts' \ - '\b(signTx|signDirect|signAndBroadcast|broadcastTx|simulate[A-Za-z]*Tx|NolusWalletFactory)\b|@/networks/(cosm|evm|sol)/.*Wallet|wallet\.(sign|broadcast)' \ + '\b(signTx|signDirect|signAndBroadcast|broadcastTx|simulate[A-Za-z]*Tx|NolusWalletFactory)\b|@/networks/(cosm|sol)/.*Wallet|wallet\.(sign|broadcast)' \ src/common/webmcp/ 2>/dev/null; then echo "FAIL: src/common/webmcp/ references a signing/broadcast surface — policy violation." echo " WebMCP scope is connect + read + navigate only. See src/common/webmcp/tools.ts docblock." diff --git a/package-lock.json b/package-lock.json index 7bff376b..a886a44a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,14 +30,13 @@ "cosmjs-types-legacy": "npm:cosmjs-types@0.8.0", "d3": "^7.9.0", "dompurify": "^3.3.1", - "ethers": "^6.16.0", "marked": "^17.0.0", "motion-plus-vue": "^1.6.0", "pinia": "^3.0.4", "vue": "^3.5.24", "vue-i18n": "11.2.8", "vue-router": "^4.6.3", - "web-components": "github:nolus-protocol/web-components#v2.0.65", + "web-components": "github:nolus-protocol/web-components#v2.0.66", "zod": "^4.3.6" }, "devDependencies": { @@ -79,12 +78,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@adraffy/ens-normalize": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", - "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", - "license": "MIT" - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -4657,12 +4650,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/aes-js": { - "version": "4.0.0-beta.5", - "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", - "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", - "license": "MIT" - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -6941,100 +6928,6 @@ "node": ">= 0.6" } }, - "node_modules/ethers": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", - "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/ethers-io/" - }, - { - "type": "individual", - "url": "https://www.buymeacoffee.com/ricmoo" - } - ], - "license": "MIT", - "dependencies": { - "@adraffy/ens-normalize": "1.10.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.2", - "@types/node": "22.7.5", - "aes-js": "4.0.0-beta.5", - "tslib": "2.7.0", - "ws": "8.17.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ethers/node_modules/@noble/curves": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", - "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/ethers/node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/ethers/node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD" - }, - "node_modules/ethers/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT" - }, - "node_modules/ethers/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", diff --git a/package.json b/package.json index a929a83e..a69b1f40 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "cosmjs-types-legacy": "npm:cosmjs-types@0.8.0", "d3": "^7.9.0", "dompurify": "^3.3.1", - "ethers": "^6.16.0", "marked": "^17.0.0", "motion-plus-vue": "^1.6.0", "pinia": "^3.0.4", diff --git a/src/common/components/WalletInfo.vue b/src/common/components/WalletInfo.vue index 48a58ba2..4a575a28 100644 --- a/src/common/components/WalletInfo.vue +++ b/src/common/components/WalletInfo.vue @@ -106,7 +106,7 @@ const connections: { icon: KeplrIcon, label: i18n.t("message.keplr") }, - [WalletConnectMechanism.EVM_PHANTOM]: { + [WalletConnectMechanism.SOL_PHANTOM]: { icon: PhantomIcon, label: i18n.t("message.phantom") }, diff --git a/src/common/stores/wallet/actions/connectPhantom.ts b/src/common/stores/wallet/actions/connectPhantom.ts index 9b5a8a32..b0c3f1d2 100644 --- a/src/common/stores/wallet/actions/connectPhantom.ts +++ b/src/common/stores/wallet/actions/connectPhantom.ts @@ -5,20 +5,20 @@ import { NolusWalletFactory } from "@nolus/nolusjs"; import { WalletConnectMechanism } from "@/common/types"; import { Buffer } from "buffer"; import { IntercomService } from "@/common/utils/IntercomService"; -import { MetaMaskWallet } from "@/networks/evm"; +import { SolanaWallet } from "@/networks/sol"; import { applyNolusWalletOverrides } from "@/networks/cosm/NolusWalletOverride"; export async function connectPhantom(this: Store) { - const metamask = new MetaMaskWallet(); - const { pubkeyAny } = await metamask.connect(WalletConnectMechanism.EVM_PHANTOM); - const signer = metamask.makeWCOfflineSigner(); + const sol = new SolanaWallet("phantom"); + const { pubkeyAny } = await sol.connect(); + const signer = sol.makeWCOfflineSigner(); const nolusWalletOfflineSigner = await NolusWalletFactory.nolusOfflineSigner(signer); await nolusWalletOfflineSigner.useAccount(); - WalletManager.saveWalletConnectMechanism(WalletConnectMechanism.EVM_PHANTOM); + WalletManager.saveWalletConnectMechanism(WalletConnectMechanism.SOL_PHANTOM); WalletManager.setPubKey(Buffer.from(pubkeyAny).toString("hex")); - applyWalletProtocolFilter(WalletConnectMechanism.EVM_PHANTOM); + applyWalletProtocolFilter(WalletConnectMechanism.SOL_PHANTOM); this.wallet = nolusWalletOfflineSigner; applyNolusWalletOverrides(this.wallet); diff --git a/src/common/stores/wallet/actions/connectSolflare.ts b/src/common/stores/wallet/actions/connectSolflare.ts index 735ef61c..0f127155 100644 --- a/src/common/stores/wallet/actions/connectSolflare.ts +++ b/src/common/stores/wallet/actions/connectSolflare.ts @@ -9,7 +9,7 @@ import { SolanaWallet } from "@/networks/sol"; import { applyNolusWalletOverrides } from "@/networks/cosm/NolusWalletOverride"; export async function connectSolflare(this: Store) { - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); const { pubkeyAny } = await sol.connect(); const signer = sol.makeWCOfflineSigner(); diff --git a/src/common/stores/wallet/actions/index.ts b/src/common/stores/wallet/actions/index.ts index 92b887fa..dbe48e26 100644 --- a/src/common/stores/wallet/actions/index.ts +++ b/src/common/stores/wallet/actions/index.ts @@ -13,7 +13,7 @@ export const actions = { [WalletActions.DISCONNECT]: disconnect, [WalletActions.CONNECT_KEPLR]: connectKeplr, [WalletActions.CONNECT_LEDGER]: connectLedger, - [WalletActions.CONNECT_EVM_PHANTOM]: connectPhantom, + [WalletActions.CONNECT_SOL_PHANTOM]: connectPhantom, [WalletActions.CONNECT_SOL_SOLFLARE]: connectSolflare, [WalletActions.LOAD_VESTED_TOKENS]: loadVestedTokens, [WalletActions.LOAD_APR]: loadApr, diff --git a/src/common/stores/wallet/actions/protocolFilter.test.ts b/src/common/stores/wallet/actions/protocolFilter.test.ts index 46089582..495c6fe2 100644 --- a/src/common/stores/wallet/actions/protocolFilter.test.ts +++ b/src/common/stores/wallet/actions/protocolFilter.test.ts @@ -99,20 +99,18 @@ vi.mock("@/common/utils", async () => { }; }); -// MetaMaskWallet (EVM) — stub `connect` and `makeWCOfflineSigner`. -vi.mock("@/networks/evm", () => ({ - MetaMaskWallet: vi.fn().mockImplementation(() => ({ - connect: vi.fn().mockResolvedValue({ pubkeyAny: new Uint8Array([1, 2, 3]) }), - makeWCOfflineSigner: vi.fn().mockReturnValue({}) - })) -})); - -// SolanaWallet — same shape as MetaMaskWallet. +// SolanaWallet — both Phantom and Solflare connect actions construct one. +// Capture the `provider` ctor arg per call so the assertions below can verify +// connectPhantom passes "phantom" and connectSolflare passes "solflare". +const solanaWalletCtor = vi.fn(); vi.mock("@/networks/sol", () => ({ - SolanaWallet: vi.fn().mockImplementation(() => ({ - connect: vi.fn().mockResolvedValue({ pubkeyAny: new Uint8Array([4, 5, 6]) }), - makeWCOfflineSigner: vi.fn().mockReturnValue({}) - })) + SolanaWallet: vi.fn().mockImplementation((...args: unknown[]) => { + solanaWalletCtor(...args); + return { + connect: vi.fn().mockResolvedValue({ pubkeyAny: new Uint8Array([4, 5, 6]) }), + makeWCOfflineSigner: vi.fn().mockReturnValue({}) + }; + }) })); // Ledger transports never run in jsdom. Stub create() to a minimal object. @@ -139,6 +137,7 @@ import type { Store } from "../types"; beforeEach(() => { setProtocolFilter.mockClear(); + solanaWalletCtor.mockClear(); localStorage.clear(); }); @@ -188,6 +187,14 @@ describe("connectPhantom", () => { expect(setProtocolFilter).toHaveBeenCalledTimes(1); expect(setProtocolFilter).toHaveBeenCalledWith("SOLANA"); }); + + it("constructs SolanaWallet with provider='phantom'", async () => { + const store = makeStore(); + await connectPhantom.call(store); + + expect(solanaWalletCtor).toHaveBeenCalledTimes(1); + expect(solanaWalletCtor).toHaveBeenCalledWith("phantom"); + }); }); describe("connectSolflare", () => { @@ -198,6 +205,14 @@ describe("connectSolflare", () => { expect(setProtocolFilter).toHaveBeenCalledTimes(1); expect(setProtocolFilter).toHaveBeenCalledWith("SOLANA"); }); + + it("constructs SolanaWallet with provider='solflare'", async () => { + const store = makeStore(); + await connectSolflare.call(store); + + expect(solanaWalletCtor).toHaveBeenCalledTimes(1); + expect(solanaWalletCtor).toHaveBeenCalledWith("solflare"); + }); }); describe("connectLedger (sunset, intentionally unwired)", () => { diff --git a/src/common/stores/wallet/index.test.ts b/src/common/stores/wallet/index.test.ts index 11aadf59..847b2855 100644 --- a/src/common/stores/wallet/index.test.ts +++ b/src/common/stores/wallet/index.test.ts @@ -30,7 +30,7 @@ vi.mock("./actions", () => ({ DISCONNECT: () => undefined, CONNECT_KEPLR: () => undefined, CONNECT_LEDGER: () => undefined, - CONNECT_EVM_PHANTOM: () => undefined, + CONNECT_SOL_PHANTOM: () => undefined, CONNECT_SOL_SOLFLARE: () => undefined, LOAD_VESTED_TOKENS: () => undefined, LOAD_APR: () => undefined, @@ -55,6 +55,8 @@ vi.mock("./getters", () => ({ })); import { useWalletStore, WalletActions } from "./index"; +import { WalletConnectMechanism } from "@/common/types"; +import { walletActionMap } from "@/common/utils/WalletConnect"; describe("useWalletStore", () => { beforeEach(() => { @@ -110,7 +112,7 @@ describe("useWalletStore", () => { expect(typeof store[WalletActions.DISCONNECT]).toBe("function"); expect(typeof store[WalletActions.CONNECT_KEPLR]).toBe("function"); expect(typeof store[WalletActions.CONNECT_LEDGER]).toBe("function"); - expect(typeof store[WalletActions.CONNECT_EVM_PHANTOM]).toBe("function"); + expect(typeof store[WalletActions.CONNECT_SOL_PHANTOM]).toBe("function"); expect(typeof store[WalletActions.CONNECT_SOL_SOLFLARE]).toBe("function"); expect(typeof store[WalletActions.LOAD_VESTED_TOKENS]).toBe("function"); expect(typeof store[WalletActions.LOAD_APR]).toBe("function"); @@ -140,6 +142,36 @@ describe("useWalletStore", () => { // Smoke check that the enum re-export path is intact. expect(WalletActions.DISCONNECT).toBe("DISCONNECT"); expect(WalletActions.CONNECT_KEPLR).toBe("CONNECT_KEPLR"); + expect(WalletActions.CONNECT_SOL_PHANTOM).toBe("CONNECT_SOL_PHANTOM"); expect(WalletActions.LOAD_APR).toBe("LOAD_APR"); }); + + // End-to-end mechanism → action wiring guard. + // + // Catches the Pinia "action-name string-value desync" failure mode: if the + // WalletActions enum member name and its string value drift apart (e.g. the + // member is renamed but the string value is left as the old name), Pinia + // registers actions under the string value while the rest of the codebase + // looks them up by enum name — and the call vanishes silently. + // + // For every WalletConnectMechanism, walletActionMap must resolve to a + // WalletActions value, AND that value must be a callable function on the + // store surface. + describe("end-to-end mechanism → action wiring", () => { + it.each(Object.values(WalletConnectMechanism))( + "mechanism %s resolves to a registered store action", + (mechanism) => { + const action = walletActionMap[mechanism]; + expect(action, `walletActionMap[${mechanism}] is undefined`).toBeDefined(); + // The action key must be a WalletActions enum value. + expect(Object.values(WalletActions)).toContain(action); + + const store = useWalletStore(); + expect( + typeof store[action as keyof typeof store], + `store does not expose a function for action key ${action}` + ).toBe("function"); + } + ); + }); }); diff --git a/src/common/stores/wallet/types/actions.ts b/src/common/stores/wallet/types/actions.ts index 82812560..bbf8c9b2 100644 --- a/src/common/stores/wallet/types/actions.ts +++ b/src/common/stores/wallet/types/actions.ts @@ -1,7 +1,7 @@ export enum WalletActions { CONNECT_KEPLR = "CONNECT_KEPLR", CONNECT_LEDGER = "CONNECT_LEDGER", - CONNECT_EVM_PHANTOM = "CONNECT_EVM_PHANTOM", + CONNECT_SOL_PHANTOM = "CONNECT_SOL_PHANTOM", CONNECT_SOL_SOLFLARE = "CONNECT_SOL_SOLFLARE", LOAD_VESTED_TOKENS = "LOAD_VESTED_TOKENS", diff --git a/src/common/types/WalletConnectMechanism.ts b/src/common/types/WalletConnectMechanism.ts index 4a237a58..398fa5f9 100644 --- a/src/common/types/WalletConnectMechanism.ts +++ b/src/common/types/WalletConnectMechanism.ts @@ -2,6 +2,6 @@ export enum WalletConnectMechanism { KEPLR = "extension", LEDGER = "ledger", LEDGER_BLUETOOTH = "ledger_bluetooth", - EVM_PHANTOM = "evm_phantom", + SOL_PHANTOM = "sol_phantom", SOL_SOLFLARE = "sol_solflare" } diff --git a/src/common/utils/WalletConnect.ts b/src/common/utils/WalletConnect.ts index 00a91b08..2db5ea3d 100644 --- a/src/common/utils/WalletConnect.ts +++ b/src/common/utils/WalletConnect.ts @@ -9,7 +9,7 @@ import { WalletManager } from "."; import { getCurrencyByDenom } from "./CurrencyLookup"; import { type NetworkData, WalletConnectMechanism } from "@/common/types"; import { authenticateKeplr, authenticateLedger, type BaseWallet, type Wallet } from "@/networks"; -import { authenticateEvmPhantom, authenticateSolFlare } from "@/networks/cosm/WalletFactory"; +import { authenticatePhantom, authenticateSolFlare } from "@/networks/cosm/WalletFactory"; export const validateAddress = (address: string) => { if (!address || address.trim() == "") { @@ -52,9 +52,9 @@ export const validateAmountV2 = (amount: string, amount2: string) => { return ""; }; -const walletActionMap: Record = { +export const walletActionMap: Record = { [WalletConnectMechanism.KEPLR]: WalletActions.CONNECT_KEPLR, - [WalletConnectMechanism.EVM_PHANTOM]: WalletActions.CONNECT_EVM_PHANTOM, + [WalletConnectMechanism.SOL_PHANTOM]: WalletActions.CONNECT_SOL_PHANTOM, [WalletConnectMechanism.SOL_SOLFLARE]: WalletActions.CONNECT_SOL_SOLFLARE, [WalletConnectMechanism.LEDGER]: WalletActions.CONNECT_LEDGER, [WalletConnectMechanism.LEDGER_BLUETOOTH]: WalletActions.CONNECT_LEDGER @@ -65,7 +65,7 @@ const externalWalletMap: Record< (wallet: Wallet, network: NetworkData) => Promise > = { [WalletConnectMechanism.KEPLR]: authenticateKeplr, - [WalletConnectMechanism.EVM_PHANTOM]: authenticateEvmPhantom, + [WalletConnectMechanism.SOL_PHANTOM]: authenticatePhantom, [WalletConnectMechanism.SOL_SOLFLARE]: authenticateSolFlare, [WalletConnectMechanism.LEDGER]: authenticateLedger, [WalletConnectMechanism.LEDGER_BLUETOOTH]: authenticateLedger diff --git a/src/common/utils/WalletUtils.test.ts b/src/common/utils/WalletUtils.test.ts index 1a97115c..32b73fb0 100644 --- a/src/common/utils/WalletUtils.test.ts +++ b/src/common/utils/WalletUtils.test.ts @@ -96,13 +96,28 @@ describe("WalletUtils.getKeplr", () => { vi.restoreAllMocks(); }); - it("should return window.keplr when extension is already set", async () => { - const fakeKeplr = { marker: "keplr-instance" }; + it("should return window.keplr when extension is already set and identifies as Keplr", async () => { + const fakeKeplr = { isKeplr: true, marker: "keplr-instance" }; w.keplr = fakeKeplr; const result = await WalletUtils.getKeplr(); expect(result).toBe(fakeKeplr); }); + it("should return undefined when window.keplr is set but isKeplr !== true", async () => { + // Defends against wallet-aggregator placeholder injection: a hostile extension can + // populate window.keplr with a Keplr-shaped object lacking the identity flag. + w.keplr = { marker: "aggregator-shim" }; + const result = await WalletUtils.getKeplr(); + expect(result).toBeUndefined(); + }); + + it("should return undefined when isKeplr is truthy-but-not-strictly-true", async () => { + // Strict equality `=== true` rejects truthy non-true values like 1 or "yes". + w.keplr = { isKeplr: 1, marker: "loose-keplr" }; + const result = await WalletUtils.getKeplr(); + expect(result).toBeUndefined(); + }); + it("should return the extension when document is complete and extension becomes available", async () => { // No w.keplr set; document.readyState in jsdom is "complete" by default. // The code path then does Promise.resolve(w[prop]) — returns undefined here. @@ -127,7 +142,7 @@ describe("WalletUtils.getKeplr", () => { configurable: true, get: () => "complete" }); - const fakeKeplr = { marker: "delayed-keplr" }; + const fakeKeplr = { isKeplr: true, marker: "delayed-keplr" }; w.keplr = fakeKeplr; document.dispatchEvent(new Event("readystatechange")); diff --git a/src/common/utils/WalletUtils.ts b/src/common/utils/WalletUtils.ts index 1c5e45a6..a2c3ca49 100644 --- a/src/common/utils/WalletUtils.ts +++ b/src/common/utils/WalletUtils.ts @@ -5,21 +5,28 @@ import { WalletManager } from "."; import { Wallet, NETWORK_DATA } from "@/networks"; import { fetchEndpoints } from "./EndpointService"; +// Defense against extensions that inject a `window.keplr` shim without identifying as +// Keplr. Real Keplr always sets `isKeplr === true`. Strict-equality guard mirrors the +// `isPhantom`/`isSolflare` checks in `src/networks/sol/wallet.ts` (PR #155). +function isRealKeplr(k?: Keplr): k is Keplr { + return (k as unknown as { isKeplr?: unknown })?.isKeplr === true; +} + function getKeplrExtension(): Promise { const w = window as unknown as { keplr?: Keplr }; - if (w.keplr) { + if (isRealKeplr(w.keplr)) { return Promise.resolve(w.keplr); } if (document.readyState === "complete") { - return Promise.resolve(w.keplr); + return Promise.resolve(isRealKeplr(w.keplr) ? w.keplr : undefined); } return new Promise((resolve) => { const documentStateChange = (event: Event) => { if (event.target && (event.target as Document).readyState === "complete") { - resolve(w.keplr); + resolve(isRealKeplr(w.keplr) ? w.keplr : undefined); document.removeEventListener("readystatechange", documentStateChange); } }; diff --git a/src/common/utils/walletProtocolFilter.test.ts b/src/common/utils/walletProtocolFilter.test.ts index 4775d653..7cadc71a 100644 --- a/src/common/utils/walletProtocolFilter.test.ts +++ b/src/common/utils/walletProtocolFilter.test.ts @@ -21,8 +21,8 @@ describe("protocolFilterForMechanism", () => { expect(protocolFilterForMechanism(WalletConnectMechanism.KEPLR)).toBe("OSMOSIS"); }); - it("maps EVM_PHANTOM to SOLANA", () => { - expect(protocolFilterForMechanism(WalletConnectMechanism.EVM_PHANTOM)).toBe("SOLANA"); + it("maps SOL_PHANTOM to SOLANA", () => { + expect(protocolFilterForMechanism(WalletConnectMechanism.SOL_PHANTOM)).toBe("SOLANA"); }); it("maps SOL_SOLFLARE to SOLANA", () => { @@ -53,8 +53,8 @@ describe("applyWalletProtocolFilter", () => { expect(setProtocolFilter).toHaveBeenCalledWith("OSMOSIS"); }); - it("calls setProtocolFilter('SOLANA') for EVM_PHANTOM", () => { - applyWalletProtocolFilter(WalletConnectMechanism.EVM_PHANTOM); + it("calls setProtocolFilter('SOLANA') for SOL_PHANTOM", () => { + applyWalletProtocolFilter(WalletConnectMechanism.SOL_PHANTOM); expect(setProtocolFilter).toHaveBeenCalledTimes(1); expect(setProtocolFilter).toHaveBeenCalledWith("SOLANA"); }); diff --git a/src/common/utils/walletProtocolFilter.ts b/src/common/utils/walletProtocolFilter.ts index 67a41635..5ad8e359 100644 --- a/src/common/utils/walletProtocolFilter.ts +++ b/src/common/utils/walletProtocolFilter.ts @@ -5,10 +5,11 @@ import { WalletConnectMechanism } from "@/common/types"; * Map a wallet-connect mechanism to the network the wallet owns. * * Keplr (and Keplr-likes routed through `connectKeplrLike`) own OSMOSIS; - * Phantom (EVM bridge) and Solflare both own SOLANA. Ledger / Ledger BLE - * are sunset and intentionally unwired — callers must NOT call - * `applyWalletProtocolFilter` on a Ledger connect path; this function - * still returns `undefined` for them so the wrapper degrades to "" cleanly. + * Phantom and Solflare both own SOLANA via the native Solana provider. + * Ledger / Ledger BLE are sunset and intentionally unwired — callers must + * NOT call `applyWalletProtocolFilter` on a Ledger connect path; this + * function still returns `undefined` for them so the wrapper degrades to + * "" cleanly. * * NOTE: when adding a new producible network here, also add it to the * `WALLET_PRODUCIBLE_NETWORKS` allowlist in `src/common/stores/config/index.ts` @@ -19,7 +20,7 @@ export function protocolFilterForMechanism(mechanism: WalletConnectMechanism | n switch (mechanism) { case WalletConnectMechanism.KEPLR: return "OSMOSIS"; - case WalletConnectMechanism.EVM_PHANTOM: + case WalletConnectMechanism.SOL_PHANTOM: case WalletConnectMechanism.SOL_SOLFLARE: return "SOLANA"; default: diff --git a/src/common/webmcp/tools.test.ts b/src/common/webmcp/tools.test.ts index 66a7a0bf..3fe9864c 100644 --- a/src/common/webmcp/tools.test.ts +++ b/src/common/webmcp/tools.test.ts @@ -19,6 +19,9 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import { WalletConnectMechanism } from "@/common/types"; +import { WalletActions } from "@/common/stores/wallet/types/actions"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const SOURCE = readFileSync(resolve(__dirname, "tools.ts"), "utf-8"); @@ -107,3 +110,75 @@ describe("WebMCP tool surface (source-level)", () => { } }); }); + +/** + * Gap-closure: source-level mechanism/action map consistency. + * + * Pattern-extracts MECHANISM_BY_LABEL and ACTION_BY_MECHANISM, then asserts: + * - every value in MECHANISM_BY_LABEL is a real WalletConnectMechanism member + * - every mechanism in MECHANISM_BY_LABEL is also a key in ACTION_BY_MECHANISM + * - every value of ACTION_BY_MECHANISM is a real WalletActions member + * - the "phantom" label is mapped to SOL_PHANTOM (locks in the rename) + * - SOL_PHANTOM is mapped to CONNECT_SOL_PHANTOM + * - the deleted EVM_PHANTOM / CONNECT_EVM_PHANTOM identifiers do not appear + * + * Catches the rename-desync failure mode where WalletConnectMechanism or + * WalletActions enums are renamed but tools.ts still references old members. + */ +describe("WebMCP mechanism/action map consistency", () => { + function extractObjectLiteral(source: string, name: string): Record { + const literalRe = new RegExp("const\\s+" + name + "[^=]*=\\s*\\{([\\s\\S]*?)\\}\\s*;"); + const m = source.match(literalRe); + if (!m) throw new Error("Could not find object literal for " + name); + const body = m[1]; + const out: Record = {}; + const entryRe = + /(?:(\w+)|\[\s*WalletConnectMechanism\.(\w+)\s*\])\s*:\s*(?:WalletConnectMechanism|WalletActions)\.(\w+)/g; + let match: RegExpExecArray | null; + while ((match = entryRe.exec(body)) !== null) { + const key = match[1] ?? match[2]; + const value = match[3]; + out[key] = value; + } + return out; + } + + const mechanismByLabel = extractObjectLiteral(SOURCE, "MECHANISM_BY_LABEL"); + const actionByMechanism = extractObjectLiteral(SOURCE, "ACTION_BY_MECHANISM"); + + it("every MECHANISM_BY_LABEL value is a real WalletConnectMechanism member", () => { + const validMembers = new Set(Object.keys(WalletConnectMechanism)); + for (const [label, member] of Object.entries(mechanismByLabel)) { + expect(validMembers, `label "${label}" maps to unknown mechanism ${member}`).toContain(member); + } + }); + + it("every mechanism in MECHANISM_BY_LABEL is also a key in ACTION_BY_MECHANISM", () => { + for (const [label, member] of Object.entries(mechanismByLabel)) { + expect( + actionByMechanism, + `mechanism for label "${label}" (${member}) missing from ACTION_BY_MECHANISM` + ).toHaveProperty(member); + } + }); + + it("every ACTION_BY_MECHANISM value is a real WalletActions member", () => { + const validActions = new Set(Object.keys(WalletActions)); + for (const [mechanism, action] of Object.entries(actionByMechanism)) { + expect(validActions, `mechanism ${mechanism} maps to unknown action ${action}`).toContain(action); + } + }); + + it("label 'phantom' maps to SOL_PHANTOM (post-rename contract)", () => { + expect(mechanismByLabel.phantom).toBe("SOL_PHANTOM"); + }); + + it("SOL_PHANTOM maps to CONNECT_SOL_PHANTOM in ACTION_BY_MECHANISM", () => { + expect(actionByMechanism.SOL_PHANTOM).toBe("CONNECT_SOL_PHANTOM"); + }); + + it("does NOT reference the removed EVM_PHANTOM mechanism or CONNECT_EVM_PHANTOM action", () => { + expect(SOURCE).not.toMatch(/EVM_PHANTOM/); + expect(SOURCE).not.toMatch(/CONNECT_EVM_PHANTOM/); + }); +}); diff --git a/src/common/webmcp/tools.ts b/src/common/webmcp/tools.ts index 50042718..df9112d3 100644 --- a/src/common/webmcp/tools.ts +++ b/src/common/webmcp/tools.ts @@ -47,14 +47,14 @@ export interface WebMcpTool { const MECHANISM_BY_LABEL: Record = { keplr: WalletConnectMechanism.KEPLR, - phantom: WalletConnectMechanism.EVM_PHANTOM, + phantom: WalletConnectMechanism.SOL_PHANTOM, solflare: WalletConnectMechanism.SOL_SOLFLARE, ledger: WalletConnectMechanism.LEDGER }; const ACTION_BY_MECHANISM: Record = { [WalletConnectMechanism.KEPLR]: WalletActions.CONNECT_KEPLR, - [WalletConnectMechanism.EVM_PHANTOM]: WalletActions.CONNECT_EVM_PHANTOM, + [WalletConnectMechanism.SOL_PHANTOM]: WalletActions.CONNECT_SOL_PHANTOM, [WalletConnectMechanism.SOL_SOLFLARE]: WalletActions.CONNECT_SOL_SOLFLARE, [WalletConnectMechanism.LEDGER]: WalletActions.CONNECT_LEDGER, [WalletConnectMechanism.LEDGER_BLUETOOTH]: WalletActions.CONNECT_LEDGER diff --git a/src/networks/cosm/NolusWalletOverride.ts b/src/networks/cosm/NolusWalletOverride.ts index c4315412..03d8778f 100644 --- a/src/networks/cosm/NolusWalletOverride.ts +++ b/src/networks/cosm/NolusWalletOverride.ts @@ -97,7 +97,11 @@ export function applyNolusWalletOverrides(wallet: NolusWallet): void { return { txHash, txBytes, usedFee }; }; - // Override getGasInfo — also uses gas multiplier for fee estimation + // Override getGasInfo — also uses gas multiplier for fee estimation. + // The `pubkey: Pubkey` parameter is load-bearing for the Solana Ed25519 path: + // SolanaWallet.simulateMultiTx supplies an Ed25519-encoded pubkey here, so a + // future refactor that drops the param and inlines `encodeSecp256k1Pubkey(wallet.pubKey!)` + // would silently break the Solana flow (chain rejects the wrong pubkey type). wallet.getGasInfo = async function (messages: MsgWithTypeUrl[], memo: string, pubkey: Pubkey, sequence: number) { const gasMultiplier = getGasMultiplier(); const encodedMSGS: ReturnType[] = []; diff --git a/src/networks/cosm/WalletFactory.test.ts b/src/networks/cosm/WalletFactory.test.ts index ebc3a9f5..8aa80416 100644 --- a/src/networks/cosm/WalletFactory.test.ts +++ b/src/networks/cosm/WalletFactory.test.ts @@ -4,8 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // factories (which are themselves hoisted to the top of the module). const { baseWalletCtor, - metamaskConnectCustom, - metamaskMakeWCOfflineSigner, + solanaWalletCtor, solanaConnectCustom, solanaMakeWCOfflineSigner, fetchEndpointsMock, @@ -15,8 +14,7 @@ const { webusbCreateMock } = vi.hoisted(() => ({ baseWalletCtor: vi.fn(), - metamaskConnectCustom: vi.fn().mockResolvedValue(undefined), - metamaskMakeWCOfflineSigner: vi.fn(() => ({ type: "evm", chainId: "nolus-1", getAccounts: async () => [] })), + solanaWalletCtor: vi.fn(), solanaConnectCustom: vi.fn().mockResolvedValue(undefined), solanaMakeWCOfflineSigner: vi.fn(() => ({ type: "svm", chainId: "nolus-1", getAccounts: async () => [] })), fetchEndpointsMock: vi.fn().mockResolvedValue({ rpc: "r", api: "a" }), @@ -41,17 +39,13 @@ vi.mock("./BaseWallet", () => { }; }); -vi.mock("../evm", () => ({ - MetaMaskWallet: class { - connectCustom = metamaskConnectCustom; - makeWCOfflineSigner = metamaskMakeWCOfflineSigner; - } -})); - vi.mock("../sol", () => ({ SolanaWallet: class { connectCustom = solanaConnectCustom; makeWCOfflineSigner = solanaMakeWCOfflineSigner; + constructor(...args: unknown[]) { + solanaWalletCtor(...args); + } } })); @@ -88,7 +82,7 @@ vi.mock("@cosmjs/ledger-amino", () => ({ } })); -import { authenticateKeplr, authenticateLedger, authenticateEvmPhantom, authenticateSolFlare } from "./WalletFactory"; +import { authenticateKeplr, authenticateLedger, authenticatePhantom, authenticateSolFlare } from "./WalletFactory"; import type { NetworkData } from "@/common/types"; function fakeWallet(chainId = "nolus-1") { @@ -115,8 +109,7 @@ function fakeNetwork(overrides: Partial = {}): NetworkData { describe("WalletFactory", () => { beforeEach(() => { baseWalletCtor.mockClear(); - metamaskConnectCustom.mockClear(); - metamaskMakeWCOfflineSigner.mockClear(); + solanaWalletCtor.mockClear(); solanaConnectCustom.mockClear(); solanaMakeWCOfflineSigner.mockClear(); bluetoothCreateMock.mockClear(); @@ -205,20 +198,30 @@ describe("WalletFactory", () => { }); }); - describe("authenticateEvmPhantom", () => { - it("creates MetaMaskWallet, connectCustom, passes WC signer to BaseWallet", async () => { - await authenticateEvmPhantom(fakeWallet(), fakeNetwork()); + describe("authenticatePhantom", () => { + it("constructs SolanaWallet with provider='phantom' and passes its WC signer to BaseWallet", async () => { + await authenticatePhantom(fakeWallet(), fakeNetwork()); expect(fetchEndpointsMock).toHaveBeenCalledWith("nolus"); - expect(metamaskConnectCustom).toHaveBeenCalledTimes(1); - expect(metamaskMakeWCOfflineSigner).toHaveBeenCalledTimes(1); - // The BaseWallet signer arg is the WC signer from MetaMask - expect(baseWalletCtor.mock.calls[0][1].type).toBe("evm"); + expect(solanaWalletCtor).toHaveBeenCalledTimes(1); + expect(solanaWalletCtor).toHaveBeenCalledWith("phantom"); + expect(solanaConnectCustom).toHaveBeenCalledTimes(1); + expect(solanaMakeWCOfflineSigner).toHaveBeenCalledTimes(1); + // The BaseWallet signer arg is the WC signer from SolanaWallet + expect(baseWalletCtor.mock.calls[0][1].type).toBe("svm"); + }); + + it("phantom errors during connectCustom propagate (BaseWallet not built on failure)", async () => { + solanaConnectCustom.mockRejectedValueOnce(new Error("user denied")); + await expect(authenticatePhantom(fakeWallet(), fakeNetwork())).rejects.toThrow(/user denied/); + expect(baseWalletCtor).not.toHaveBeenCalled(); }); }); describe("authenticateSolFlare", () => { - it("creates SolanaWallet and passes its WC signer to BaseWallet", async () => { + it("constructs SolanaWallet with provider='solflare' and passes its WC signer to BaseWallet", async () => { await authenticateSolFlare(fakeWallet(), fakeNetwork()); + expect(solanaWalletCtor).toHaveBeenCalledTimes(1); + expect(solanaWalletCtor).toHaveBeenCalledWith("solflare"); expect(solanaConnectCustom).toHaveBeenCalledTimes(1); expect(solanaMakeWCOfflineSigner).toHaveBeenCalledTimes(1); expect(baseWalletCtor.mock.calls[0][1].type).toBe("svm"); diff --git a/src/networks/cosm/WalletFactory.ts b/src/networks/cosm/WalletFactory.ts index d24401aa..246727ba 100644 --- a/src/networks/cosm/WalletFactory.ts +++ b/src/networks/cosm/WalletFactory.ts @@ -14,7 +14,6 @@ import { AminoTypes } from "@cosmjs/stargate"; import { WalletManager, WalletUtils, Logger } from "@/common/utils"; import { fetchEndpoints } from "@/common/utils/EndpointService"; import { BaseWallet } from "./BaseWallet"; -import { MetaMaskWallet } from "../evm"; import { SolanaWallet } from "../sol"; const aminoTypes = { @@ -94,18 +93,18 @@ async function authenticateKeplr(wallet: Wallet, network: NetworkData) { return authenticateKeplrLike(wallet, network, WalletUtils.getKeplr, "Keplr"); } -export async function authenticateEvmPhantom(wallet: Wallet, network: NetworkData) { +export async function authenticateSolFlare(wallet: Wallet, network: NetworkData) { const node = await fetchEndpoints(network.key); - const metamask = new MetaMaskWallet(); - await metamask.connectCustom(node, network); - const signer = metamask.makeWCOfflineSigner(); + const sol = new SolanaWallet("solflare"); + await sol.connectCustom(node, network); + const signer = sol.makeWCOfflineSigner(); return await createWallet(wallet, signer, network.prefix, network.gasMultiplier, network.gasPrice, network.explorer); } -export async function authenticateSolFlare(wallet: Wallet, network: NetworkData) { +export async function authenticatePhantom(wallet: Wallet, network: NetworkData) { const node = await fetchEndpoints(network.key); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("phantom"); await sol.connectCustom(node, network); const signer = sol.makeWCOfflineSigner(); diff --git a/src/networks/evm/index.ts b/src/networks/evm/index.ts deleted file mode 100644 index 6c42d5da..00000000 --- a/src/networks/evm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MetaMaskWallet } from "./wallet"; diff --git a/src/networks/evm/sign.test.ts b/src/networks/evm/sign.test.ts deleted file mode 100644 index b2da8663..00000000 --- a/src/networks/evm/sign.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { AuthInfo, SignerInfo, ModeInfo, Fee } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing"; -import { ensureEip191AuthInfoBytes, personalSignJSON } from "./sign"; - -function singleSignerAuthInfo(mode: SignMode): AuthInfo { - return AuthInfo.fromPartial({ - fee: Fee.fromPartial({ amount: [{ amount: "100", denom: "unls" }], gasLimit: 200000n }), - signerInfos: [ - SignerInfo.fromPartial({ - publicKey: { typeUrl: "/cosmos.crypto.secp256k1.PubKey", value: new Uint8Array([1, 2, 3]) }, - sequence: 5n, - modeInfo: ModeInfo.fromPartial({ single: { mode } }) - }) - ] - }); -} - -describe("ensureEip191AuthInfoBytes", () => { - it("returns AuthInfo.encode unchanged when signer already uses SIGN_MODE_EIP_191", () => { - const authInfo = singleSignerAuthInfo(SignMode.SIGN_MODE_EIP_191); - const out = ensureEip191AuthInfoBytes(authInfo); - const expected = AuthInfo.encode(authInfo).finish(); - expect(out).toEqual(expected); - }); - - it("patches a DIRECT-signed AuthInfo to EIP-191 mode, preserving sequence and pubkey", () => { - const authInfo = singleSignerAuthInfo(SignMode.SIGN_MODE_DIRECT); - const out = ensureEip191AuthInfoBytes(authInfo); - - const decoded = AuthInfo.decode(out); - expect(decoded.signerInfos).toHaveLength(1); - expect(decoded.signerInfos[0].modeInfo?.single?.mode).toBe(SignMode.SIGN_MODE_EIP_191); - expect(decoded.signerInfos[0].sequence).toBe(5n); - expect(decoded.signerInfos[0].publicKey?.typeUrl).toBe("/cosmos.crypto.secp256k1.PubKey"); - }); - - it("patches AMINO-JSON to EIP-191", () => { - const authInfo = singleSignerAuthInfo(SignMode.SIGN_MODE_LEGACY_AMINO_JSON); - const decoded = AuthInfo.decode(ensureEip191AuthInfoBytes(authInfo)); - expect(decoded.signerInfos[0].modeInfo?.single?.mode).toBe(SignMode.SIGN_MODE_EIP_191); - }); - - it("preserves the original fee when patching", () => { - const authInfo = singleSignerAuthInfo(SignMode.SIGN_MODE_DIRECT); - const decoded = AuthInfo.decode(ensureEip191AuthInfoBytes(authInfo)); - expect(decoded.fee?.gasLimit).toBe(200000n); - expect(decoded.fee?.amount).toEqual([{ amount: "100", denom: "unls" }]); - }); - - it("handles an empty signerInfos by still producing a valid encoded AuthInfo", () => { - const authInfo = AuthInfo.fromPartial({ - fee: Fee.fromPartial({ gasLimit: 1n }), - signerInfos: [] - }); - const out = ensureEip191AuthInfoBytes(authInfo); - const decoded = AuthInfo.decode(out); - // Empty signerInfos means the `already` check is false, and the patched - // signerInfos has one entry with empty pubkey/sequence. - expect(decoded.signerInfos).toHaveLength(1); - expect(decoded.signerInfos[0].modeInfo?.single?.mode).toBe(SignMode.SIGN_MODE_EIP_191); - }); -}); - -describe("personalSignJSON", () => { - const ETH_ADDR = "0x0000000000000000000000000000000000000001"; - - it("sends a 0x-hex encoded JSON string to personal_sign and strips the v byte", async () => { - // r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes hex = 130 chars + "0x" - const sigHex = "0x" + "aa".repeat(32) + "bb".repeat(32) + "1c"; - const request = vi.fn().mockResolvedValue(sigHex); - const provider = { request } as unknown as Parameters[1]; - - const rsBytes = await personalSignJSON({ foo: "bar" }, provider, ETH_ADDR); - - expect(request).toHaveBeenCalledTimes(1); - const call = request.mock.calls[0][0]; - expect(call.method).toBe("personal_sign"); - expect(call.params[1]).toBe(ETH_ADDR); - expect(call.params[0]).toMatch(/^0x[0-9a-fA-F]+$/); - - // r + s only — v byte stripped - expect(rsBytes).toBeInstanceOf(Uint8Array); - expect(rsBytes.length).toBe(64); - expect(rsBytes[0]).toBe(0xaa); - expect(rsBytes[31]).toBe(0xaa); - expect(rsBytes[32]).toBe(0xbb); - expect(rsBytes[63]).toBe(0xbb); - }); - - it("pretty-prints (4-space) the JSON passed to the signer", async () => { - const sigHex = "0x" + "00".repeat(65); - const request = vi.fn().mockResolvedValue(sigHex); - const provider = { request } as unknown as Parameters[1]; - - await personalSignJSON({ a: 1 }, provider, ETH_ADDR); - const hexParam: string = request.mock.calls[0][0].params[0]; - // Decode back - const body = hexParam.slice(2); - const bytes = new Uint8Array(body.length / 2); - for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(body.slice(i * 2, i * 2 + 2), 16); - const text = new TextDecoder().decode(bytes); - expect(text).toBe(JSON.stringify({ a: 1 }, null, 4)); - }); - - it("propagates provider errors", async () => { - const provider = { - request: vi.fn().mockRejectedValue(new Error("user rejected")) - } as unknown as Parameters[1]; - - await expect(personalSignJSON({}, provider, ETH_ADDR)).rejects.toThrow(/user rejected/); - }); - - it("throws on hex strings with odd length (from malformed provider)", async () => { - // 131-char (odd) — triggers the "Hex string must have an even length" branch - const sigHex = "0x" + "a".repeat(131); - const provider = { - request: vi.fn().mockResolvedValue(sigHex) - } as unknown as Parameters[1]; - await expect(personalSignJSON({}, provider, ETH_ADDR)).rejects.toThrow(/even length/); - }); -}); diff --git a/src/networks/evm/sign.ts b/src/networks/evm/sign.ts deleted file mode 100644 index df700bea..00000000 --- a/src/networks/evm/sign.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Eip1193Provider } from "ethers"; -import { AuthInfo, SignerInfo, ModeInfo } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing"; -import { toUtf8Bytes } from "ethers"; -import { toHex } from "@cosmjs/encoding"; - -export function ensureEip191AuthInfoBytes(authInfo: AuthInfo): Uint8Array { - const si = authInfo.signerInfos?.[0]; - const already = si?.modeInfo?.single?.mode === SignMode.SIGN_MODE_EIP_191; - if (already) return AuthInfo.encode(authInfo).finish(); - - const patched = AuthInfo.fromPartial({ - fee: authInfo.fee, - signerInfos: [ - SignerInfo.fromPartial({ - publicKey: si?.publicKey, - sequence: si?.sequence, - modeInfo: ModeInfo.fromPartial({ single: { mode: SignMode.SIGN_MODE_EIP_191 } }) - }) - ] - }); - return AuthInfo.encode(patched).finish(); -} - -export async function personalSignJSON( - jsonObj: unknown, - ethereum: Eip1193Provider, - ethAddress: string -): Promise { - const json = JSON.stringify(jsonObj, null, 4); - const hex = `0x${toHex(toUtf8Bytes(json))}`; - const sigHex: string = await ethereum.request({ method: "personal_sign", params: [hex, ethAddress] }); - const raw = sigHex.slice(2); - const rsHex = raw.slice(0, raw.length - 2); - const rsBytes = hexToBytes(rsHex); - return rsBytes; -} - -function hexToBytes(hex: string): Uint8Array { - const clean = hex.startsWith("0x") ? hex.slice(2) : hex; - if (clean.length % 2 !== 0) throw new Error("Hex string must have an even length"); - const out = new Uint8Array(clean.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); - return out; -} diff --git a/src/networks/evm/wallet.test.ts b/src/networks/evm/wallet.test.ts deleted file mode 100644 index 13ff204c..00000000 --- a/src/networks/evm/wallet.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { SigningKey, Wallet as EthersWallet, hashMessage, hexlify, toUtf8Bytes } from "ethers"; - -const stargateConnectMock = vi.fn(); -vi.mock("@cosmjs/stargate", () => ({ - StargateClient: { - connect: (...args: unknown[]) => stargateConnectMock(...args) - } -})); - -vi.mock("@nolus/nolusjs", () => ({ - ChainConstants: { CHAIN_KEY: "nolus" }, - NolusClient: { - setInstance: vi.fn(), - getInstance: vi.fn(() => ({ getChainId: vi.fn().mockResolvedValue("nolus-1") })) - } -})); - -vi.mock("@/common/utils/EndpointService", () => ({ - fetchEndpoints: vi.fn(async () => ({ rpc: "rpc", api: "api" })) -})); - -vi.mock("@/common/utils", () => ({ - EnvNetworkUtils: { getStoredNetworkName: vi.fn(() => "mainnet") }, - WalletManager: { - getWalletConnectMechanism: vi.fn(() => undefined) - } -})); - -vi.mock("@/config/global", () => ({ - KeplrEmbedChainInfo: vi.fn(() => ({ - bech32Config: { bech32PrefixAccAddr: "nolus" } - })) -})); - -import { MetaMaskWallet } from "./wallet"; -import type { NetworkData, API } from "@/common/types"; -import type { Window as MMWindow } from "../window"; - -// Use a fixed private key so we produce real valid secp256k1 signatures -// without relying on jsdom's crypto entropy source. -const TEST_PRIV_KEY = "0x4c0883a69102937d6231471b5dbb6204fe5129617082796bb3b72f23cfb3d6f1"; -const testWallet = new EthersWallet(TEST_PRIV_KEY); - -function mkProvider() { - const ethAddr = testWallet.address; - const request = vi.fn(async (args: { method: string; params?: unknown[] }) => { - switch (args.method) { - case "eth_requestAccounts": - return [ethAddr]; - case "eth_accounts": - return [ethAddr]; - case "personal_sign": { - const msgHex: string = (args.params as string[])[0]; - // Decode hex to string, sign with ethers wallet's signing key - const bytes = new Uint8Array(msgHex.slice(2).length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(msgHex.slice(2 + i * 2, 4 + i * 2), 16); - } - const text = new TextDecoder().decode(bytes); - const sig = await testWallet.signMessage(text); - return sig; - } - default: - throw new Error(`Unhandled method ${args.method}`); - } - }); - return { request }; -} - -function stubEthereum(provider: unknown) { - const w = window as unknown as MMWindow; - w.ethereum = provider as MMWindow["ethereum"]; -} -function stubPhantom(provider: unknown) { - const w = window as unknown as MMWindow; - w.phantom = { ethereum: provider } as MMWindow["phantom"]; -} -function clearWindow() { - const w = window as unknown as MMWindow; - delete w.ethereum; - delete w.phantom; -} - -function networkStub(): NetworkData { - return { - embedChainInfo: () => ({ bech32Config: { bech32PrefixAccAddr: "nolus" } }) - } as unknown as NetworkData; -} - -describe("MetaMaskWallet", () => { - beforeEach(() => { - stargateConnectMock.mockReset(); - stargateConnectMock.mockResolvedValue({ getChainId: vi.fn().mockResolvedValue("nolus-1") }); - }); - - afterEach(() => { - clearWindow(); - vi.restoreAllMocks(); - }); - - it("connectCustom derives bech32 nolus address and secp256k1 compressed pubkey from signed challenge", async () => { - const provider = mkProvider(); - stubEthereum(provider); - - const mm = new MetaMaskWallet(); - const { bech32Addr, ethAddress, pubkeyAny } = await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - expect(bech32Addr).toMatch(/^nolus1[a-z0-9]+$/); - expect(ethAddress).toBe(testWallet.address); - expect(pubkeyAny).toBeInstanceOf(Uint8Array); - expect(mm.chainId).toBe("nolus-1"); - expect(mm.type).toBe("evm"); - expect(mm.algo).toBe("secp256k1"); - expect(mm.pubKey).toBeInstanceOf(Uint8Array); - // Compressed secp256k1 pubkey is 33 bytes, first byte 0x02 or 0x03 - expect(mm.pubKey!.length).toBe(33); - expect([0x02, 0x03]).toContain(mm.pubKey![0]); - }); - - it("connect uses NolusClient + KeplrEmbedChainInfo and populates chainId/address", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await mm.connect("metamask"); - expect(mm.chainId).toBe("nolus-1"); - expect(mm.address).toMatch(/^nolus1/); - }); - - it("getChainId returns cached chainId", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - await expect(mm.getChainId()).resolves.toBe("nolus-1"); - }); - - it("picks the last provider in ethereum.providers[] when present", async () => { - const primary = mkProvider(); - const fallback = mkProvider(); - const w = window as unknown as MMWindow; - w.ethereum = { providers: [fallback, primary] } as MMWindow["ethereum"]; - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - // Last provider is `primary` - expect(primary.request).toHaveBeenCalled(); - }); - - it("uses phantom.ethereum when WalletConnectMechanism is EVM_PHANTOM", async () => { - const phantomProv = mkProvider(); - const ethProv = mkProvider(); - stubPhantom(phantomProv); - stubEthereum(ethProv); - const utilsMock = (await import("@/common/utils")) as unknown as { - WalletManager: { getWalletConnectMechanism: ReturnType }; - }; - utilsMock.WalletManager.getWalletConnectMechanism.mockReturnValueOnce("evm_phantom"); - - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - expect(phantomProv.request).toHaveBeenCalled(); - }); - - it("makeWCOfflineSigner.getAccounts returns a single account with the bech32 address", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - const signer = mm.makeWCOfflineSigner(); - const accounts = await signer.getAccounts(); - expect(accounts).toHaveLength(1); - expect(accounts[0].address).toBe(mm.address); - expect(accounts[0].algo).toBe("secp256k1"); - expect(accounts[0].pubkey).toEqual(mm.pubKey); - expect(signer.type).toBe("evm"); - expect(signer.chainId).toBe("nolus-1"); - }); - - it("makeWCOfflineSigner.signDirect forwards a personal_sign call and returns a valid signature envelope", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - - const signer = mm.makeWCOfflineSigner(); - const { TxBody, AuthInfo, Fee, SignerInfo } = await import("cosmjs-types/cosmos/tx/v1beta1/tx"); - const { SignMode } = await import("cosmjs-types/cosmos/tx/signing/v1beta1/signing"); - - const txBody = TxBody.fromPartial({ memo: "m", messages: [] }); - const bodyBytes = TxBody.encode(txBody).finish(); - const authInfo = AuthInfo.fromPartial({ - fee: Fee.fromPartial({ amount: [{ amount: "1", denom: "unls" }], gasLimit: 100n }), - signerInfos: [ - SignerInfo.fromPartial({ - publicKey: { typeUrl: "/cosmos.crypto.secp256k1.PubKey", value: new Uint8Array([1]) }, - sequence: 0n, - modeInfo: { single: { mode: SignMode.SIGN_MODE_DIRECT } } - }) - ] - }); - const authInfoBytes = AuthInfo.encode(authInfo).finish(); - - const res = await signer.signDirect("ignored", { - bodyBytes, - authInfoBytes, - chainId: "nolus-1", - accountNumber: 1n - }); - expect(res.signature.pub_key.type).toBe("/cosmos.crypto.secp256k1.PubKey"); - expect(typeof res.signature.signature).toBe("string"); - expect(res.signed.chainId).toBe("nolus-1"); - expect(res.signed.accountNumber).toBe(1n); - expect(res.signed.bodyBytes).toEqual(bodyBytes); - expect(res.signed.authInfoBytes).toBeInstanceOf(Uint8Array); - // authInfoBytes should now have EIP-191 mode set by ensureEip191AuthInfoBytes - const decoded = AuthInfo.decode(res.signed.authInfoBytes); - expect(decoded.signerInfos[0].modeInfo?.single?.mode).toBe(SignMode.SIGN_MODE_EIP_191); - }); - - it("produces a recoverable secp256k1 pubkey that matches the ethers signer", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - - // Derive expected compressed pubkey from the same challenge "Generate address" - const expectedSig = await testWallet.signMessage("Generate address"); - const fullPubkey = SigningKey.recoverPublicKey(hashMessage("Generate address"), expectedSig); - const expectedUncompressed = Buffer.from(fullPubkey.slice(2), "hex"); - const x = expectedUncompressed.slice(1, 33); - const y = expectedUncompressed.slice(33); - const expectedCompressed = Buffer.concat([Buffer.from([y[y.length - 1] % 2 ? 0x03 : 0x02]), x]); - expect(Buffer.from(mm.pubKey!)).toEqual(expectedCompressed); - }); - - it("bech32 prefix comes from the ChainInfo returned by embedChainInfo (e.g. osmo)", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const osmoNet = { - embedChainInfo: () => ({ bech32Config: { bech32PrefixAccAddr: "osmo" } }) - } as unknown as NetworkData; - const mm = new MetaMaskWallet(); - const { bech32Addr } = await mm.connectCustom({ rpc: "r", api: "a" } as API, osmoNet); - expect(bech32Addr).toMatch(/^osmo1/); - }); - - it("personal_sign is called with 0x-hex of the challenge text", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - const personalSignCalls = provider.request.mock.calls.filter((c) => c[0].method === "personal_sign"); - expect(personalSignCalls.length).toBeGreaterThan(0); - const firstChallenge = personalSignCalls[0][0].params[0]; - expect(firstChallenge).toBe(hexlify(toUtf8Bytes("Generate address"))); - }); - - it("throws when eth_requestAccounts rejects (user denies)", async () => { - const provider = { - request: vi.fn(async (args: { method: string }) => { - if (args.method === "eth_requestAccounts") throw new Error("user denied"); - return []; - }) - }; - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await expect(mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub())).rejects.toThrow(/user denied/); - }); - - it("ethAddress getter returns the address from eth_accounts", async () => { - const provider = mkProvider(); - stubEthereum(provider); - const mm = new MetaMaskWallet(); - await mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub()); - expect(mm.ethAddress).toBe(testWallet.address); - }); - - it("throws a descriptive error when a malformed pubkey has short y-bytes", async () => { - // Use vi.doMock to swap in a wallet module that imports a patched `ethers` - // where SigningKey.recoverPublicKey returns a truncated pubkey hex. That - // simulates a malformed pubkey (getBytes → < 33-byte y half). - vi.resetModules(); - vi.doMock("ethers", async () => { - const actual = await vi.importActual("ethers"); - return { - ...actual, - SigningKey: { - ...actual.SigningKey, - // Return an uncompressed pubkey hex with only the 0x04 prefix byte. - // getBytes will give a 1-byte array; slicing yields empty x and y. - recoverPublicKey: () => "0x04" - } - }; - }); - - const { MetaMaskWallet: PatchedWallet } = await import("./wallet"); - const provider = mkProvider(); - stubEthereum(provider); - const mm = new PatchedWallet(); - await expect(mm.connectCustom({ rpc: "r", api: "a" } as API, networkStub())).rejects.toThrow( - /EVM wallet: unexpected pubkey length/ - ); - - vi.doUnmock("ethers"); - vi.resetModules(); - }); -}); diff --git a/src/networks/evm/wallet.ts b/src/networks/evm/wallet.ts deleted file mode 100644 index 526fa680..00000000 --- a/src/networks/evm/wallet.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { getBytes, hashMessage, hexlify, SigningKey, type Eip1193Provider, toUtf8Bytes } from "ethers"; -import { PubKey as PubKeyProto } from "cosmjs-types/cosmos/crypto/secp256k1/keys"; -import { ripemd160, sha256 } from "@cosmjs/crypto"; -import { bech32 } from "bech32"; -import { ChainConstants, NolusClient } from "@nolus/nolusjs"; -import { toBase64 } from "@cosmjs/encoding"; - -import { AuthInfo, TxBody, type SignDoc as ProtoSignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -import type { Wallet } from "../wallet"; -import { EnvNetworkUtils, WalletManager } from "@/common/utils"; -import { fetchEndpoints } from "@/common/utils/EndpointService"; -import { KeplrEmbedChainInfo } from "@/config/global"; -import type { AccountData, DirectSignResponse, OfflineDirectSigner, Algo } from "@cosmjs/proto-signing"; -import type { ChainInfo } from "@keplr-wallet/types"; -import { anyToLegacy, reorderCoinsDeep } from "../utilities"; -import { ensureEip191AuthInfoBytes, personalSignJSON } from "./sign"; -import { WalletTypes } from "../types"; -import { WalletConnectMechanism, type API, type NetworkData } from "@/common/types"; -import { StargateClient } from "@cosmjs/stargate"; -import type { Window as MetamaskWindow } from "../window"; - -export class MetaMaskWallet implements Wallet { - address!: string; - pubKey?: Uint8Array; - algo?: Algo = "secp256k1"; - explorer: string; - ethAddress?: string; - type: string = WalletTypes.evm; - chainId: string; - - constructor() {} - - private getProvider(provider?: string) { - const p = WalletManager.getWalletConnectMechanism() ?? provider; - switch (p) { - case WalletConnectMechanism.EVM_PHANTOM: { - return (window as MetamaskWindow)?.phantom?.ethereum; - } - default: { - const p0 = (window as MetamaskWindow)?.ethereum?.providers?.at?.(-1); - if (p0) { - return p0; - } - return (window as MetamaskWindow)?.ethereum; - } - } - } - - async getChainId() { - return this.chainId; - } - - async connectCustom(networkConfig: API, network: NetworkData) { - const stargate = await StargateClient.connect(networkConfig.rpc); - - const chainId = await stargate.getChainId(); - const chainInfo = network.embedChainInfo(chainId, networkConfig.rpc, networkConfig.api); - - const data = await this.getWallet(chainInfo); - this.address = data.bech32Addr; - this.chainId = chainId; - return data; - } - - async connect(provider: string) { - const networkConfig = await fetchEndpoints(ChainConstants.CHAIN_KEY); - NolusClient.setInstance(networkConfig.rpc); - const chainId = await NolusClient.getInstance().getChainId(); - const chainInfo = KeplrEmbedChainInfo( - EnvNetworkUtils.getStoredNetworkName(), - chainId, - networkConfig.rpc as string, - networkConfig.api as string - ); - const data = await this.getWallet(chainInfo, provider); - this.address = data.bech32Addr; - this.chainId = chainId; - return data; - } - - private async getWallet(chainInfo: ChainInfo, provider?: string) { - const ethereum = this.getProvider(provider) as Eip1193Provider; - - await ethereum.request({ method: "eth_requestAccounts" }); - const ethAddress = (await ethereum.request({ method: "eth_accounts" }))[0]; - - const message = "Generate address"; - const sig: string = await ethereum.request({ - method: "personal_sign", - params: [hexlify(toUtf8Bytes(message)), ethAddress] - }); - - const digest = hashMessage(message); - const fullPubkey = SigningKey.recoverPublicKey(digest, sig); - - const uncompressed = getBytes(fullPubkey); - // Uncompressed secp256k1 pubkey: 1-byte prefix (0x04) + 32-byte x + 32-byte y = 65 bytes. - // A malformed pubkey means the wallet is unusable; fail visibly rather than - // silently deriving garbage from out-of-bounds reads. - if (uncompressed.length !== 65) { - throw new Error(`EVM wallet: unexpected pubkey length ${uncompressed.length}, expected 65`); - } - const x = uncompressed.slice(1, 33); - const y = uncompressed.slice(33); - if (y.length !== 32) { - throw new Error(`EVM wallet: unexpected pubkey length (y=${y.length})`); - } - const compressed = new Uint8Array(33); - compressed[0] = y[y.length - 1]! % 2 ? 0x03 : 0x02; - compressed.set(x, 1); - - const pubkeyProtoBytes = PubKeyProto.encode({ key: compressed }).finish(); - - const sha = sha256(compressed); - const rip = ripemd160(sha); - const bech32Addr = bech32.encode(chainInfo.bech32Config.bech32PrefixAccAddr, bech32.toWords(rip)); - this.pubKey = compressed; - this.address = bech32Addr; - this.ethAddress = ethAddress; - return { ethAddress, pubkeyAny: pubkeyProtoBytes, bech32Addr }; - } - - makeWCOfflineSigner(): OfflineDirectSigner & { type: WalletTypes; chainId: string } { - const address = this.address; - const pubkey = this.pubKey; - const algo = this.algo; - const ethAddress = this.ethAddress; - const ethereum = this.getProvider() as Eip1193Provider; - const chainId = this.chainId; - - return { - type: WalletTypes.evm, - chainId, - async getAccounts(): Promise { - return [ - { - address, - algo, - pubkey - } - ]; - }, - - async signDirect(_: string, signDoc: ProtoSignDoc): Promise { - const txBody = TxBody.decode(signDoc.bodyBytes); - const authInfo = AuthInfo.decode(signDoc.authInfoBytes); - const signerInfo = authInfo.signerInfos?.[0]; - - const origAuthInfo = AuthInfo.decode(signDoc.authInfoBytes); - const authInfoBytes = ensureEip191AuthInfoBytes(origAuthInfo); - - const msgs = txBody.messages.map(anyToLegacy); - const feeAmount = (authInfo.fee?.amount ?? []).map((c) => ({ amount: c.amount, denom: c.denom })); - const gas = authInfo.fee?.gasLimit?.toString?.() ?? "0"; - const memo = txBody.memo ?? ""; - - const jsonForSigning = reorderCoinsDeep({ - account_number: signDoc.accountNumber.toString(), - chain_id: signDoc.chainId, - fee: { amount: feeAmount, gas }, - memo, - msgs, - sequence: signerInfo.sequence?.toString?.() ?? "0" - }); - - const rsBytes = await personalSignJSON(jsonForSigning, ethereum, ethAddress); - - return { - signed: { - chainId: signDoc.chainId, - accountNumber: BigInt(signDoc.accountNumber.toString()), - authInfoBytes, - bodyBytes: signDoc.bodyBytes - }, - signature: { - pub_key: { - type: "/cosmos.crypto.secp256k1.PubKey", - value: pubkey - }, - signature: toBase64(rsBytes) - } - }; - } - }; - } -} diff --git a/src/networks/sol/wallet.test.ts b/src/networks/sol/wallet.test.ts index 9f43e79c..4ca4d7a2 100644 --- a/src/networks/sol/wallet.test.ts +++ b/src/networks/sol/wallet.test.ts @@ -34,6 +34,7 @@ vi.mock("@/config/global", () => ({ import { SolanaWallet } from "./wallet"; import type { NetworkData } from "@/common/types"; import type { Window } from "../window"; +import { AuthInfo, SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx"; const ED_PUBKEY_BYTES = new Uint8Array(32); for (let i = 0; i < 32; i++) ED_PUBKEY_BYTES[i] = i + 1; @@ -44,6 +45,23 @@ function makeProvider(overrides: Partial> = {}) { toBase58: vi.fn(() => "SolAddrBase58") }; return { + isSolflare: true, + isPhantom: false, + connect: vi.fn().mockResolvedValue(true), + publicKey, + signMessage: vi.fn().mockResolvedValue({ signature: new Uint8Array(64) }), + ...overrides + }; +} + +function makePhantomProvider(overrides: Partial> = {}) { + const publicKey = { + toBytes: vi.fn(() => ED_PUBKEY_BYTES), + toBase58: vi.fn(() => "SolAddrBase58Phantom") + }; + return { + isPhantom: true, + isSolflare: false, connect: vi.fn().mockResolvedValue(true), publicKey, signMessage: vi.fn().mockResolvedValue({ signature: new Uint8Array(64) }), @@ -61,13 +79,23 @@ function clearSolflare() { delete w.solflare; } +function stubPhantom(solanaProvider: unknown) { + const w = window as unknown as Window & { phantom?: { solana?: unknown } }; + w.phantom = { solana: solanaProvider }; +} + +function clearPhantom() { + const w = window as unknown as Window & { phantom?: unknown }; + delete w.phantom; +} + function networkStub(): NetworkData { return { embedChainInfo: () => ({ bech32Config: { bech32PrefixAccAddr: "nolus" } }) } as unknown as NetworkData; } -describe("SolanaWallet", () => { +describe("SolanaWallet (Solflare provider)", () => { beforeEach(() => { stargateConnectMock.mockReset(); stargateConnectMock.mockResolvedValue({ getChainId: vi.fn().mockResolvedValue("nolus-1") }); @@ -75,6 +103,7 @@ describe("SolanaWallet", () => { afterEach(() => { clearSolflare(); + clearPhantom(); vi.restoreAllMocks(); }); @@ -82,7 +111,7 @@ describe("SolanaWallet", () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); const { solAddress, bech32Addr, pubkeyAny } = await sol.connectCustom( { rpc: "r", api: "a" } as unknown as Parameters[0], networkStub() @@ -103,7 +132,7 @@ describe("SolanaWallet", () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); const { bech32Addr } = await sol.connect(); expect(bech32Addr).toMatch(/^nolus1/); expect(sol.chainId).toBe("nolus-1"); @@ -112,7 +141,7 @@ describe("SolanaWallet", () => { it("getChainId returns the cached chainId", async () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await sol.connectCustom( { rpc: "r", api: "a" } as unknown as Parameters[0], networkStub() @@ -123,25 +152,36 @@ describe("SolanaWallet", () => { it("throws when provider.connect resolves false", async () => { const provider = makeProvider({ connect: vi.fn().mockResolvedValue(false) }); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await expect( sol.connectCustom({ rpc: "r", api: "a" } as unknown as Parameters[0], networkStub()) ).rejects.toThrow(/Connection failed/); }); it("throws when provider.publicKey is missing (user rejected)", async () => { - const provider = { connect: vi.fn().mockResolvedValue(true), publicKey: undefined }; + const provider = makeProvider({ publicKey: undefined }); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await expect( sol.connectCustom({ rpc: "r", api: "a" } as unknown as Parameters[0], networkStub()) ).rejects.toThrow(/Connection failed/); }); + it("throws when window.solflare is not really Solflare (isSolflare !== true)", async () => { + // Wallet aggregator placeholder injection — defends against another wallet + // squatting the window.solflare slot. + const provider = makeProvider({ isSolflare: false }); + stubSolflare(provider); + const sol = new SolanaWallet("solflare"); + await expect( + sol.connectCustom({ rpc: "r", api: "a" } as unknown as Parameters[0], networkStub()) + ).rejects.toThrow(); + }); + it("makeWCOfflineSigner.getAccounts returns the derived account", async () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await sol.connectCustom( { rpc: "r", api: "a" } as unknown as Parameters[0], networkStub() @@ -156,7 +196,7 @@ describe("SolanaWallet", () => { it("makeWCOfflineSigner.signDirect throws 'not supported'", async () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await sol.connectCustom( { rpc: "r", api: "a" } as unknown as Parameters[0], networkStub() @@ -169,7 +209,7 @@ describe("SolanaWallet", () => { it("makeWCOfflineSigner.simulateMultiTx signs via provider.signMessage and returns txHash/txBytes/usedFee", async () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await sol.connectCustom( { rpc: "r", api: "a" } as unknown as Parameters[0], networkStub() @@ -200,10 +240,90 @@ describe("SolanaWallet", () => { expect(res).toHaveProperty("usedFee"); }); + it("simulateMultiTx passes EXACTLY SignDoc.encode(signDoc).finish() bytes to provider.signMessage (round-trip)", async () => { + const provider = makeProvider(); + stubSolflare(provider); + const sol = new SolanaWallet("solflare"); + await sol.connectCustom( + { rpc: "r", api: "a" } as unknown as Parameters[0], + networkStub() + ); + const signer = sol.makeWCOfflineSigner(); + + const bound = Object.assign(signer, { + registry: { + encodeAsAny: vi.fn((m: unknown) => ({ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: m })) + }, + getSequence: vi.fn().mockResolvedValue({ accountNumber: 7n, sequence: 3n }), + getGasInfo: vi.fn().mockResolvedValue({ + gasInfo: { gasUsed: 100000 }, + gas: 150000, + usedFee: { amount: [{ denom: "unls", amount: "1" }], gas: "150000" } + }) + }); + + await bound.simulateMultiTx!( + [{ msg: { fromAddress: "a", toAddress: "b" }, msgTypeUrl: "/cosmos.bank.v1beta1.MsgSend" }], + "" + ); + + // Decode the bytes that were actually passed to signMessage and confirm + // they round-trip cleanly through SignDoc — i.e. the bytes ARE + // `SignDoc.encode(signDoc).finish()`, not a hash, JSON, or wrapped form. + expect(provider.signMessage).toHaveBeenCalledTimes(1); + const passedBytes = provider.signMessage.mock.calls[0][0] as Uint8Array; + expect(passedBytes).toBeInstanceOf(Uint8Array); + + const decoded = SignDoc.decode(passedBytes); + expect(decoded.chainId).toBe("nolus-1"); + expect(decoded.accountNumber).toBe(7n); + expect(decoded.bodyBytes.length).toBeGreaterThan(0); + expect(decoded.authInfoBytes.length).toBeGreaterThan(0); + + // Re-encode and assert byte-for-byte equality — no extra wrapper. + const reEncoded = SignDoc.encode(decoded).finish(); + expect(passedBytes).toEqual(reEncoded); + }); + + it("AuthInfo carries /cosmos.crypto.ed25519.PubKey (NOT secp256k1) in signer_infos[0].public_key.type_url", async () => { + const provider = makeProvider(); + stubSolflare(provider); + const sol = new SolanaWallet("solflare"); + await sol.connectCustom( + { rpc: "r", api: "a" } as unknown as Parameters[0], + networkStub() + ); + const signer = sol.makeWCOfflineSigner(); + + const bound = Object.assign(signer, { + registry: { + encodeAsAny: vi.fn((m: unknown) => ({ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: m })) + }, + getSequence: vi.fn().mockResolvedValue({ accountNumber: 1n, sequence: 0n }), + getGasInfo: vi.fn().mockResolvedValue({ + gasInfo: { gasUsed: 100000 }, + gas: 150000, + usedFee: { amount: [{ denom: "unls", amount: "1" }], gas: "150000" } + }) + }); + + await bound.simulateMultiTx!( + [{ msg: { fromAddress: "a", toAddress: "b" }, msgTypeUrl: "/cosmos.bank.v1beta1.MsgSend" }], + "" + ); + + const passedBytes = provider.signMessage.mock.calls[0][0] as Uint8Array; + const signDoc = SignDoc.decode(passedBytes); + const authInfo = AuthInfo.decode(signDoc.authInfoBytes); + + expect(authInfo.signerInfos).toHaveLength(1); + expect(authInfo.signerInfos[0].publicKey?.typeUrl).toBe("/cosmos.crypto.ed25519.PubKey"); + }); + it("makeWCOfflineSigner.simulateTx delegates to simulateMultiTx with a single-message list", async () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await sol.connectCustom( { rpc: "r", api: "a" } as unknown as Parameters[0], networkStub() @@ -222,7 +342,7 @@ describe("SolanaWallet", () => { it("signer has type=svm and correct chainId", async () => { const provider = makeProvider(); stubSolflare(provider); - const sol = new SolanaWallet(); + const sol = new SolanaWallet("solflare"); await sol.connectCustom( { rpc: "r", api: "a" } as unknown as Parameters[0], networkStub() @@ -232,3 +352,94 @@ describe("SolanaWallet", () => { expect(signer.chainId).toBe("nolus-1"); }); }); + +describe("SolanaWallet (Phantom provider)", () => { + beforeEach(() => { + stargateConnectMock.mockReset(); + stargateConnectMock.mockResolvedValue({ getChainId: vi.fn().mockResolvedValue("nolus-1") }); + }); + + afterEach(() => { + clearSolflare(); + clearPhantom(); + vi.restoreAllMocks(); + }); + + it("reads window.phantom.solana (NOT window.solflare) when provider is 'phantom'", async () => { + const phantomProvider = makePhantomProvider(); + const solflareProvider = makeProvider({ + // If the wallet incorrectly read solflare for "phantom", this would be + // the address used. Distinct toBase58 lets the assertion catch wrong-slot reads. + publicKey: { + toBytes: vi.fn(() => new Uint8Array(32)), + toBase58: vi.fn(() => "WRONG_SOLFLARE_SLOT") + } + }); + + stubPhantom(phantomProvider); + stubSolflare(solflareProvider); + + const sol = new SolanaWallet("phantom"); + const { solAddress } = await sol.connectCustom( + { rpc: "r", api: "a" } as unknown as Parameters[0], + networkStub() + ); + + expect(phantomProvider.connect).toHaveBeenCalledTimes(1); + expect(solflareProvider.connect).not.toHaveBeenCalled(); + expect(solAddress).toBe("SolAddrBase58Phantom"); + }); + + it("throws when window.phantom.solana is not really Phantom (isPhantom !== true)", async () => { + // Wallet aggregator placeholder injection — e.g. another wallet writing + // `window.phantom = { solana: { ... } }` without `isPhantom`. The wallet + // must refuse rather than authenticate against the wrong provider. + const placeholder = makePhantomProvider({ isPhantom: false }); + stubPhantom(placeholder); + const sol = new SolanaWallet("phantom"); + await expect( + sol.connectCustom({ rpc: "r", api: "a" } as unknown as Parameters[0], networkStub()) + ).rejects.toThrow(); + }); + + it("throws when window.phantom is missing entirely", async () => { + // No phantom slot at all — must not silently fall back to solflare or any + // other window key. + clearPhantom(); + const sol = new SolanaWallet("phantom"); + await expect( + sol.connectCustom({ rpc: "r", api: "a" } as unknown as Parameters[0], networkStub()) + ).rejects.toThrow(); + }); + + it("connectCustom on Phantom signs via window.phantom.solana.signMessage", async () => { + const phantomProvider = makePhantomProvider(); + stubPhantom(phantomProvider); + + const sol = new SolanaWallet("phantom"); + await sol.connectCustom( + { rpc: "r", api: "a" } as unknown as Parameters[0], + networkStub() + ); + const signer = sol.makeWCOfflineSigner(); + + const bound = Object.assign(signer, { + registry: { + encodeAsAny: vi.fn((m: unknown) => ({ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: m })) + }, + getSequence: vi.fn().mockResolvedValue({ accountNumber: 1n, sequence: 0n }), + getGasInfo: vi.fn().mockResolvedValue({ + gasInfo: { gasUsed: 100000 }, + gas: 150000, + usedFee: { amount: [{ denom: "unls", amount: "1" }], gas: "150000" } + }) + }); + + await bound.simulateMultiTx!( + [{ msg: { fromAddress: "a", toAddress: "b" }, msgTypeUrl: "/cosmos.bank.v1beta1.MsgSend" }], + "" + ); + + expect(phantomProvider.signMessage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/networks/sol/wallet.ts b/src/networks/sol/wallet.ts index 437b7559..d2dc9f2e 100644 --- a/src/networks/sol/wallet.ts +++ b/src/networks/sol/wallet.ts @@ -42,10 +42,29 @@ export class SolanaWallet implements Wallet { type: string = WalletTypes.svm; chainId: string; - constructor() {} + private readonly providerKind: "solflare" | "phantom"; + + constructor(provider: "solflare" | "phantom") { + this.providerKind = provider; + } private getProvider() { - return (window as Window).solflare; + if (this.providerKind === "solflare") { + const provider = (window as Window).solflare; + if (!provider || (provider as { isSolflare?: unknown }).isSolflare !== true) { + throw new Error("Solflare wallet is not installed."); + } + return provider; + } + + const phantom = (window as Window).phantom; + const provider = (phantom as { solana?: unknown } | undefined)?.solana as + | (Window["solflare"] & { isPhantom?: unknown }) + | undefined; + if (!provider || provider.isPhantom !== true) { + throw new Error("Phantom wallet is not installed."); + } + return provider; } async getChainId() { diff --git a/src/networks/window.ts b/src/networks/window.ts index bccf10cd..7f06b075 100644 --- a/src/networks/window.ts +++ b/src/networks/window.ts @@ -3,6 +3,8 @@ import type { Keplr } from "@keplr-wallet/types"; export interface Window { ethereum?: Record; keplr?: Keplr; - phantom?: Record; + phantom?: { + solana?: Record; + }; solflare?: Record; } diff --git a/vitest.config.ts b/vitest.config.ts index e4af7e92..a58adfee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -165,12 +165,6 @@ export default mergeConfig( functions: 100, statements: 95 }, - "src/networks/evm/sign.ts": { - lines: 100, - branches: 90, - functions: 100, - statements: 100 - }, "src/common/composables/useAsyncOperation.ts": { lines: 95, branches: 85, @@ -196,12 +190,6 @@ export default mergeConfig( functions: 95, statements: 95 }, - "src/networks/evm/wallet.ts": { - lines: 95, - branches: 75, - functions: 95, - statements: 95 - }, "src/networks/cosm/WalletFactory.ts": { lines: 95, branches: 90,