Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

move third party wallet connections to sdk #473

Merged
merged 12 commits into from
Mar 24, 2023
4 changes: 2 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ const config: Config.InitialOptions = {
global: {
lines: 99,
statements: 99,
functions: 99,
functions: 98,
branches: 99,
}
},
},
setupFilesAfterEnv: ['./test/setup.ts'],
globals: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"postinstall": "husky install && lerna link"
},
"devDependencies": {
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@ikscodes/browser-env": "~0.3.1",
"@ikscodes/eslint-config": "~7.0.2",
"@ikscodes/prettier-config": "~2.0.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/@magic-sdk/provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
"tslib": "^2.3.1"
},
"dependencies": {
"@coinbase/wallet-sdk": "3.6.3",
"@magic-sdk/types": "^11.6.2",
"@walletconnect/web3-provider": "^1.8.0",
"eventemitter3": "^4.0.4",
"web3-core": "1.5.2"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/@magic-sdk/provider/src/core/sdk-environment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ThirdPartyWalletOptions } from '@magic-sdk/types';
import type localForage from 'localforage';
import type { ViewController } from './view-controller';
import type { SDKBase } from './sdk';
Expand All @@ -17,6 +18,7 @@ export interface SDKEnvironment {
ViewController: ConstructorOf<ViewController>;
configureStorage: () => Promise<typeof localForage>;
bundleId?: string | null;
thirdPartyWalletOptions?: ThirdPartyWalletOptions;
}

export const SDKEnvironment: SDKEnvironment = {} as any;
Expand Down
7 changes: 5 additions & 2 deletions packages/@magic-sdk/provider/src/core/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */

import { EthNetworkConfiguration, QueryParameters, SupportedLocale } from '@magic-sdk/types';
import { EthNetworkConfiguration, QueryParameters, SupportedLocale, ThirdPartyWalletOptions } from '@magic-sdk/types';
import type { AbstractProvider } from 'web3-core';
import { coerce, satisfies } from '../util/semver';
import { encodeJSON } from '../util/base64-json';
Expand Down Expand Up @@ -95,6 +95,7 @@ export interface MagicSDKAdditionalConfiguration<
network?: EthNetworkConfiguration;
extensions?: TExt;
testMode?: boolean;
thirdPartyWalletOptions?: ThirdPartyWalletOptions | undefined;
}

