diff --git a/packages/frame/README.md b/packages/frame/README.md new file mode 100644 index 000000000..16593e9b4 --- /dev/null +++ b/packages/frame/README.md @@ -0,0 +1 @@ +# @web3-react/frame diff --git a/packages/frame/package.json b/packages/frame/package.json new file mode 100644 index 000000000..12f7e6ea5 --- /dev/null +++ b/packages/frame/package.json @@ -0,0 +1,30 @@ +{ + "name": "@web3-react/frame", + "keywords": [ + "web3-react", + "frame" + ], + "author": "Jean-BenoƮt RICHEZ ", + "license": "GPL-3.0-or-later", + "repository": "github:Uniswap/web3-react", + "publishConfig": { + "access": "public" + }, + "version": "8.0.7-beta.0", + "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": { + "eth-provider": "^0.13.6", + "@web3-react/types": "^8.0.20-beta.0" + } +} diff --git a/packages/frame/src/index.ts b/packages/frame/src/index.ts new file mode 100644 index 000000000..f7741a7b2 --- /dev/null +++ b/packages/frame/src/index.ts @@ -0,0 +1,164 @@ +import { Actions, AddEthereumChainParameter, Connector, Provider, ProviderConnectInfo, ProviderRpcError, WatchAssetParameters } from "@web3-react/types"; +import ethProvider from "eth-provider"; + +function parseChainId(chainId: string) { + return Number.parseInt(chainId, 16) +} + +export class NoFrameError extends Error { + public constructor() { + super('Frame not installed') + this.name = NoFrameError.name + Object.setPrototypeOf(this, NoFrameError.prototype) + } +} + +export type FrameProvider = Provider & { + isConnected?: () => boolean +} + +export interface FrameConstructorArgs{ + actions: Actions + // options?: Parameters[0] + onError?: (error: Error) => void +} + +export class Frame extends Connector{ + + /** {@inheritdoc Connector.provider} */ + declare public provider?: FrameProvider; + private eagerConnection?: Promise; + + constructor({ actions, onError }: FrameConstructorArgs) { + super(actions, onError) + } + + private async isomorphicInitialize(): Promise { + if (this.eagerConnection) return; + + const provider = ethProvider('frame'); + if(!provider) return; + + this.provider = provider as FrameProvider; + + if(this.provider){ + this.provider.on('connect', ({ chainId }: ProviderConnectInfo): void => { + this.actions.update({ chainId: parseChainId(chainId) }) + }); + + this.provider.on('disconnect', (error: ProviderRpcError): void => { + 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 }) + } + }); + } + + } + + /** {@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) + 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 NoFrameError() + + // 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) => { + 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/frame/tsconfig.json b/packages/frame/tsconfig.json new file mode 100644 index 000000000..cb5574e36 --- /dev/null +++ b/packages/frame/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "outDir": "./dist" + } + } + \ No newline at end of file diff --git a/packages/frame/yarn.lock b/packages/frame/yarn.lock new file mode 100644 index 000000000..2c496c437 --- /dev/null +++ b/packages/frame/yarn.lock @@ -0,0 +1,80 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@web3-react/types@^8.0.20-beta.0": + version "8.2.2" + resolved "https://registry.yarnpkg.com/@web3-react/types/-/types-8.2.2.tgz#1ae7f11069d9a9c711aa4152f95331747fb1e551" + integrity sha512-PrPrJNjJhUX3lL/365llAZwY0bpUm9N52OjGMFyzCIX7IR13f7WLUk/LyQa9ALneCBu3cJUVTZANuFdqdREuvw== + dependencies: + zustand "4.4.0" + +cookiejar@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + +eth-provider@^0.13.6: + version "0.13.6" + resolved "https://registry.yarnpkg.com/eth-provider/-/eth-provider-0.13.6.tgz#664ad8a5b0aa5db41ff419e6cc1081b4588f1c12" + integrity sha512-/i0qSQby/rt3CCZrNVlgBdCUYQBwULStFRlBt7+ULNVpwbsYWl9VWXFaQxsbJLOo0x7swRS3OknIdlxlunsGJw== + dependencies: + ethereum-provider "0.7.7" + events "3.3.0" + oboe "2.1.5" + uuid "9.0.0" + ws "8.9.0" + xhr2-cookies "1.1.0" + +ethereum-provider@0.7.7: + version "0.7.7" + resolved "https://registry.yarnpkg.com/ethereum-provider/-/ethereum-provider-0.7.7.tgz#c67c69aa9ced8f728dacc2b4c00ad4a8bf329319" + integrity sha512-ulbjKgu1p2IqtZqNTNfzXysvFJrMR3oTmWEEX3DnoEae7WLd4MkY4u82kvXhxA2C171rK8IVlcodENX7TXvHTA== + dependencies: + events "3.3.0" + +events@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +http-https@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/http-https/-/http-https-1.0.0.tgz#2f908dd5f1db4068c058cd6e6d4ce392c913389b" + integrity sha512-o0PWwVCSp3O0wS6FvNr6xfBCHgt0m1tvPLFOCc2iFDKTRAXhB7m8klDf7ErowFH8POa6dVdGatKU5I1YYwzUyg== + +oboe@2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/oboe/-/oboe-2.1.5.tgz#5554284c543a2266d7a38f17e073821fbde393cd" + integrity sha512-zRFWiF+FoicxEs3jNI/WYUrVEgA7DeET/InK0XQuudGHRg8iIob3cNPrJTKaz4004uaA9Pbe+Dwa8iluhjLZWA== + dependencies: + http-https "^1.0.0" + +use-sync-external-store@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + +uuid@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + +ws@8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" + integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== + +xhr2-cookies@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/xhr2-cookies/-/xhr2-cookies-1.1.0.tgz#7d77449d0999197f155cb73b23df72505ed89d48" + integrity sha512-hjXUA6q+jl/bd8ADHcVfFsSPIf+tyLIjuO9TwJC9WI6JP2zKcS7C+p56I9kCLLsaCiNT035iYvEUUzdEFj/8+g== + dependencies: + cookiejar "^2.1.1" + +zustand@4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.0.tgz#13b3e8ca959dd53d536034440aec382ff91b65c3" + integrity sha512-2dq6wq4dSxbiPTamGar0NlIG/av0wpyWZJGeQYtUOLegIUvhM2Bf86ekPlmgpUtS5uR7HyetSiktYrGsdsyZgQ== + dependencies: + use-sync-external-store "1.2.0"