diff --git a/cypress/integration/connect.spec.ts b/cypress/integration/connect.spec.ts new file mode 100644 index 000000000..02e694966 --- /dev/null +++ b/cypress/integration/connect.spec.ts @@ -0,0 +1,19 @@ +/// +import { MockExtensionProvider } from "../support"; + +describe("MetaMask", () => { + it("Lets user connect with Metamask provider", () => { + cy.visit("/faucet", { + onBeforeLoad(win) { + win.ethereum = new MockExtensionProvider("rinkeby", "0x3256a804085C24f3451cAb2C98a37e16DEEc5721"); + } + }); + cy.get("button.connect").click(); + cy.get("button.secondary").click(); + cy.get("button.address").should("contain", "3256 – 5721"); + cy.window().then((win) => { + win.ethereum.changeAccount("0xd3b5586D15140B6f793b260fd90588A0dAefc5B6"); + }); + cy.get("button.address").should("contain", "d3b5 – c5B6"); + }); +}); diff --git a/cypress/integration/home.spec.ts b/cypress/integration/home.spec.ts index c916024f0..cc19d42b7 100644 --- a/cypress/integration/home.spec.ts +++ b/cypress/integration/home.spec.ts @@ -17,7 +17,7 @@ describe("Landing page", () => { cy.intercept("https://willow.radicle.garden:8777/v1/projects", { fixture: "projectList.json" }); cy.visit("/", { onBeforeLoad(win) { - win.ethereum = new MockExtensionProvider(); + win.ethereum = new MockExtensionProvider("homestead", "0x3256a804085C24f3451cAb2C98a37e16DEEc5721"); }, }); cy.get("div.card-label") diff --git a/cypress/integration/project.spec.ts b/cypress/integration/project.spec.ts index 958e58d95..78817a025 100644 --- a/cypress/integration/project.spec.ts +++ b/cypress/integration/project.spec.ts @@ -21,7 +21,7 @@ describe("Project view", () => { it("Page header", () => { cy.visit("/seeds/willow.radicle.garden/bright-forest-protocol", { onBeforeLoad(win) { - win.ethereum = new MockExtensionProvider(); + win.ethereum = new MockExtensionProvider("homestead", "0x3256a804085C24f3451cAb2C98a37e16DEEc5721"); }, }); cy.get("div.title").contains("bright-forest-protocol"); diff --git a/cypress/plugins/index.cjs b/cypress/plugins/index.cjs index d4edfb687..6799380a2 100644 --- a/cypress/plugins/index.cjs +++ b/cypress/plugins/index.cjs @@ -8,7 +8,7 @@ module.exports = (on, config) => { options, viteConfig: { configFile: path.resolve(__dirname, '..', '..', 'vite.config.ts'), - logLevel: "warn" + logLevel: "silent" }, }); }); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 04cb7c542..e28084d3c 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,31 +1,92 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import '@cypress/code-coverage/support'; +import { JsonRpcProvider, JsonRpcSigner } from '@ethersproject/providers'; import '@testing-library/cypress/add-commands'; -import { ethers } from 'ethers'; +import { BigNumber, ethers } from 'ethers'; declare global { interface Window { ethereum: any; + localStorage: Storage; } } export class MockExtensionProvider extends ethers.providers.BaseProvider { isMetaMask = true; + currentAddress: string; - constructor() { - super({ name: "homestead", chainId: 1 }); + constructor(network: ethers.providers.Networkish, address: string) { + super(network); + this.currentAddress = address; console.log("Creating Mock provider"); } + + get ready(): Promise { + return Promise.resolve(this.network); + } + + getSigner(addressOrIndex?: string | number): JsonRpcSigner { + return new JsonRpcSigner({}, this as unknown as JsonRpcProvider, addressOrIndex); + } + + changeAccount(address: string): void { + this.currentAddress = address; + this.emit("accountsChanged", [address]); + } + async request({ method, params }: { method: string; params: any }): Promise { switch (method) { case 'eth_chainId': - return "1"; + return this.network.chainId; case 'net_version': - return "1"; + return this.network.chainId; case 'eth_call': return resolveEthCall(params); + case 'eth_accounts': + return [this.currentAddress]; + case 'eth_requestAccounts': + return [this.currentAddress]; case 'eth_getCode': return "0x"; + case 'eth_blockNumber': + return BigNumber.from(1); + case 'eth_estimateGas': + return BigNumber.from(21000); + case 'eth_sendTransaction': + return "0x8829dea7e20ebcf6dbfd942e3613d7ac49b9aef3ecbed396acfc5901713f5983"; + case 'eth_getTransactionByHash': + return { + hash: "0x8829dea7e20ebcf6dbfd942e3613d7ac49b9aef3ecbed396acfc5901713f5983", + to: "0x0000000000000000000000000000000000000000", + from: "0x0000000000000000000000000000000000000000", + nonce: 123, + gasLimit: BigNumber.from(21000), + gasPrice: BigNumber.from(40), + data: "0x", + value: BigNumber.from(0), + chainId: 4, + }; + case 'eth_getTransactionCount': + return BigNumber.from(123); + case 'eth_getTransactionReceipt': + return { + to: '0x01139F82659A3bD56D1f051D57D4Bc96a3b9Ef05', + from: '0x00192Fb10dF37c9FB26829eb2CC623cd1BF599E8', + contractAddress: null, + transactionIndex: 338, + gasUsed: BigNumber.from('0x5208'), + logsBloom: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + blockHash: '0xdbe7ca0dd26310cd5415202c67467d46795cfec922dbb47e4cc4ff388e7856d2', + transactionHash: '0x8829dea7e20ebcf6dbfd942e3613d7ac49b9aef3ecbed396acfc5901713f5983', + logs: [], + blockNumber: 14451272, + confirmations: 53, + cumulativeGasUsed: BigNumber.from('0x01b11d57'), + effectiveGasPrice: BigNumber.from('0x0dffd03ca0'), + status: 1, + type: 2, + byzantium: true + }; default: console.log("Unknown method", method); break; @@ -34,16 +95,28 @@ export class MockExtensionProvider extends ethers.providers.BaseProvider { } function resolveEthCall(params: { to: string; data: string }[]): string { + const [{ to, data }] = params; + // Get Resolver - if (params[0].to === "0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e") { + if (to === "0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e") { return "0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41"; // Get ENS Attributes - } else if (params[0].to === "0x4976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41" && params[0].data === "0x59d1d43c567c364804de7bbedb53f583e483f6b73513fd2f44299e281024e4719da0b332000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000") { + } else if (to === "0x4976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41" && data === "0x59d1d43c567c364804de7bbedb53f583e483f6b73513fd2f44299e281024e4719da0b332000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066176617461720000000000000000000000000000000000000000000000000000") { return "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001f68747470733a2f2f636c6f7564686561642e696f2f6176617461722e706e6700"; // Get Org informations - } else if (params[0].to === "0x8152237402e0f194176154c3a6ea1eb99b611482" && params[0].data === "0x8da5cb5b") { + } else if (to === "0x8152237402e0f194176154c3a6ea1eb99b611482" && data === "0x8da5cb5b") { return "0x000000000000000000000000ceab094641905c209cc796fc8037dd9ecc87ca2f"; - } else { + // Get Token Balance + } else if (to === "0x31c8eacbffdd875c74b94b077895bd78cf1e64a3" && data === "0x70a082310000000000000000000000003256a804085c24f3451cab2c98a37e16deec5721") { + return "0x00000000000000000000000000000000000000000000000246DDF97976680000"; + // getMaxWithdrawAmount + } else if (to === "0x9aa75397ed632a3060acb5de7f96e2457bceed8d" && data === "0xf516440c") { + return "0x000000000000000000000000000000000000000000000001D7D843DC3B480000"; + // Get resolved address from ENS + } else if (to === "0x4976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41") { return "0x000000000000000000000000394b920c5d39e0ca40fca2871569b6b90d750c7c"; + // Return 0 for token balances else + } else { + return "0x0000000000000000000000000000000000000000000000000000000000000000"; } } diff --git a/src/base/faucet/Index.svelte b/src/base/faucet/Index.svelte index c7df94943..2b5abced6 100644 --- a/src/base/faucet/Index.svelte +++ b/src/base/faucet/Index.svelte @@ -32,7 +32,7 @@ if (!amount || amount === "0") { return [false, "Not able to withdraw zero tokens"]; } if (toWei(amount).gt(maxWithdrawAmount)) return [false, `Reduce amount, max withdrawal is ${formatEther(maxWithdrawAmount)}`]; let currentTime = new Date().getTime(); - let timelock = await calculateTimeLock(amount, $session.signer, config); + let timelock = await calculateTimeLock(amount, $session.config.signer, config); // Converting a 10 digit to 13 digit timestamp by multiplying by 1000 // since JS doesn't display a correct Date string when passing a 10 digit timestamp. let nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000); @@ -48,8 +48,8 @@ } $: if ($session) { - getMaxWithdrawAmount($session.signer, config).then(x => maxWithdrawAmount = x); - lastWithdrawalByUser($session.signer, config).then(x => lastWithdrawal = x); + getMaxWithdrawAmount($session.config.signer, config).then(x => maxWithdrawAmount = x); + lastWithdrawalByUser($session.config.signer, config).then(x => lastWithdrawal = x); } diff --git a/src/base/faucet/Withdraw.svelte b/src/base/faucet/Withdraw.svelte index b67166ef7..68cdaac51 100644 --- a/src/base/faucet/Withdraw.svelte +++ b/src/base/faucet/Withdraw.svelte @@ -25,7 +25,7 @@ try { if ($session) { state.status = Status.Signing; - const tx = await withdraw(amount, $session.signer, config); + const tx = await withdraw(amount, $session.config.signer, config); state.status = Status.Pending; await tx.wait(); state.status = Status.Success; diff --git a/src/base/seeds/View.svelte b/src/base/seeds/View.svelte index d008cb969..7b48dde38 100644 --- a/src/base/seeds/View.svelte +++ b/src/base/seeds/View.svelte @@ -109,7 +109,7 @@
- {#if session?.signer} + {#if session?.config.signer} {#if siweSession}
Signed in as diff --git a/src/session.ts b/src/session.ts index 7a322147f..5a287ed22 100644 --- a/src/session.ts +++ b/src/session.ts @@ -8,7 +8,6 @@ import type { TypedDataSigner } from '@ethersproject/abstract-signer'; import type { WalletConnectSigner } from "./WalletConnectSigner"; import * as ethers from "ethers"; import type { SeedSession } from "./siwe"; -import { unixTime } from "./utils"; export enum Connection { Disconnected, @@ -23,7 +22,21 @@ export type TxState = | { state: 'fail'; hash: string; blockHash: string; blockNumber: number; error: string } | null; -export type Signer = ethers.Signer & TypedDataSigner | WalletConnectSigner; +export type Signer = ethers.Signer & TypedDataSigner | WalletConnectSigner | null; + +// Defines the type of signer we are using in the current session. +// Allows us to guard certain functionality for a specific signer. +enum SignerType { + WalletConnect, + MetaMask +} + +// Definitions made in `Config` that need to be part of the session, +// to provide reactivity based on events i.e. accountsChanged +export interface SignerConfig { + signer: Signer; + type: SignerType; +} export type State = { connection: Connection.Disconnected } @@ -32,7 +45,7 @@ export type State = export interface Session { address: string; - signer: Signer | null; + config: SignerConfig; siwe: { [key: string]: SeedSession }; tokenBalance: BigNumber | null; // `null` means it isn't loaded yet. tx: TxState; @@ -47,7 +60,7 @@ export interface Store extends Readable { setTxSigning(): void; setTxPending(tx: TransactionResponse): void; setTxConfirmed(tx: TransactionReceipt): void; - setChangedAccount(address: string, config: Config): void; + setChangedAccount(address: string): void; } export const loadState = (initial: State): Store => { @@ -62,8 +75,9 @@ export const loadState = (initial: State): Store => { // Re-connect using previous session. if (config.metamask.connected) { const metamask = config.metamask.session; + const signerConfig: SignerConfig = { signer: config.signer, type: SignerType.MetaMask }; const tokenBalance: BigNumber = await config.token.balanceOf(metamask.address); - const session = { tokenBalance, tx: null, siwe, signer: config.metamask.signer, address: metamask.address }; + const session = { tokenBalance, tx: null, siwe, config: signerConfig, address: metamask.address }; store.set({ connection: Connection.Connected, session }); config.setSigner(config.metamask.signer); @@ -87,13 +101,14 @@ export const loadState = (initial: State): Store => { config.walletConnect.state.set({ state: "close" }); const tokenBalance: BigNumber = await config.token.balanceOf(address); - const session = { address, signer: config.metamask.signer, siwe, tokenBalance, tx: null }; + const signerConfig = { signer: config.signer, type: SignerType.MetaMask }; + const session = { config: signerConfig, siwe, address, tokenBalance, tx: null }; store.set({ connection: Connection.Connected, session, }); - saveSession({ ...session }); + saveMetamaskSession(session); } catch (e) { console.error(e); } @@ -107,9 +122,10 @@ export const loadState = (initial: State): Store => { await config.walletConnect.client.connect(); console.log("WalletConnect: connected."); + const signerConfig: SignerConfig = { signer: config.signer, type: SignerType.WalletConnect }; const address = await signer.getAddress(); const tokenBalance: BigNumber = await config.token.balanceOf(address); - const session = { address, signer, siwe, tokenBalance, tx: null }; + const session = { address, config: signerConfig, siwe, tokenBalance, tx: null }; const network = ethers.providers.getNetwork( signer.walletConnect.chainId ); @@ -125,6 +141,8 @@ export const loadState = (initial: State): Store => { } try { + // When the WalletConnect session is updated, we need to update the config signer. + config.getWalletConnectSigner(); // We only change accounts if the address has been changed, to avoid unnecessary refreshing. if (address !== accounts[0]) changeAccounts(accounts[0]); // Check the current chainId, and request Metamask to change, or reload the window to get the correct chain. @@ -163,7 +181,7 @@ export const loadState = (initial: State): Store => { switch (s.connection) { case Connection.Connected: s.session.siwe[id] = session; - saveSession(s.session); + saveSeedSession(s.session); return s; default: @@ -179,7 +197,7 @@ export const loadState = (initial: State): Store => { // If the token balance is loaded, we can update it, otherwise // we let it finish loading. s.session.tokenBalance = s.session.tokenBalance.add(n); - saveSession(s.session); + saveMetamaskSession(s.session); } return s; }); @@ -257,18 +275,20 @@ export const loadState = (initial: State): Store => { }); }, - setChangedAccount: (address: string, config: Config) => { + setChangedAccount: (address: string) => { store.update(s => { switch (s.connection) { case Connection.Connected: // In case of locking Metamask the accountsChanged event returns undefined. // To prevent out of sync state, the wallet gets disconnected. if (address === undefined) { + // This ends with a window reload. disconnectMetamask(); } else { s.session.address = address; - s.session.signer = config.signer; - saveSession(s.session); + // We only save the session to localStorage if we use a MetaMask signer + // WalletConnect does their own session persistance. + if (s.session.config.type === SignerType.MetaMask) saveMetamaskSession(s.session); } return s; default: @@ -301,7 +321,7 @@ window.ethereum?.on("accountsChanged", async ([address]: string) => { export async function changeAccounts(address: string): Promise { const config = await getConfig(); - state.setChangedAccount(address, config); + state.setChangedAccount(address); state.refreshBalance(config); } @@ -314,7 +334,7 @@ export function loadSeedSessions(): { [key: string]: SeedSession } { // We only keep the sessions that are still valid, and remove expired ones from `localStorage`. // For a session to be valid the expiration time has to be bigger or equal than the current time. const activeSessions = Object.fromEntries(Object.entries(siwe).filter(([, value]) => { - return value.expirationTime >= unixTime(); + return new Date(value.expirationTime) >= new Date(); })); window.localStorage.setItem("siwe", JSON.stringify({ ...activeSessions })); @@ -358,9 +378,10 @@ export function disconnectWallet(config: Config): void { disconnectMetamask(); } -function saveSession(session: Session): void { - const { address, tokenBalance, tx, siwe } = session; +function saveSeedSession(session: Session): void { + window.localStorage.setItem("siwe", JSON.stringify(session.siwe)); +} - window.localStorage.setItem("metamask", JSON.stringify({ address, tokenBalance, tx })); - window.localStorage.setItem("siwe", JSON.stringify(siwe)); +function saveMetamaskSession(session: Session): void { + window.localStorage.setItem("metamask", JSON.stringify({ address: session.address, tokenBalance: null, tx: null, config: null })); }