diff --git a/app/components/add-contributor/template.hbs b/app/components/add-contributor/template.hbs index 8478db6..c221fc6 100644 --- a/app/components/add-contributor/template.hbs +++ b/app/components/add-contributor/template.hbs @@ -1,6 +1,6 @@

- + (core) - {{/if}} {{else}} Anonymous - - {{#if this.showConnectButton}} - + + {{#if this.walletDisconnected}} + {{/if}} {{/if}} {{/if}} diff --git a/app/controllers/signup/account.js b/app/controllers/signup/account.js new file mode 100644 index 0000000..3c333a9 --- /dev/null +++ b/app/controllers/signup/account.js @@ -0,0 +1,50 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import config from 'kredits-web/config/environment'; +import { isAddress } from 'web3-utils'; + +export default class AccountController extends Controller { + @service kredits; + + @tracked accountAddress = null; + + get isValidEthAccount () { + return isAddress(this.accountAddress); + } + + get signupButtonDisabled () { + return !this.isValidEthAccount; + } + + @action + completeSignup () { + const payload = { + accessToken: this.kredits.githubAccessToken, + account: this.accountAddress + } + + fetch(config.githubSignupUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(res => res.json()) + .then(data => { + if (data.error) { + alert('Creating profile failed. We have been notified about this error and will take a look soon. Sorry!'); + console.warn('Creating contributor profile failed:', + JSON.parse(data.error.body).error.message) + return false; + } else { + console.log('[signup/account] Created contributor:', data); + + this.kredits.githubAccessToken = null; + this.accountAddress = null; + + this.transitionToRoute('signup.complete'); + } + }) + } +} diff --git a/app/controllers/signup/eth-account.js b/app/controllers/signup/eth-account.js deleted file mode 100644 index 1a29880..0000000 --- a/app/controllers/signup/eth-account.js +++ /dev/null @@ -1,49 +0,0 @@ -import Controller from '@ember/controller'; -import { computed } from '@ember/object'; -import { alias, not } from '@ember/object/computed'; -import { isAddress } from 'web3-utils'; -import { inject as service } from '@ember/service'; -import config from 'kredits-web/config/environment'; - -export default Controller.extend({ - - kredits: service(), - - ethAddress: null, - githubAccessToken: alias('kredits.githubAccessToken'), - - isValidEthAccount: computed('ethAddress', function() { - return isAddress(this.ethAddress); - }), - - signupButtonDisabled: not('isValidEthAccount'), - - actions: { - - completeSignup () { - const payload = { - accessToken: this.githubAccessToken, - account: this.ethAddress - } - - fetch(config.githubSignupUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }) - .then(res => res.json()) - .then(data => { - console.log('Created contributor:', data); - - this.setProperties({ - githubAccessToken: null, - ethAddress: null - }); - - this.transitionToRoute('signup.complete'); - }); - } - - } - -}); diff --git a/app/controllers/signup/index.js b/app/controllers/signup/index.js index d90035c..e8204a4 100644 --- a/app/controllers/signup/index.js +++ b/app/controllers/signup/index.js @@ -1,14 +1,12 @@ import Controller from '@ember/controller'; +import { action } from '@ember/object'; import config from 'kredits-web/config/environment'; -export default Controller.extend({ - - actions: { - - connectGithub () { - window.location = config.githubConnectUrl; - } +export default class IndexController extends Controller { + @action + connectGithub () { + window.location = config.githubConnectUrl; } -}); +} diff --git a/app/router.js b/app/router.js index b0941de..b26ef15 100644 --- a/app/router.js +++ b/app/router.js @@ -26,7 +26,7 @@ Router.map(function() { }); this.route('signup', function() { this.route('github'); - this.route('eth-account'); + this.route('account'); this.route('complete'); }); this.route('budget', function() { diff --git a/app/routes/signup/account.js b/app/routes/signup/account.js new file mode 100644 index 0000000..9c5d2d3 --- /dev/null +++ b/app/routes/signup/account.js @@ -0,0 +1,23 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; +import { isEmpty } from '@ember/utils'; + +export default class SignupAccountRoute extends Route { + @service kredits; + + async setupController (controller) { + if (!window.ethereum) return; + + if (this.kredits.hasAccounts) { + controller.accountAddress = this.kredits.currentUserAccounts.firstObject; + } else { + return this.kredits.connectWallet(); + } + } + + redirect () { + if (isEmpty(this.kredits.githubAccessToken)) { + this.transitionTo('signup.index'); + } + } +} diff --git a/app/routes/signup/eth-account.js b/app/routes/signup/eth-account.js deleted file mode 100644 index 078a109..0000000 --- a/app/routes/signup/eth-account.js +++ /dev/null @@ -1,17 +0,0 @@ -import Route from '@ember/routing/route'; -import { inject as service } from '@ember/service'; -import { isEmpty } from '@ember/utils'; - -export default Route.extend({ - - kredits: service(), - - redirect () { - this._super(...arguments); - - if (isEmpty(this.kredits.githubAccessToken)) { - this.transitionTo('signup.index'); - } - } - -}); diff --git a/app/routes/signup/github.js b/app/routes/signup/github.js index 7ad6798..ec64bf3 100644 --- a/app/routes/signup/github.js +++ b/app/routes/signup/github.js @@ -22,7 +22,7 @@ export default Route.extend({ this.kredits.set('githubAccessToken', accessToken); - this.transitionTo('signup.eth-account'); + this.transitionTo('signup.account'); } }); 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/dashboard/contributors/show.hbs b/app/templates/dashboard/contributors/show.hbs index 4686f70..2fe3d16 100644 --- a/app/templates/dashboard/contributors/show.hbs +++ b/app/templates/dashboard/contributors/show.hbs @@ -31,7 +31,7 @@

- Inspect Ethereum transactions + Inspect Rootstock transactions {{#if this.model.ipfsHash}} Inspect IPFS profile {{/if}} diff --git a/app/templates/signup/account.hbs b/app/templates/signup/account.hbs new file mode 100644 index 0000000..8e934db --- /dev/null +++ b/app/templates/signup/account.hbs @@ -0,0 +1,30 @@ +

+

Complete your contributor profile

+
+
+

+ Kredits allow you to to earn rewards for your contributions, in the form of + dynamic open-source grants. As a regular contributor, you can also take + part in the community's project governance and finances. +

+

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

+ +

+ +

+ +

+ +

+
\ No newline at end of file diff --git a/app/templates/signup/complete.hbs b/app/templates/signup/complete.hbs index 3fa98ac..082543b 100644 --- a/app/templates/signup/complete.hbs +++ b/app/templates/signup/complete.hbs @@ -7,7 +7,8 @@

Why not say hi to your fellow contributors - in one of our chat rooms?. + in one of our chat rooms?.

Return to dashboard diff --git a/app/templates/signup/eth-account.hbs b/app/templates/signup/eth-account.hbs deleted file mode 100644 index 4c232e0..0000000 --- a/app/templates/signup/eth-account.hbs +++ /dev/null @@ -1,24 +0,0 @@ -

-

Complete your contributor profile

-
-
-

- Kredits allow you to take part in project governance, and to earn rewards for - your contributions. For both, you will need an Ethereum wallet/account. -

-
-

- -

-
-

- -

-
\ No newline at end of file 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..15321bf 100644 --- a/config/environment.js +++ b/config/environment.js @@ -26,11 +26,11 @@ 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', + githubConnectUrl: 'https://hal8000.kosmos.chat/kredits/signup/connect/github', + githubSignupUrl: 'https://hal8000.kosmos.chat/kredits/signup/github', ipfs: { host: 'ipfs.kosmos.org', @@ -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' + ); }); }); diff --git a/tests/unit/routes/signup/eth-account-test.js b/tests/unit/routes/signup/account-test.js similarity index 57% rename from tests/unit/routes/signup/eth-account-test.js rename to tests/unit/routes/signup/account-test.js index 4c4f714..0c0adb6 100644 --- a/tests/unit/routes/signup/eth-account-test.js +++ b/tests/unit/routes/signup/account-test.js @@ -1,11 +1,11 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; -module('Unit | Route | signup/eth-account', function(hooks) { +module('Unit | Route | signup/account', function(hooks) { setupTest(hooks); test('it exists', function(assert) { - let route = this.owner.lookup('route:signup/eth-account'); + let route = this.owner.lookup('route:signup/account'); assert.ok(route); }); });