Skip to content

Commit

Permalink
fix metamask snap connect
Browse files Browse the repository at this point in the history
  • Loading branch information
peterjah committed Dec 19, 2024
1 parent 4b5cd37 commit e4f48d1
Show file tree
Hide file tree
Showing 10 changed files with 60 additions and 217 deletions.
2 changes: 1 addition & 1 deletion jest.e2e.config.esm.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['./test-e2e/setup.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
Expand Down
2 changes: 1 addition & 1 deletion src/bearbyWallet/BearbyWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class BearbyWallet implements Wallet {
*
* @returns a boolean indicating whether the wallet is connected.
*/
public connected(): boolean {
public async connected(): Promise<boolean> {
return web3.wallet.connected;
}

Expand Down
2 changes: 1 addition & 1 deletion src/massaStation/MassaStationWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export class MassaStationWallet implements Wallet {
* Indicates if the station is connected.
* Always returns `true` because the station is always connected when running.
*/
public connected(): boolean {
public async connected(): Promise<boolean> {
return true;
}

Expand Down
22 changes: 6 additions & 16 deletions src/metamaskSnap/MetamaskWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@ import {
Provider,
} from '@massalabs/massa-web3';
import { WalletName } from '../wallet';
import {
getMetamaskProvider,
isMetaMaskUnlocked,
promptAndWaitForWalletUnlock,
} from './metamask';
import { connectSnap, getMassaSnapInfo } from './snap';
import { getMetamaskProvider } from './metamask';
import { connectSnap, isConnected } from './snap';
import { MetamaskAccount } from './MetamaskAccount';
import { MetaMaskInpageProvider } from '@metamask/providers';
import { getActiveAccount, getNetwork, setRpcUrl } from './services';
Expand Down Expand Up @@ -140,15 +136,9 @@ export class MetamaskWallet implements Wallet {
*/
public async connect() {
try {
const isUnlocked = await isMetaMaskUnlocked();

if (!isUnlocked) {
await promptAndWaitForWalletUnlock();
}

const snap = await getMassaSnapInfo(this.metamaskProvider);
const connected = await isConnected(this.metamaskProvider);

if (!snap) {
if (!connected) {
await connectSnap(this.metamaskProvider);
}
return true;
Expand All @@ -161,8 +151,8 @@ export class MetamaskWallet implements Wallet {
throw new Error('Method not implemented.');
}

public connected(): boolean {
return this.metamaskProvider.isConnected();
public connected(): Promise<boolean> {
return isConnected(this.metamaskProvider);
}

public enabled(): boolean {
Expand Down
12 changes: 2 additions & 10 deletions src/metamaskSnap/config/snap.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,2 @@
/**
* The snap origin to use.
* Will default to the local hosted snap if no value is provided in environment.
*
* You may be tempted to change this to the URL where your production snap is hosted, but please
* don't. Instead, rename `.env.production.dist` to `.env.production` and set the production URL
* there. Running `yarn build` will automatically use the production environment variables.
*/
// TODO - Change this to the massa snap origin when published
export const MASSA_SNAP_ID = `local:http://localhost:8080`;
export const MASSA_SNAP_ID = 'npm:@massalabs/metamask-snap';
// export const MASSA_SNAP_ID = `local:http://localhost:8080`;
136 changes: 17 additions & 119 deletions src/metamaskSnap/metamask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
EIP6963AnnounceProviderEvent,
MetaMaskInpageProvider,
} from '@metamask/providers';
import { MetaMaskProvider } from './types/snap';

declare global {
interface Window {
Expand All @@ -21,12 +22,8 @@ const PROVIDER_DETECTION_TIMEOUT = 500;
* @returns A promise that resolves to true if snaps are supported
*/
export async function hasSnapsSupport(
provider?: MetaMaskInpageProvider,
provider: MetaMaskInpageProvider,
): Promise<boolean> {
if (!provider) {
return false;
}

try {
await provider.request({
method: 'wallet_getSnaps',
Expand All @@ -37,6 +34,17 @@ export async function hasSnapsSupport(
}
}

function isMetaMaskProvider(obj: unknown): obj is MetaMaskProvider {
return (
obj !== null &&
typeof obj === 'object' &&
// eslint-disable-next-line no-prototype-builtins
obj.hasOwnProperty('isMetaMask') &&
// eslint-disable-next-line no-prototype-builtins
obj.hasOwnProperty('request')
);
}

/**
* Get a MetaMask provider using EIP6963 specification.
*
Expand All @@ -58,7 +66,10 @@ export async function getMetaMaskEIP6963Provider(): Promise<MetaMaskInpageProvid
}

function handleAnnouncement({ detail }: EIP6963AnnounceProviderEvent) {
if (detail.info.rdns.includes('io.metamask')) {
if (
['io.metamask', 'io.metamask.flask'].includes(detail.info.rdns) &&
isMetaMaskProvider(detail.provider)
) {
cleanup();
resolve(detail.provider);
}
Expand All @@ -69,129 +80,16 @@ export async function getMetaMaskEIP6963Provider(): Promise<MetaMaskInpageProvid
});
}

/**
* Check providers from an array for snaps support
*
* @param providers - Array of providers to check
* @returns First provider with snaps support or null
*/
async function findProviderWithSnaps(
providers?: MetaMaskInpageProvider[],
): Promise<MetaMaskInpageProvider | null> {
if (!providers?.length) {
return null;
}

for (const provider of providers) {
if (await hasSnapsSupport(provider)) {
return provider;
}
}
return null;
}

/**
* Get a MetaMask provider that supports snaps.
* Checks multiple provider sources in order of priority.
*
* @returns Promise resolving to a compatible provider or null
*/
export async function getMetamaskProvider(): Promise<MetaMaskInpageProvider | null> {
// Check window.ethereum first
if (window.ethereum && (await hasSnapsSupport(window.ethereum))) {
return window.ethereum;
}

// Check detected providers
const detectedProvider = await findProviderWithSnaps(
window.ethereum?.detected,
);
if (detectedProvider) {
return detectedProvider;
}

// Check providers array
const arrayProvider = await findProviderWithSnaps(window.ethereum?.providers);
if (arrayProvider) {
return arrayProvider;
}

// Finally, try EIP6963
const eip6963Provider = await getMetaMaskEIP6963Provider();
if (eip6963Provider && (await hasSnapsSupport(eip6963Provider))) {
return eip6963Provider;
}

return null;
}

export async function isMetaMaskUnlocked() {
if (!isMetamaskInstalled()) {
return false;
}

const accounts: string[] = await window.ethereum.request({
method: 'eth_accounts',
});
return accounts.length > 0;
}

export function isMetamaskInstalled() {
return Boolean(window.ethereum);
}

export async function promptAndWaitForWalletUnlock(): Promise<string[]> {
if (typeof window.ethereum === 'undefined') {
throw new Error(
'MetaMask is not installed. Please install it and try again.',
);
}

const ethereum = window.ethereum;

try {
// Prompt the user to unlock the wallet
await ethereum.request({ method: 'eth_requestAccounts' });

// Wait for accounts to become available
return new Promise((resolve, reject) => {
const checkAccounts = async () => {
try {
const accounts: string[] = await ethereum.request({
method: 'eth_accounts',
});
if (accounts && accounts.length > 0) {
resolve(accounts); // Wallet is unlocked
}
} catch (error) {
reject(
new Error('Error checking accounts: ' + (error as Error).message),
);
}
};

// Initial check for accounts
checkAccounts();

ethereum.on('accountsChanged', (accounts: string[]) => {
if (accounts.length > 0) {
console.log('Wallet unlocked:', accounts);
resolve(accounts);
} else {
console.warn('Accounts changed, but no accounts are available.');
}
});
ethereum.on('disconnect', () => {
reject(new Error('MetaMask disconnected.'));
});
});
} catch (error: any) {
if (error.code === 4001) {
throw new Error('User rejected the request.');
} else if (error.message.includes('User closed popup')) {
throw new Error('MetaMask popup was closed without connecting.');
} else {
throw new Error('Wallet unlocking failed.');
}
}
}
90 changes: 25 additions & 65 deletions src/metamaskSnap/snap.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,54 @@
import type { MetaMaskInpageProvider } from '@metamask/providers';

import { MASSA_SNAP_ID } from './config';
import type { GetSnapsResponse, Snap } from './types';

const getInstalledSnaps = async (
/**
* Connect a snap to MetaMask.
*
* @param provider - The MetaMask inpage provider.
* @param snapId - The ID of the snap.
*/
export const isConnected = async (
provider: MetaMaskInpageProvider,
): Promise<GetSnapsResponse> =>
provider.request({
method: 'wallet_getSnaps',
});
): Promise<boolean> => {
try {
await provider.request({
method: 'wallet_invokeSnap',
params: {
snapId: MASSA_SNAP_ID,
request: {
method: 'ping',
params: {},
},
},
});
return true;
} catch (error) {
return false;
}
};

/**
* Connect a snap to MetaMask.
*
* @param provider - The MetaMask inpage provider.
* @param snapId - The ID of the snap.
* @param params - The params to pass with the snap to connect.
*/
export const connectSnap = async (
provider: MetaMaskInpageProvider,
snapId: string = MASSA_SNAP_ID,
params: Record<'version' | string, unknown> = {},
) => {
provider.request({
method: 'wallet_requestSnaps',
params: {
[snapId]: params,
[MASSA_SNAP_ID]: params,
},
});
};

export const getMassaSnapInfo = async (
provider: MetaMaskInpageProvider,
version?: string,
): Promise<Snap | undefined> => {
try {
const snaps = await getInstalledSnaps(provider);

return Object.values(snaps).find(
(snap) =>
snap.id === MASSA_SNAP_ID && (!version || snap.version === version),
);
} catch (error) {
console.error('Failed to obtain installed snap', error);
return undefined;
}
};

export const showPrivateKey = async (provider: MetaMaskInpageProvider) => {
return provider.request({
method: 'wallet_invokeSnap',
params: { snapId: MASSA_SNAP_ID, request: { method: 'showSecretKey' } },
});
};

export const isLocalSnap = (snapId: string) => snapId.startsWith('local:');

export async function isDappConnectedToSnap(snapId: string): Promise<boolean> {
if (typeof window.ethereum === 'undefined') {
console.error('MetaMask is not installed.');
return false;
}

try {
// Request all installed snaps
const installedSnaps = await window.ethereum.request({
method: 'wallet_getSnaps',
});

// Check if the specific Snap is installed
const snap = installedSnaps[snapId];
if (!snap) {
console.log(`Snap with ID ${snapId} is not installed.`);
return false;
}

// Check if the current dApp is allowed
const currentOrigin = window.location.origin;
const allowedOrigins =
snap.permissions?.snap_allowedOrigins?.caveats?.[0]?.value || [];

if (allowedOrigins.includes(currentOrigin)) {
console.log(`DApp ${currentOrigin} is already connected to the Snap.`);
return true;
} else {
console.log(`DApp ${currentOrigin} is NOT connected to the Snap.`);
return false;
}
} catch (error) {
console.error('Error checking Snap connection:', error);
return false;
}
}
2 changes: 1 addition & 1 deletion src/metamaskSnap/types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { type GetSnapsResponse, type Snap } from './snap';
export { type Snap } from './snap';
Loading

0 comments on commit e4f48d1

Please sign in to comment.