From 1e7d8491f9a2cbd81174e615334e4355646e55f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Fri, 20 Jan 2023 17:27:29 +0800 Subject: [PATCH] Refactor wallet connect, automatically propose network --- .../topbar-account-panel/component.js | 66 +++++++-------- .../topbar-account-panel/template.hbs | 13 ++- app/services/browser-cache.js | 4 +- app/services/kredits.js | 70 ++++++++++------ app/templates/dashboard.hbs | 56 +++++++------ app/templates/signup/account.hbs | 11 +-- app/utils/switch-network.js | 29 +++++++ config/environment.js | 11 ++- package.json | 2 +- .../topbar-account-panel/component-test.js | 83 +++++++++++++++---- 10 files changed, 225 insertions(+), 120 deletions(-) create mode 100644 app/utils/switch-network.js diff --git a/app/components/topbar-account-panel/component.js b/app/components/topbar-account-panel/component.js index ed1bcfb..4ecb2df 100644 --- a/app/components/topbar-account-panel/component.js +++ b/app/components/topbar-account-panel/component.js @@ -1,45 +1,37 @@ -import Component from '@ember/component'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; -import { computed } from '@ember/object'; +import { action } from '@ember/object'; import { isPresent } from '@ember/utils'; -export default Component.extend({ +export default class TopbarAccountPanelComponent extends Component { + @service router; + @service kredits; - tagName: '', + @tracked setupInProgress = false; - kredits: service(), - router: service(), - - setupInProgress: false, - - userHasEthereumWallet: computed(function() { + get userHasWallet () { return isPresent(window.ethereum); - }), - - showConnectButton: computed('userHasEthereumWallet', - 'kredits.hasAccounts', function() { - return this.userHasEthereumWallet && - !this.kredits.hasAccounts; - }), - - actions: { - - signup() { - this.router.transitionTo('signup'); - }, - - async connectAccount() { - try { - await window.ethereum.enable(); - this.set('setupInProgress', true); - await this.kredits.setup(); - this.set('setupInProgress', false); - } catch (error) { - this.set('setupInProgress', false); - console.log('Opening Ethereum wallet failed:', error); - } - } - } -}); + get walletConnected () { + return this.userHasWallet && this.kredits.hasAccounts; + } + + get walletDisconnected () { + return this.userHasWallet && !this.kredits.hasAccounts; + } + + @action + signup () { + this.router.transitionTo('signup'); + } + + @action + async connectWallet () { + this.setupInProgress = true; + await this.kredits.connectWallet(); + this.setupInProgress = false; + } + +} diff --git a/app/components/topbar-account-panel/template.hbs b/app/components/topbar-account-panel/template.hbs index 1c113cf..28dc713 100644 --- a/app/components/topbar-account-panel/template.hbs +++ b/app/components/topbar-account-panel/template.hbs @@ -2,16 +2,15 @@ {{#if this.setupInProgress}} Connecting account... {{else}} - {{#if (and this.kredits.hasAccounts this.kredits.currentUser)}} + {{#if (and this.walletConnected this.kredits.currentUser)}} {{this.kredits.currentUser.name}} - {{#if this.kredits.currentUserIsCore}} - (core) - {{/if}} {{else}} Anonymous - - {{#if this.showConnectButton}} - + + {{#if this.walletDisconnected}} + {{/if}} {{/if}} {{/if}} diff --git a/app/services/browser-cache.js b/app/services/browser-cache.js index 33cac37..2d828f7 100644 --- a/app/services/browser-cache.js +++ b/app/services/browser-cache.js @@ -4,8 +4,8 @@ import config from 'kredits-web/config/environment'; function createStore(name) { let networkName; - if (config.web3RequiredNetworkName) { - networkName = config.web3RequiredNetworkName.toLocaleLowerCase().replace(' ', '-'); + if (config.web3NetworkName) { + networkName = config.web3NetworkName.toLocaleLowerCase().replace(' ', '-'); } else { networkName = 'custom'; } diff --git a/app/services/kredits.js b/app/services/kredits.js index 3436e7e..bd1d5e1 100644 --- a/app/services/kredits.js +++ b/app/services/kredits.js @@ -5,7 +5,7 @@ import Service from '@ember/service'; import EmberObject from '@ember/object'; import { computed } from '@ember/object'; import { alias, filterBy, notEmpty, sort } from '@ember/object/computed'; -import { isEmpty, isPresent } from '@ember/utils'; +import { isEmpty } from '@ember/utils'; import { inject as service } from '@ember/service'; import { task, taskGroup } from 'ember-concurrency'; @@ -15,6 +15,7 @@ import processContributorData from 'kredits-web/utils/process-contributor-data'; import processContributionData from 'kredits-web/utils/process-contribution-data'; import processReimbursementData from 'kredits-web/utils/process-reimbursement-data'; import formatKredits from 'kredits-web/utils/format-kredits'; +import switchNetwork from 'kredits-web/utils/switch-network'; import config from 'kredits-web/config/environment'; import Contributor from 'kredits-web/models/contributor'; @@ -55,6 +56,21 @@ export default Service.extend({ this.set('contributors', []); this.set('contributions', []); this.set('reimbursements', []); + + if (window.ethereum) { + window.ethereum.on('chainChanged', this.handleUserChainChanged); + window.ethereum.on('accountsChanged', this.handleAccountsChanged); + } + }, + + handleUserChainChanged (chainId) { + console.log('User-provided chain ID changed to', chainId); + window.location.reload(); + }, + + handleAccountsChanged (accounts) { + console.log('User-provided accounts changed to', accounts); + window.location.reload(); }, // This is called in the application route's beforeModel(). So it is @@ -64,7 +80,7 @@ export default Service.extend({ let ethProvider; return new Promise(resolve => { - function instantiateWithoutAccount () { + function instantiateWithoutWallet () { console.debug('[kredits] Creating new instance from npm module class'); console.debug(`[kredits] providerURL: ${config.web3ProviderUrl}`); ethProvider = new ethers.providers.JsonRpcProvider(config.web3ProviderUrl); @@ -74,44 +90,50 @@ export default Service.extend({ }); } - async function instantiateWithAccount (web3Provider, context) { + async function instantiateWithWallet (web3Provider, context) { console.debug('[kredits] Using user-provided Web3 instance, e.g. from Metamask'); - ethProvider = new ethers.providers.Web3Provider(web3Provider); + ethProvider = new ethers.providers.Web3Provider(web3Provider); + const network = await ethProvider.getNetwork(); + const accounts = await ethProvider.listAccounts(); + const chainId = config.web3ChainId; - const network = await ethProvider.getNetwork(); - if (isPresent(config.web3RequiredChainId) && - network.chainId !== config.web3RequiredChainId) { - return instantiateWithoutAccount(); - } + if (isEmpty(accounts)) return instantiateWithoutWallet(); - ethProvider.listAccounts().then(accounts => { + if (network.chainId !== chainId) { + return switchNetwork(); + } else { context.set('currentUserAccounts', accounts); const ethSigner = accounts.length === 0 ? null : ethProvider.getSigner(); resolve({ ethProvider, ethSigner }); - }); + } } if (window.ethereum) { - if (window.ethereum.isConnected()) { - instantiateWithAccount(window.ethereum, this); - } else { - instantiateWithoutAccount(); - } - } - // Legacy dapp browsers... - else if (window.web3) { - instantiateWithAccount(window.web3.currentProvider, this); - } - // Non-dapp browsers... - else { - instantiateWithoutAccount(); + instantiateWithWallet(window.ethereum, this); + } else { + instantiateWithoutWallet(); } }); }, + async connectWallet () { + const provider = new ethers.providers.Web3Provider(window.ethereum); + const network = await provider.getNetwork(); + const chainId = config.web3ChainId; + const chainIdHex = `0x${Number(chainId).toString(16)}`; + + try { + await window.ethereum.request({ method: 'eth_requestAccounts' }); + if (network.chainId !== chainId) await switchNetwork(chainIdHex); + } catch (err) { + console.log('Connecting wallet failed:', err); + return false; + } + }, + async setup () { const kredits = await this.getEthProvider().then(providerAndSigner => { return new Kredits(providerAndSigner.ethProvider, providerAndSigner.ethSigner, { diff --git a/app/templates/dashboard.hbs b/app/templates/dashboard.hbs index edbdb03..be6609b 100644 --- a/app/templates/dashboard.hbs +++ b/app/templates/dashboard.hbs @@ -45,14 +45,16 @@ -
-
-

Contributions by type

-
-
- -
-
+ {{#if this.contributionsConfirmed}} +
+
+

Contributions by type

+
+
+ +
+
+ {{/if}}
@@ -87,24 +89,26 @@ {{/if}} -
-
-

Confirmed Contributions

- -
-
- -
-
+ {{#if this.contributionsConfirmed}} +
+
+

Confirmed Contributions

+ +
+
+ +
+
+ {{/if}} {{#if this.showFullContributionSync}}
diff --git a/app/templates/signup/account.hbs b/app/templates/signup/account.hbs index e3f32ee..8e934db 100644 --- a/app/templates/signup/account.hbs +++ b/app/templates/signup/account.hbs @@ -8,20 +8,21 @@ part in the community's project governance and finances.

- In order to interact with the system you will need a Rootstock wallet/account. + In order to interact with the system you will need a + Rootstock + wallet/account.

- diff --git a/app/utils/switch-network.js b/app/utils/switch-network.js new file mode 100644 index 0000000..5e22c55 --- /dev/null +++ b/app/utils/switch-network.js @@ -0,0 +1,29 @@ +import config from 'kredits-web/config/environment'; + +export default async function () { + const networkName = config.web3NetworkName; + const chainId = config.web3ChainId; + const chainIdHex = `0x${Number(chainId).toString(16)}`; + + try { + await window.ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainIdHex }] + }); + } catch (err) { + // This error code indicates that the chain has not been added to MetaMask + if (err.code === 4902) { + await window.ethereum.request({ + method: 'wallet_addEthereumChain', + params: [{ + chainId: chainIdHex, + chainName: networkName, + rpcUrls: [ config.web3ProviderUrl ], + nativeCurrency: { name: 'tRBTC', symbol: 'tRBTC', decimals: 18 } + }] + }); + } else { + console.warn('Failed to switch chains:', err.message); + } + } +} diff --git a/config/environment.js b/config/environment.js index dcd354d..a3afbd6 100644 --- a/config/environment.js +++ b/config/environment.js @@ -26,8 +26,8 @@ module.exports = function(environment) { }, web3ProviderUrl: 'https://rsk-testnet.kosmos.org', - web3RequiredChainId: 31, - web3RequiredNetworkName: 'RSK Testnet', + web3ChainId: 31, + web3NetworkName: 'RSK Testnet', githubConnectUrl: 'https://hal8000.chat.kosmos.org/kredits/signup/connect/github', githubSignupUrl: 'https://hal8000.chat.kosmos.org/kredits/signup/github', @@ -82,7 +82,12 @@ module.exports = function(environment) { if (process.env.WEB3_PROVIDER_URL) { ENV.web3ProviderUrl = process.env.WEB3_PROVIDER_URL; - ENV.web3RequiredChainId = null; + } + if (process.env.WEB3_CHAIN_ID) { + ENV.web3ChainId = parseInt(process.env.WEB3_CHAIN_ID); + } + if (process.env.WEB3_NETWORK_NAME) { + ENV.web3NetworkName = process.env.WEB3_NETWORK_NAME; } return ENV; diff --git a/package.json b/package.json index 6ba8540..c3bfdea 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "start": "ember serve", "test": "npm-run-all lint:* test:*", "test:ember": "ember test", - "start:local": "WEB3_PROVIDER_URL=http://localhost:8545 ember serve", + "start:local": "WEB3_PROVIDER_URL=http://localhost:8545 WEB3_CHAIN_ID=1337 WEB3_NETWORK_NAME='Hardhat Devchain' ember serve", "build": "ember build --environment=production", "build-prod": "rm -rf release/* && ember build -prod --output-path release", "preversion": "npm test", diff --git a/tests/integration/components/topbar-account-panel/component-test.js b/tests/integration/components/topbar-account-panel/component-test.js index 251c7ed..457280d 100644 --- a/tests/integration/components/topbar-account-panel/component-test.js +++ b/tests/integration/components/topbar-account-panel/component-test.js @@ -3,37 +3,90 @@ import { setupRenderingTest } from 'ember-qunit'; import { render } from '@ember/test-helpers'; import hbs from 'htmlbars-inline-precompile'; +function stubWalletApi () { + window.ethereum = { + on: function() { return true; }, + request: function() { return Promise.resolve(); } + } +} + module('Integration | Component | topbar-account-panel', function(hooks) { setupRenderingTest(hooks); - test('unknown user without wallet (or no permission to get wallet/account info)', async function(assert) { + test('Unknown user without wallet', async function(assert) { await render(hbs``); - assert.ok(this.element.textContent.trim().match(/^Anonymous/)); + assert.ok( + this.element.textContent.trim().match(/^Anonymous/), + 'shows current user as anonymous' + ); + assert.equal( + this.element.querySelectorAll('button#signup').length, 1, + 'signup button is visible' + ); + assert.equal( + this.element.querySelectorAll('button#connect').length, 0, + 'connect button is not visible' + ); }); - test('unknown user with Ethereum wallet', async function(assert) { + test('Unknown user with disconnected wallet', async function(assert) { + stubWalletApi(); + let service = this.owner.lookup('service:kredits'); + service.set('currentUserAccounts', []); + await render(hbs``); + + assert.ok( + this.element.textContent.trim().match(/^Anonymous/), + 'shows current user as anonymous' + ); + assert.equal( + this.element.querySelectorAll('button#signup').length, 1, + 'signup button is visible' + ); + assert.equal( + this.element.querySelectorAll('button#connect').length, 1, + 'connect button is visible' + ); + }); + + test('Unknown user with connected wallet', async function(assert) { + stubWalletApi(); let service = this.owner.lookup('service:kredits'); service.set('currentUserAccounts', [{ foo: 'bar' }]); await render(hbs``); - assert.ok(this.element.textContent.trim().match(/^Anonymous/)); + assert.ok( + this.element.textContent.trim().match(/^Anonymous/), + 'shows current user as anonymous' + ); + assert.equal( + this.element.querySelectorAll('button#signup').length, 1, + 'signup button is visible' + ); + assert.equal( + this.element.querySelectorAll('button#connect').length, 0, + 'connect button is not visible' + ); }); - test('known contributor', async function(assert) { + test('Known contributor', async function(assert) { + stubWalletApi(); let service = this.owner.lookup('service:kredits'); service.set('currentUserAccounts', [{ foo: 'bar' }]); - service.set('currentUser', { - name: 'Dorian Nakamoto', - isCore: false - }); + service.set('currentUser', { name: 'Dorian Nakamoto', isCore: false }); await render(hbs``); - assert.dom(this.element).hasText('Dorian Nakamoto'); - - service.set('currentUser.isCore', true); - await render(hbs``); - - assert.equal(this.element.querySelectorAll('span.core-flag').length, 1); + assert.dom(this.element).hasText( + 'Dorian Nakamoto', 'shows current user\'s name' + ); + assert.equal( + this.element.querySelectorAll('button#signup').length, 0, + 'signup button is not visible' + ); + assert.equal( + this.element.querySelectorAll('button#connect').length, 0, + 'connect button is not visible' + ); }); });