export class SDKBase {
Expand All @@ -103,6 +104,7 @@ export class SDKBase {
protected readonly endpoint: string;
protected readonly parameters: string;
public readonly testMode: boolean;
public readonly thirdPartyWalletOptions: ThirdPartyWalletOptions | undefined;

/**
* Contains methods for starting a Magic SDK authentication flow.
Expand Down Expand Up @@ -142,9 +144,10 @@ export class SDKBase {
createReactNativeEndpointConfigurationWarning().log();
}

const { defaultEndpoint, version } = SDKEnvironment;
const { defaultEndpoint, version, thirdPartyWalletOptions } = SDKEnvironment;
this.testMode = !!options?.testMode;
this.endpoint = createURL(options?.endpoint ?? defaultEndpoint).origin;
this.thirdPartyWalletOptions = thirdPartyWalletOptions || options?.thirdPartyWalletOptions;

// Prepare built-in modules
this.auth = new AuthModule(this);
Expand Down
259 changes: 250 additions & 9 deletions packages/@magic-sdk/provider/src/modules/wallet.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,278 @@
import { MagicPayloadMethod, RequestUserInfoScope, UserInfo, WalletInfo } from '@magic-sdk/types';
import WalletConnectProvider from '@walletconnect/web3-provider';
import { CoinbaseWalletProvider, CoinbaseWalletSDK } from '@coinbase/wallet-sdk';
import {
Errors,
Events,
MagicPayloadMethod,
RequestUserInfoScope,
UserEnv,
UserInfo,
WalletInfo,
Wallets,
} from '@magic-sdk/types';

import { BaseModule } from './base-module';
import { createJsonRpcRequestPayload } from '../core/json-rpc';
import { clearKeys } from '../util/web-crypto';
import { setItem, getItem, removeItem } from '../util/storage';

export class WalletModule extends BaseModule {
/* Prompt Magic's Login Form */
public connectWithUI() {
const requestPayload = createJsonRpcRequestPayload(MagicPayloadMethod.RequestAccounts);
return this.request<string[]>(requestPayload);
public async connectWithUI() {
// If within metamask wallet browser, auto-connect without any UI (if dapp has metamask enabled)
if (this.isMetaMaskBrowser()) {
try {
const isMetaMaskEnabled = await this.isWalletEnabled(Wallets.MetaMask);
if (isMetaMaskEnabled) {
return this.autoConnectIfWalletBrowser(Wallets.MetaMask);
}
// If not enabled, continue with normal flow
} catch (error) {
console.error(error);
}
}
// If within coinbase wallet browser, auto-connect without any UI (if dapp has coinbase enabled)
if (this.isCoinbaseWalletBrowser()) {
try {
const isCoinbaseWalletEnabled = await this.isWalletEnabled(Wallets.CoinbaseWallet);
if (isCoinbaseWalletEnabled) {
return this.autoConnectIfWalletBrowser(Wallets.CoinbaseWallet);
}
// If not enabled, continue with normal flow
} catch (error) {
console.error(error);
}
}
const userEnv = this.getUserEnv();
const loginRequestPayload = createJsonRpcRequestPayload(MagicPayloadMethod.Login, [userEnv]);
const loginRequest = this.request<string[]>(loginRequestPayload);
loginRequest.on(Events.WalletSelected as any, (params) =>
this.handleWalletSelected({ ...params, showModal: !!params.showModal, payloadId: loginRequestPayload.id }),
);
return loginRequest;
}

/* Returns the provider for the connected wallet */
public async getProvider(): Promise<any> {
const activeWallet = await getItem(this.localForageKey);
switch (activeWallet) {
case Wallets.MetaMask:
return this.getMetaMaskProvider();
case Wallets.WalletConnect:
if (!this.sdk.thirdPartyWalletOptions?.walletConnect) {
throw new Error(Errors.WalletConnectError);
}
return this.getWalletConnectProvider(false);
case Wallets.CoinbaseWallet:
if (!this.sdk.thirdPartyWalletOptions?.coinbaseWallet) {
throw new Error(Errors.CoinbaseWalletError);
}
return this.getCoinbaseProvider().provider;
default:
return this.sdk.rpcProvider;
}
}

/* Prompt Magic's Wallet UI (not available for users logged in with third party wallets) */
public showUI() {
public showUI(): Promise<boolean> {
const requestPayload = createJsonRpcRequestPayload(MagicPayloadMethod.ShowUI);
return this.request<boolean>(requestPayload);
}

/* Get user info such as the wallet type they are logged in with */
public getInfo() {
const requestPayload = createJsonRpcRequestPayload(MagicPayloadMethod.GetInfo);
public async getInfo(): Promise<WalletInfo> {
const activeWallet = await getItem(this.localForageKey);
const requestPayload = createJsonRpcRequestPayload(MagicPayloadMethod.GetInfo, [{ walletType: activeWallet }]);
return this.request<WalletInfo>(requestPayload);
}

/* Request email address from logged in user */
public requestUserInfoWithUI(scope?: RequestUserInfoScope) {
public requestUserInfoWithUI(scope?: RequestUserInfoScope): Promise<UserInfo> {
const requestPayload = createJsonRpcRequestPayload(MagicPayloadMethod.RequestUserInfoWithUI, scope ? [scope] : []);
return this.request<UserInfo>(requestPayload);
}

/* Logout user */
public disconnect() {
public async disconnect(): Promise<boolean> {
clearKeys();
const activeWallet = await getItem(this.localForageKey);
// if (activeWallet === Wallets.WalletConnect) {
// const provider = await this.getWalletConnectProvider(false);
// await provider.disconnect();
// }
if (activeWallet === Wallets.CoinbaseWallet) {
const coinbase = this.getCoinbaseProvider();
coinbase.provider.disconnect();
}
removeItem(this.localForageKey);
const requestPayload = createJsonRpcRequestPayload(MagicPayloadMethod.Disconnect);
return this.request<boolean>(requestPayload);
}

/* Private methods */

private localForageKey = 'mc_active_wallet';

/* MetaMask */
private isMetaMaskInstalled(): boolean {
return (
(window as any).ethereum?.isMetaMask ||
!!(window as any).ethereum?.providers?.find((provider: any) => provider?.isMetaMask)
);
}

private isMetaMaskBrowser(): boolean {
return this.isMobile() && this.isMetaMaskInstalled();
}

private getMetaMaskProvider(): any {
return (window as any).ethereum?.providers?.find((p: any) => p?.isMetaMask) || (window as any).ethereum;
}

private connectToMetaMask(): Promise<string[]> {
// Redirect to MetaMask app if user selects MetaMask on mobile
if (this.isMobile() && !this.isMetaMaskInstalled()) {
const metaMaskDeepLink = `https://metamask.app.link/dapp/${window.location.href.replace(/(^\w+:|^)\/\//, '')}`;
window.location.href = metaMaskDeepLink;
hcote marked this conversation as resolved.
Show resolved Hide resolved
}
return this.getMetaMaskProvider().request({ method: 'eth_requestAccounts' });
}

/* Wallet Connect */
private async getWalletConnectProvider(showModal: boolean): Promise<WalletConnectProvider> {
const providerConfig = this.sdk.thirdPartyWalletOptions?.walletConnect;
if (!providerConfig) {
throw new Error(Errors.WalletConnectError);
}
const provider = new WalletConnectProvider({
...(providerConfig as any),
qrcode: showModal,
});
const activeWallet = await getItem(this.localForageKey);
const isConnected = localStorage.getItem('walletconnect');
// Only enable Wallet Connect provider if wallet is still connected
if (activeWallet && isConnected) {
await provider.enable();
}
return provider;
}

private async connectToWalletConnect(payloadId: any, showModal?: boolean): Promise<string[]> {
if (!this.sdk.thirdPartyWalletOptions?.walletConnect) {
throw new Error(Errors.WalletConnectError);
}
const provider = await this.getWalletConnectProvider(!!showModal);
provider.connector.on(Events.DisplayUri, (err, payload: any) => {
if (!showModal) {
const uri = payload.params[0];
this.createIntermediaryEvent(Events.Uri as any, payloadId)(uri);
}
});
return provider.enable();
}

/* Coinbase Wallet */
private isCoinbaseWalletInstalled(): boolean {
return (
(window as any).ethereum?.isCoinbaseWallet ||
!!(window as any).ethereum?.providers?.find((provider: any) => provider?.isCoinbaseWallet)
);
}

private isCoinbaseWalletBrowser(): boolean {
return !!(window as any).ethereum?.isCoinbaseBrowser;
}

private getCoinbaseProvider(): { provider: CoinbaseWalletProvider; qrCodeUrl: string | null } {
const providerConfig = this.sdk.thirdPartyWalletOptions?.coinbaseWallet?.provider;
const sdkConfig = this.sdk.thirdPartyWalletOptions?.coinbaseWallet?.sdk;
if (!providerConfig || !sdkConfig) {
throw new Error(Errors.CoinbaseWalletError);
}
const coinbaseWallet = new CoinbaseWalletSDK({
...(sdkConfig as any),
overrideIsMetaMask: false,
headlessMode: true,
});
const qrCodeUrl = coinbaseWallet.getQrUrl();
const provider = coinbaseWallet.makeWeb3Provider(providerConfig.jsonRpcUrl, providerConfig.chainId);
return { provider, qrCodeUrl };
}

private connectToCoinbaseWallet(payloadId: any): Promise<string[]> {
// Redirect to Coinbase Wallet app if user selects Coinbase Wallet on mobile
if (this.isMobile() && !this.isCoinbaseWalletBrowser()) {
const coinbaseWalletDeepLink = `https://go.cb-w.com/dapp?cb_url=${encodeURIComponent(window.location.href)}`;
window.location.href = coinbaseWalletDeepLink;
}
if (!this.sdk.thirdPartyWalletOptions?.coinbaseWallet) {
throw new Error(Errors.CoinbaseWalletError);
}
const coinbase = this.getCoinbaseProvider();
if (coinbase.qrCodeUrl) {
this.createIntermediaryEvent(Events.Uri as any, payloadId)(coinbase.qrCodeUrl);
}
return coinbase.provider.request({ method: 'eth_requestAccounts' });
}

/* Helpers */
private isMobile(): boolean {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Silk|Opera Mini/i.test(
navigator.userAgent,
);
}

private getUserEnv(): UserEnv {
return {
env: {
isMetaMaskInstalled: this.isMetaMaskInstalled(),
isCoinbaseWalletInstalled: this.isCoinbaseWalletInstalled(),
},
};
}

private connectToThirdPartyWallet(provider: Wallets, payloadId: any, showModal?: boolean): Promise<any> {
switch (provider) {
case Wallets.MetaMask:
return this.connectToMetaMask();
case Wallets.WalletConnect:
return this.connectToWalletConnect(payloadId, showModal);
case Wallets.CoinbaseWallet:
return this.connectToCoinbaseWallet(payloadId);
default:
throw new Error(
`Invalid provider: ${provider}. Must be one of "metamask", "coinbase_wallet", or "wallet_connect".`,
);
}
}

private isWalletEnabled(wallet: Wallets): Promise<boolean> {
const isWalletEnabled = createJsonRpcRequestPayload('mc_is_wallet_enabled', [{ wallet }]);
return this.request<boolean>(isWalletEnabled);
}

/* Triggers connection to wallet, emits success/reject event back to iframe */
private async handleWalletSelected(params: { wallet: Wallets; showModal: boolean; payloadId: number }) {
try {
const address = await this.connectToThirdPartyWallet(params.wallet, params.payloadId, params.showModal);
await setItem(this.localForageKey, params.wallet);
this.createIntermediaryEvent(Events.WalletConnected as any, params.payloadId as any)(address);
} catch (error) {
this.createIntermediaryEvent(Events.WalletRejected as any, params.payloadId as any)();
}
}

private async autoConnectIfWalletBrowser(wallet: Wallets): Promise<string[]> {
let address;
if (wallet === Wallets.MetaMask) {
address = await this.getMetaMaskProvider().request({ method: 'eth_requestAccounts' });
}
if (wallet === Wallets.CoinbaseWallet) {
address = await this.getCoinbaseProvider().provider.request({ method: 'eth_requestAccounts' });
}
await setItem(this.localForageKey, wallet);
const autoConnectPayload = createJsonRpcRequestPayload(MagicPayloadMethod.AutoConnect, [{ wallet, address }]);
const autoConnectRequest = this.request<string[]>(autoConnectPayload);
return autoConnectRequest;
}
}