Skip to content

Commit

Permalink
MetaMask Snap (unaudited) (#107)
Browse files Browse the repository at this point in the history
* Adjusted MM snap /core versions

* Added changeset

* WIP - Checksum error

* - Added a test website for testing the snap
- Made adjustments to the core mm snap functions to allow for a dynamically passed in snap id
- Improved the UI of the snap popups

* - Removed lint dependency
- Change "origin" to "snapId"

* Added changeset

* - Renamed file
- Added notice on Sign Arbitrary requests

* - Removed verify arbitrary from snap, moved to SeiWallet creation

* - Added api.ts tests
- Added mock files (from cosmos-metamask-snap)
- Removed unused files from test ui
- Improved styles of test ui
- Exported helper function to create SeiWallet for MM Snap

* Added optional account index to cosmjs, removed values from CHANGELOG
  • Loading branch information
codebycarson authored Jan 8, 2024
1 parent 27a9782 commit 2d1b863
Show file tree
Hide file tree
Showing 80 changed files with 8,576 additions and 398 deletions.
6 changes: 6 additions & 0 deletions .changeset/nervous-ways-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sei-js/metamask-snap': patch
'@sei-js/core': patch
---

Added MetaMask Snap and helper functions to library (experimental as this hasn't completed audit)
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@cosmjs/utils": "^0.29.5",
"@ethersproject/keccak256": "^5.7.0",
"@keplr-wallet/types": "^0.11.41",
"@noble/secp256k1": "1.7.1",
"@sei-js/proto": "^3.1.0",
"bech32": "^2.0.0",
"buffer": "^6.0.3",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './queryClient';
export * from './signingClient';
export * from './wallet';
export * from './utils';
export * from './metamask-snap';
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,10 @@ import { SignDoc } from 'cosmjs-types/cosmos/tx/v1beta1/tx';
import { AccountData, AminoSignResponse, StdSignDoc } from '@cosmjs/amino';
import { DirectSignResponse, OfflineDirectSigner } from '@cosmjs/proto-signing';
import Long from 'long';
import { MM_SNAP_ORIGIN } from './config';
import { SignAminoOptions } from './types';
import { makeADR36AminoSignDoc } from '@sei-js/core';
import { getWallet } from './snapWallet';

export const sendReqToSnap = async (method: string, params: any): Promise<any> => {
return window.ethereum.request({
method: 'wallet_invokeSnap',
params: {
snapId: MM_SNAP_ORIGIN,
request: {
method,
params
}
}
});
};
import { sendReqToSnap } from './utils';
import { makeADR36AminoSignDoc } from '../utils';

export const requestSignature = async (
chainId: string,
Expand All @@ -28,13 +15,18 @@ export const requestSignature = async (
authInfoBytes?: Uint8Array | null;
chainId?: string | null;
accountNumber?: Long | null;
}
) => {
const signature = await sendReqToSnap('signDirect', {
chainId,
signerAddress,
signDoc
});
},
snapId: string
): Promise<DirectSignResponse> => {
const signature = await sendReqToSnap(
'signDirect',
{
chainId,
signerAddress,
signDoc
},
snapId
);

const { accountNumber } = signDoc;

Expand All @@ -53,13 +45,17 @@ export const requestSignature = async (

export class CosmJSOfflineSigner implements OfflineDirectSigner {
readonly chainId: string;
readonly snapId: string;
readonly accountIndex: number;

constructor(chainId: string) {
constructor(chainId: string, snapId: string, accountIndex?: number) {
this.chainId = chainId;
this.snapId = snapId;
this.accountIndex = accountIndex || 0;
}

async getAccounts(): Promise<AccountData[]> {
const wallet = await getWallet(0);
const wallet = await getWallet(this.accountIndex, this.snapId);
return wallet.getAccounts();
}

Expand All @@ -73,10 +69,9 @@ export class CosmJSOfflineSigner implements OfflineDirectSigner {
throw new Error('Signer address does not match wallet address');
}

return requestSignature(this.chainId, signerAddress, signDoc) as Promise<DirectSignResponse>;
return requestSignature(this.chainId, signerAddress, signDoc, this.snapId);
}

// This has been added as a placeholder.
async signAmino(signerAddress: string, signDoc: StdSignDoc, options?: SignAminoOptions): Promise<AminoSignResponse> {
if (this.chainId !== signDoc.chain_id) {
throw new Error('Chain ID does not match signer chain ID');
Expand All @@ -87,31 +82,41 @@ export class CosmJSOfflineSigner implements OfflineDirectSigner {
throw new Error('Signer address does not match wallet address');
}

return requestSignAmino(this.chainId, signerAddress, signDoc, options) as unknown as Promise<AminoSignResponse>;
return requestSignAmino(this.chainId, signerAddress, signDoc, this.snapId, options);
}

async signArbitrary(signer: string, data: string, signOptions?: { enableExtraEntropy?: boolean }) {
const signDoc = makeADR36AminoSignDoc(signer, data);
const result = await requestSignAmino(this.chainId, signer, signDoc, {
const result = await requestSignAmino(this.chainId, signer, signDoc, this.snapId, {
isADR36: true,
enableExtraEntropy: signOptions?.enableExtraEntropy
});
return result.signature;
}
}

export const requestSignAmino = async (chainId: string, signerAddress: string, signDoc: StdSignDoc, options?: SignAminoOptions) => {
export const requestSignAmino = async (
chainId: string,
signerAddress: string,
signDoc: StdSignDoc,
snapId: string,
options?: SignAminoOptions
): Promise<AminoSignResponse> => {
const { isADR36 = false, enableExtraEntropy = false } = options || {};

if (!isADR36 && chainId !== signDoc.chain_id) {
throw new Error('Chain ID does not match signer chain ID');
}

return (await sendReqToSnap('signAmino', {
chainId,
signerAddress,
signDoc,
isADR36,
enableExtraEntropy
})) as AminoSignResponse;
return (await sendReqToSnap(
'signAmino',
{
chainId,
signerAddress,
signDoc,
isADR36,
enableExtraEntropy
},
snapId
)) as AminoSignResponse;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * from './config';
export * from './cosmjs';
export * from './snapWallet';
export * from './types';
export * from './utils';
135 changes: 135 additions & 0 deletions packages/core/src/lib/metamask-snap/snapWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { sign as signSecp256k1, getPublicKey as getSecp256k1PublicKey } from '@noble/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
import { SignDoc } from 'cosmjs-types/cosmos/tx/v1beta1/tx';
import { BIP44Node } from '@metamask/key-tree';
import { AccountData, encodeSecp256k1Signature, StdSignDoc } from '@cosmjs/amino';
import { Buffer } from 'buffer';
import { getSnapEthereumProvider, sendReqToSnap } from './utils';
import { compressedPubKeyToAddress, serializeAminoSignDoc, serializeDirectSignDoc, verifyArbitrary } from '../utils';
import { CosmJSOfflineSigner } from './cosmjs';
import { SeiWallet } from '../wallet';

export class SnapWallet {
constructor(private privateKey: Uint8Array, private compressedPubKey: Uint8Array, private address: string) {}

static create(privateKey: string) {
const sanitizedPvtKey = privateKey.replace('0x', '');
const pvtKeyBytes = Buffer.from(sanitizedPvtKey, 'hex');
const compressedPubKey = getSecp256k1PublicKey(pvtKeyBytes, true);
const seiAddress = compressedPubKeyToAddress(compressedPubKey);
return new SnapWallet(pvtKeyBytes, compressedPubKey, seiAddress);
}

getAccounts() {
return [
{
address: this.address,
algo: 'secp256k1',
pubkey: this.compressedPubKey
}
] as AccountData[];
}

async signDirect(signerAddress: string, signDoc: SignDoc) {
const accounts = this.getAccounts();
const account = accounts.find((acc) => acc.address === signerAddress);

if (!account) {
throw new Error('Signer address does not match wallet address');
}

const hash = sha256(serializeDirectSignDoc(signDoc));
const signature = await signSecp256k1(hash, this.privateKey, {
canonical: true,
extraEntropy: true,
der: false
});

return {
signed: { ...signDoc, accountNumber: signDoc.accountNumber.toString() },
signature: encodeSecp256k1Signature(account.pubkey, signature)
};
}

async signAmino(signerAddress: string, signDoc: StdSignDoc, options?: { extraEntropy: boolean }) {
const accounts = this.getAccounts();
const account = accounts.find((acc) => acc.address === signerAddress);
if (!account) {
throw new Error('Signer address does not match wallet address');
}

if (!account.pubkey) {
throw new Error('Unable to derive keypair');
}

const hash = sha256(serializeAminoSignDoc(signDoc));
const extraEntropy = options?.extraEntropy ? true : undefined;
const signature = await signSecp256k1(hash, this.privateKey, {
canonical: true,
extraEntropy,
der: false
});

return {
signed: signDoc,
signature: encodeSecp256k1Signature(account.pubkey, signature)
};
}
}

export async function getWallet(account_index = 0, snapId: string): Promise<SnapWallet> {
const account: BIP44Node = await sendReqToSnap('getPrivateKey', { account_index }, snapId);

if (account.privateKey) {
return SnapWallet.create(account.privateKey);
}
throw new Error(`Error creating sei wallet!`);
}

export const getMetaMaskSnapSeiWallet = (snapId: string): SeiWallet => {
return {
getAccounts: async (chainId) => {
const offlineSigner = new CosmJSOfflineSigner(chainId, snapId);
return offlineSigner.getAccounts();
},
connect: async (_: string) => {
const provider = await getSnapEthereumProvider();
const installedSnaps: any = await provider.request({ method: 'wallet_getSnaps' });
if (!installedSnaps || !installedSnaps[snapId]) {
await provider.request({
method: 'wallet_requestSnaps',
params: {
[snapId]: {}
}
});
}
},
disconnect: async (_: string) => {
throw new Error('Not implemented');
},
getOfflineSigner: async (chainId) => {
return new CosmJSOfflineSigner(chainId, snapId);
},
getOfflineSignerAmino: async (chainId) => {
// This signer includes both signDirect and signAmino, so just return it
return new CosmJSOfflineSigner(chainId, snapId);
},
signArbitrary: async (chainId, signer, message) => {
const offlineSigner = new CosmJSOfflineSigner(chainId, snapId);
return offlineSigner.signArbitrary(signer, message);
},
verifyArbitrary: async (_: string, signingAddress, data, signature) => {
if (!signingAddress || !data) {
throw new Error('Invalid params');
}
return await verifyArbitrary(signingAddress, data, signature);
},
walletInfo: {
windowKey: 'ethereum',
name: 'Sei Metamask Snap',
website: 'https://metamask.io/',
icon: 'https://github.com/MetaMask/brand-resources/raw/master/SVG/SVG_MetaMask_Icon_Color.svg'
},
isMobileSupported: true
};
};
19 changes: 19 additions & 0 deletions packages/core/src/lib/metamask-snap/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MetaMaskInpageProvider } from '@metamask/providers';

export type EthereumProvider = MetaMaskInpageProvider & {
providers: MetaMaskInpageProvider[];
detected: MetaMaskInpageProvider[];
setProvider: (provider: MetaMaskInpageProvider) => void;
};

declare global {
interface Window {
ethereum: EthereumProvider;
}
}

export type SignAminoOptions = {
isADR36?: boolean;
preferNoSetFee?: boolean;
enableExtraEntropy?: boolean;
};
65 changes: 65 additions & 0 deletions packages/core/src/lib/metamask-snap/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { EthereumProvider } from './types';

/**
* The fool proof version of getting the ethereum provider suggested by
* https://github.com/Montoya/snap-connect-test/blob/0dad2dd53ab2ecbf4b4369230d3aaaeca08c6dae/index.html#L41
*
* @returns the ethereum provider which supports snaps
*/
export const getSnapEthereumProvider = async (): Promise<EthereumProvider> => {
let mmFound = false;
if ('detected' in window.ethereum) {
for (const provider of window.ethereum.detected) {
try {
// Detect snaps support
await provider.request({
method: 'wallet_getSnaps'
});
// enforces MetaMask as provider
window.ethereum.setProvider(provider);

mmFound = true;
// @ts-ignore
return provider;
} catch {
// no-op
}
}
}

if (!mmFound && 'providers' in window.ethereum) {
for (const provider of window.ethereum.providers) {
try {
// Detect snaps support
await provider.request({
method: 'wallet_getSnaps'
});

// @ts-ignore
window.ethereum = provider;

mmFound = true;
// @ts-ignore
return provider;
} catch {
// no-op
}
}
}

return window.ethereum;
};

export const sendReqToSnap = async (method: string, params: any, snapId: string): Promise<any> => {
const provider = await getSnapEthereumProvider();
return provider.request({
method: 'wallet_invokeSnap',
params: {
snapId,
request: {
method,
params
}
}
});
};
Loading

0 comments on commit 2d1b863

Please sign in to comment.