From f698fee287c46e117a339db436c71df5216546a4 Mon Sep 17 00:00:00 2001 From: safepalteam001 Date: Tue, 9 Jul 2024 00:22:52 +0800 Subject: [PATCH] Add SafePal Wallet --- packages/safepal-wallet/README.md | 1 + packages/safepal-wallet/package.json | 36 ++++ packages/safepal-wallet/src/index.spec.ts | 66 +++++++ packages/safepal-wallet/src/index.ts | 208 ++++++++++++++++++++++ packages/safepal-wallet/tsconfig.json | 7 + 5 files changed, 318 insertions(+) create mode 100644 packages/safepal-wallet/README.md create mode 100644 packages/safepal-wallet/package.json create mode 100644 packages/safepal-wallet/src/index.spec.ts create mode 100644 packages/safepal-wallet/src/index.ts create mode 100644 packages/safepal-wallet/tsconfig.json diff --git a/packages/safepal-wallet/README.md b/packages/safepal-wallet/README.md new file mode 100644 index 000000000..2847ae047 --- /dev/null +++ b/packages/safepal-wallet/README.md @@ -0,0 +1 @@ +# @web3-react/safepal-wallet diff --git a/packages/safepal-wallet/package.json b/packages/safepal-wallet/package.json new file mode 100644 index 000000000..9d34d5d2b --- /dev/null +++ b/packages/safepal-wallet/package.json @@ -0,0 +1,36 @@ +{ + "name": "@web3-react/safepal-wallet", + "keywords": [ + "web3-react", + "safepal-wallet" + ], + "author": "safepalteam001", + "license": "GPL-3.0-or-later", + "repository": "github:Uniswap/web3-react", + "publishConfig": { + "access": "public" + }, + "version": "8.2.2", + "files": [ + "dist/*" + ], + "type": "commonjs", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "exports": "./dist/index.js", + "scripts": { + "prebuild": "rm -rf dist", + "build": "tsc", + "start": "tsc --watch" + }, + "dependencies": { + "@safepal-wallet/web3-sdk": "^1.0.0", + "@web3-react/types": "^8.2.2" + }, + "peerDependencies": { + "@coinbase/wallet-sdk": "^3.0.4" + }, + "devDependencies": { + "@web3-react/store": "^8.2.2" + } +} diff --git a/packages/safepal-wallet/src/index.spec.ts b/packages/safepal-wallet/src/index.spec.ts new file mode 100644 index 000000000..b15b4af37 --- /dev/null +++ b/packages/safepal-wallet/src/index.spec.ts @@ -0,0 +1,66 @@ +import { createWeb3ReactStoreAndActions } from '@web3-react/store' +import type { Actions, Web3ReactStore } from '@web3-react/types' +import { SafePalWallet } from '.' +import { MockEIP1193Provider } from '@web3-react/core' + +const chainId = '0x1' +const accounts: string[] = ['0x0000000000000000000000000000000000000000'] + +describe('SafePalWallet', () => { + let mockProvider: MockEIP1193Provider + + beforeEach(() => { + mockProvider = new MockEIP1193Provider() + }) + + beforeEach(() => { + ;(window as any).ethereum = mockProvider + }) + + let store: Web3ReactStore + let connector: SafePalWallet + + beforeEach(() => { + let actions: Actions + ;[store, actions] = createWeb3ReactStoreAndActions() + connector = new SafePalWallet({ actions }) + }) + + test('#connectEagerly', async () => { + mockProvider.chainId = chainId + mockProvider.accounts = accounts + + await connector.connectEagerly() + + expect(mockProvider.eth_requestAccounts).not.toHaveBeenCalled() + expect(mockProvider.eth_accounts).toHaveBeenCalled() + expect(mockProvider.eth_chainId).toHaveBeenCalled() + expect(mockProvider.eth_chainId.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_accounts.mock.invocationCallOrder[0]) + + expect(store.getState()).toEqual({ + chainId: Number.parseInt(chainId, 16), + accounts, + activating: false, + }) + }) + + test('#activate', async () => { + mockProvider.chainId = chainId + mockProvider.accounts = accounts + + await connector.activate() + + expect(mockProvider.eth_requestAccounts).toHaveBeenCalled() + expect(mockProvider.eth_accounts).not.toHaveBeenCalled() + expect(mockProvider.eth_chainId).toHaveBeenCalled() + expect(mockProvider.eth_chainId.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_requestAccounts.mock.invocationCallOrder[0]) + + expect(store.getState()).toEqual({ + chainId: Number.parseInt(chainId, 16), + accounts, + activating: false, + }) + }) +}) diff --git a/packages/safepal-wallet/src/index.ts b/packages/safepal-wallet/src/index.ts new file mode 100644 index 000000000..cc889de4f --- /dev/null +++ b/packages/safepal-wallet/src/index.ts @@ -0,0 +1,208 @@ +import { + EIP1193Options, + currentProvider, + getDownload, + getIsInstall, + installWalletMessage +} from '@safepal-wallet/web3-sdk' +import type { + Actions, + AddEthereumChainParameter, + Provider, + ProviderConnectInfo, + ProviderRpcError, + WatchAssetParameters, +} from '@web3-react/types' +import { Connector } from '@web3-react/types' + +type SafePalWalletProvider = Provider & { + isSafePal?: boolean + isConnected?: () => boolean + providers?: SafePalWalletProvider[] + get chainId(): string + get accounts(): string[] +} + +export class NoSafePalWalletError extends Error { + public constructor() { + super('SafePalWallet not installed') + this.name = NoSafePalWalletError.name + Object.setPrototypeOf(this, NoSafePalWalletError.prototype) + } +} + +type SafePalWalletConnectorOptions = EIP1193Options & { + /** + * Connector automatically connects when used as Safe App. + * + * This flag simulates the disconnect behavior by keeping track of connection status in storage + * and only autoconnecting when previously connected by user action (e.g. explicitly choosing to connect). + * + * @default false + */ + shimDisconnect?: boolean +} + +function parseChainId(chainId: string) { + return Number.parseInt(chainId, 16) +} + +/** + * @param options - Options to pass to `@SafePalWallet/detect-provider` + * @param onError - Handler to report errors thrown from eventListeners. + */ +export interface SafePalWalletConstructorArgs { + actions: Actions + options?: SafePalWalletConnectorOptions + onError?: (error: Error) => void +} + +export class SafePalWallet extends Connector { + /** {@inheritdoc Connector.provider} */ + public provider?: SafePalWalletProvider + private readonly options?: SafePalWalletConnectorOptions + + constructor({ actions, options, onError }: SafePalWalletConstructorArgs) { + super(actions, onError) + this.options = options + } + + private async isomorphicInitialize(): Promise { + const provider = currentProvider(); + if (getIsInstall()) { + this.provider = provider as SafePalWalletProvider + this.provider.on('connect', ({ chainId }: ProviderConnectInfo): void => { + this.actions.update({ chainId: parseChainId(chainId) }) + }) + this.provider.on('disconnect', (error: ProviderRpcError): void => { + // 1013 indicates that SafePalWallet is attempting to reestablish the connection + if (error.code === 1013) { + console.debug('SafePalWallet logged connection error 1013: "Try again later"') + return + } + this.actions.resetState() + this.onError?.(error) + }) + + this.provider.on('chainChanged', (chainId: string): void => { + this.actions.update({ chainId: parseChainId(chainId) }) + }) + + this.provider.on('accountsChanged', (accounts: string[]): void => { + if (accounts.length === 0) { + // handle this edge case by disconnecting + this.actions.resetState() + } else { + this.actions.update({ accounts }) + } + }) + } else { + window.open(getDownload(), '_blank') + throw new Error(installWalletMessage) + } + } + + /** {@inheritdoc Connector.connectEagerly} */ + public async connectEagerly(): Promise { + const cancelActivation = this.actions.startActivation() + + try { + await this.isomorphicInitialize() + if (!this.provider) return cancelActivation() + + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = (await this.provider.request({ method: 'eth_accounts' })) as string[] + if (!accounts.length) throw new Error('No accounts returned') + const chainId = (await this.provider.request({ method: 'eth_chainId' })) as string + this.actions.update({ chainId: parseChainId(chainId), accounts }) + } catch (error) { + console.debug('Could not connect eagerly', error) + // we should be able to use `cancelActivation` here, but on mobile, SafePal Wallet emits a 'connect' + // event, meaning that chainId is updated, and cancelActivation doesn't work because an intermediary + // update has occurred, so we reset state instead + this.actions.resetState() + } + } + + /** + * Initiates a connection. + * + * @param desiredChainIdOrChainParameters - If defined, indicates the desired chain to connect to. If the user is + * already connected to this chain, no additional steps will be taken. Otherwise, the user will be prompted to switch + * to the chain, if one of two conditions is met: either they already have it added in their extension, or the + * argument is of type AddEthereumChainParameter, in which case the user will be prompted to add the chain with the + * specified parameters first, before being prompted to switch. + */ + public async activate(desiredChainIdOrChainParameters?: number | AddEthereumChainParameter): Promise { + let cancelActivation: () => void + if (!this.provider?.isConnected?.()) cancelActivation = this.actions.startActivation() + + return this.isomorphicInitialize() + .then(async () => { + if (!this.provider) throw new NoSafePalWalletError() + + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = (await this.provider.request({ method: 'eth_requestAccounts' })) as string[] + const chainId = (await this.provider.request({ method: 'eth_chainId' })) as string + const receivedChainId = parseChainId(chainId) + const desiredChainId = + typeof desiredChainIdOrChainParameters === 'number' + ? desiredChainIdOrChainParameters + : desiredChainIdOrChainParameters?.chainId + + // if there's no desired chain, or it's equal to the received, update + if (!desiredChainId || receivedChainId === desiredChainId) + return this.actions.update({ chainId: receivedChainId, accounts }) + + const desiredChainIdHex = `0x${desiredChainId.toString(16)}` + + // if we're here, we can try to switch networks + return this.provider + .request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: desiredChainIdHex }], + }) + .catch((error: ProviderRpcError) => { + const errorCode = (error.data as any)?.originalError?.code || error.code + if (errorCode === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { + if (!this.provider) throw new Error('No provider') + // if we're here, we can try to add a new network + return this.provider.request({ + method: 'wallet_addEthereumChain', + params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], + }) + } + throw error + }) + .then(() => this.activate(desiredChainId)) + }) + .catch((error) => { + cancelActivation?.() + throw error + }) + } + + public async watchAsset({ address, symbol, decimals, image }: WatchAssetParameters): Promise { + if (!this.provider) throw new Error('No provider') + + return this.provider + .request({ + method: 'wallet_watchAsset', + params: { + type: 'ERC20', // Initially only supports ERC20, but eventually more! + options: { + address, // The address that the token is at. + symbol, // A ticker symbol or shorthand, up to 5 chars. + decimals, // The number of decimals in the token + image, // A string url of the token logo + }, + }, + }) + .then((success) => { + if (!success) throw new Error('Rejected') + return true + }) + } +} \ No newline at end of file diff --git a/packages/safepal-wallet/tsconfig.json b/packages/safepal-wallet/tsconfig.json new file mode 100644 index 000000000..67531bbb0 --- /dev/null +++ b/packages/safepal-wallet/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "outDir": "./dist" + } +}