diff --git a/assets/locales/en-US.json b/assets/locales/en-US.json index 9f51269..0009338 100644 --- a/assets/locales/en-US.json +++ b/assets/locales/en-US.json @@ -78,5 +78,6 @@ "DISCLAIMER": "Disclaimer: Account integrity persistence and security are not assured. Expect that funds used for account might be lost, as well as any data the account uses.", "DAPP_SIGN_MESSAGE_MESSAGE": "Dapp with ID {dappId} wants to sign the following message: {message}. Do you want to sign the message?", "EXTENSION_SIGN_MESSAGE_MESSAGE": "Extension with ID {dappId} wants to sign the following message: {message}. Do you want to sign the message?", - "ERROR_USER_NOT_LOGGED_IN": "User is not logged in" + "ERROR_USER_NOT_LOGGED_IN": "User is not logged in", + "DIALOG_DAPP_TRANSACTION": "dApp with ID {dappId} wants to send a transaction.\nTo address: {to}\nAmount: {amount}\nDo you allow this transaction?" } diff --git a/library/src/blossom.ts b/library/src/blossom.ts index 9d494a2..1ac6458 100644 --- a/library/src/blossom.ts +++ b/library/src/blossom.ts @@ -5,6 +5,7 @@ import { FdpStorage } from './model/fdp-storage.model' import createFdpStorageProxy from './proxy/fdp-storage.proxy.factory' import { Signer } from './signer' import { getDappId } from './utils/dapp.util' +import { Wallet } from './wallet' /** * Interface of the Blossom browser extension @@ -26,6 +27,11 @@ export class Blossom { */ public readonly signer: Signer + /** + * Wallet object. This object contains methods for interaction with blockchain. + */ + public readonly wallet: Wallet + /** * dApp ENS name. If dApp is loaded from an invalid URL, the value will be null. */ @@ -39,6 +45,7 @@ export class Blossom { this.messages = createBlossomMessages(extensionId) this.fdpStorage = createFdpStorageProxy(this.messages) this.signer = new Signer(this.messages) + this.wallet = new Wallet(this.messages) } /** diff --git a/library/src/constants/api-actions.enum.ts b/library/src/constants/api-actions.enum.ts index 80d1ca7..c377fa2 100644 --- a/library/src/constants/api-actions.enum.ts +++ b/library/src/constants/api-actions.enum.ts @@ -1,5 +1,7 @@ export enum ApiActions { FDP_STORAGE = 'fdp-storage', SIGNER_SIGN_MESSAGE = 'signer.signMessage', + SEND_TRANSACTION = 'send-transaction', + GET_USER_BALANCE = 'get-user-balance', ECHO = 'echo', } diff --git a/library/src/signer.ts b/library/src/signer.ts index 444386b..d11669c 100644 --- a/library/src/signer.ts +++ b/library/src/signer.ts @@ -5,6 +5,6 @@ export class Signer { constructor(private messages: BlossomMessages) {} public signMessage(podName: string, message: string): Promise { - return this.messages.sendMessage(`${ApiActions.SIGNER_SIGN_MESSAGE}`, { podName, message }) + return this.messages.sendMessage(ApiActions.SIGNER_SIGN_MESSAGE, { podName, message }) } } diff --git a/library/src/wallet.ts b/library/src/wallet.ts new file mode 100644 index 0000000..13f9bd0 --- /dev/null +++ b/library/src/wallet.ts @@ -0,0 +1,23 @@ +import { ApiActions } from './constants/api-actions.enum' +import { BlossomMessages } from './messages/blossom-messages' + +export class Wallet { + constructor(private messages: BlossomMessages) {} + + /** + * Returns account balance of current user in wei + * @returns current balance in wei + */ + public getUserBalance(): Promise { + return this.messages.sendMessage(ApiActions.GET_USER_BALANCE) + } + + /** + * Creates a transaction with current user as signer. + * @param address receiver account + * @param amount transaction amount in wei + */ + public sendTransaction(address: string, amount: string): Promise { + return this.messages.sendMessage(ApiActions.SEND_TRANSACTION, { to: address, amount }) + } +} diff --git a/src/constants/background-actions.enum.ts b/src/constants/background-actions.enum.ts index a655d67..f56d1bf 100644 --- a/src/constants/background-actions.enum.ts +++ b/src/constants/background-actions.enum.ts @@ -11,6 +11,8 @@ enum BackgroundAction { GET_CURRENT_USER = 'get-current-user', GET_LOCAL_ACCOUNTS = 'get-local-accounts', GET_BALANCE = 'get-balance', + GET_USER_BALANCE = 'get-user-balance', + SEND_TRANSACTION = 'send-transaction', SETTINGS_GET_SELECTED_NETWORK = 'settings-get-selected-network', SETTINGS_GET_NETWORK_LIST = 'settings-get-network-list', SETTINGS_ADD_NETWORK = 'settings-add-network', diff --git a/src/constants/dapp-actions.enum.ts b/src/constants/dapp-actions.enum.ts index aa48c6e..e3ae616 100644 --- a/src/constants/dapp-actions.enum.ts +++ b/src/constants/dapp-actions.enum.ts @@ -3,11 +3,15 @@ import BackgroundAction from './background-actions.enum' export const DAPP_ACTIONS: string[] = [ BackgroundAction.FDP_STORAGE, BackgroundAction.SIGNER_SIGN_MESSAGE, + BackgroundAction.SEND_TRANSACTION, + BackgroundAction.GET_USER_BALANCE, BackgroundAction.ECHO, ] export const E2E_ACTIONS: string[] = [ BackgroundAction.FDP_STORAGE, BackgroundAction.SIGNER_SIGN_MESSAGE, + BackgroundAction.SEND_TRANSACTION, + BackgroundAction.GET_USER_BALANCE, BackgroundAction.ECHO, ] diff --git a/src/constants/errors.ts b/src/constants/errors.ts index e7b3f38..fdbde2f 100644 --- a/src/constants/errors.ts +++ b/src/constants/errors.ts @@ -1,5 +1,9 @@ import { ErrorObject } from '../model/error.model' +export const errorMessages = { + ACCESS_DENIED: 'Blossom: Access denied', +} + export enum ErrorCode { USER_NOT_LOGGED_IN, } diff --git a/src/listeners/message-listeners/account.listener.ts b/src/listeners/message-listeners/account.listener.ts index 0caf0c3..85a525a 100644 --- a/src/listeners/message-listeners/account.listener.ts +++ b/src/listeners/message-listeners/account.listener.ts @@ -1,14 +1,52 @@ -import { BigNumber } from 'ethers' +import { BigNumber, providers } from 'ethers' import BackgroundAction from '../../constants/background-actions.enum' -import { isAddress } from '../../messaging/message.asserts' +import { isAddress, isTransaction } from '../../messaging/message.asserts' import { Address } from '../../model/general.types' import { createMessageHandler } from './message-handler' import { Blockchain } from '../../services/blockchain.service' +import { Transaction } from '../../model/internal-messages.model' +import { SessionFdpStorageProvider } from '../../services/fdp-storage/session-fdp-storage.provider' +import { isInternalMessage } from '../../utils/extension' +import { Dialog } from '../../services/dialog.service' +import { getDappId } from './listener.utils' +import { errorMessages } from '../../constants/errors' +const dialogs = new Dialog() const blockchain = new Blockchain() +const fdpStorageProvider = new SessionFdpStorageProvider() -export function getAccountBalance(address: Address): Promise { - return blockchain.getAccountBalance(address) +export async function getAccountBalance(address: Address): Promise { + const balance = await blockchain.getAccountBalance(address) + + return balance.toString() +} + +export async function getUserAccountBalance(): Promise { + const fdp = await fdpStorageProvider.getService() + + const balance = await blockchain.getAccountBalance(fdp.account.wallet.address) + + return balance.toString() +} + +export async function sendTransaction( + { to, amount }: Transaction, + sender: chrome.runtime.MessageSender, +): Promise { + const fdp = await fdpStorageProvider.getService() + + const { wallet } = fdp.account + + if (!isInternalMessage(sender)) { + const dappId = await getDappId(sender) + const confirmed = await dialogs.ask('DIALOG_DAPP_TRANSACTION', { dappId, to, amount }) + + if (!confirmed) { + throw new Error(errorMessages.ACCESS_DENIED) + } + } + + return blockchain.sendTransaction(wallet.privateKey, to, BigNumber.from(amount)) } const messageHandler = createMessageHandler([ @@ -17,6 +55,15 @@ const messageHandler = createMessageHandler([ assert: isAddress, handler: getAccountBalance, }, + { + action: BackgroundAction.GET_USER_BALANCE, + handler: getUserAccountBalance, + }, + { + action: BackgroundAction.SEND_TRANSACTION, + assert: isTransaction, + handler: sendTransaction, + }, ]) export default messageHandler diff --git a/src/listeners/message-listeners/fdp-storage.listener.ts b/src/listeners/message-listeners/fdp-storage.listener.ts index 77bae45..f214319 100644 --- a/src/listeners/message-listeners/fdp-storage.listener.ts +++ b/src/listeners/message-listeners/fdp-storage.listener.ts @@ -11,28 +11,19 @@ import { getPodNameFromParams, isPodBasedMethod, } from '../../services/fdp-storage/fdp-storage-access' -import { dappUrlToId } from '../../services/fdp-storage/fdp-storage.utils' import { SessionFdpStorageProvider } from '../../services/fdp-storage/session-fdp-storage.provider' import { SessionService } from '../../services/session.service' import { Storage } from '../../services/storage/storage.service' -import { SwarmExtension } from '../../swarm-api/swarm-extension' import { isPodActionAllowed } from '../../utils/permissions' import { createMessageHandler } from './message-handler' +import { getDappId } from './listener.utils' +import { errorMessages } from '../../constants/errors' const fdpStorageProvider = new SessionFdpStorageProvider() const dialogs = new Dialog() const storage = new Storage() const sessionService = new SessionService() -async function getDappId(sender: chrome.runtime.MessageSender): Promise { - const { extensionId } = await storage.getSwarm() - const swarmExtension = new SwarmExtension(extensionId) - - const { beeApiUrl } = await swarmExtension.beeAddress() - - return dappUrlToId(sender.url, beeApiUrl) -} - async function handleFullAccessRequest(dappId: DappId, dapp: Dapp, session: MemorySession): Promise { if (dapp && dapp.fullStorageAccess) { return true @@ -64,7 +55,7 @@ async function handlePodBasedMethod( const confirmed = await dialogs.ask('DIALOG_CREATE_POD', { dappId, podName }) if (!confirmed) { - throw new Error('Blossom: Access denied') + throw new Error(errorMessages.ACCESS_DENIED) } await storage.setDappPodPermissionBySession( diff --git a/src/listeners/message-listeners/listener.utils.ts b/src/listeners/message-listeners/listener.utils.ts new file mode 100644 index 0000000..46d0b4d --- /dev/null +++ b/src/listeners/message-listeners/listener.utils.ts @@ -0,0 +1,15 @@ +import { DappId } from '../../model/general.types' +import { dappUrlToId } from '../../services/fdp-storage/fdp-storage.utils' +import { Storage } from '../../services/storage/storage.service' +import { SwarmExtension } from '../../swarm-api/swarm-extension' + +const storage = new Storage() + +export async function getDappId(sender: chrome.runtime.MessageSender): Promise { + const { extensionId } = await storage.getSwarm() + const swarmExtension = new SwarmExtension(extensionId) + + const { beeApiUrl } = await swarmExtension.beeAddress() + + return dappUrlToId(sender.url, beeApiUrl) +} diff --git a/src/listeners/message-listeners/signer.listener.ts b/src/listeners/message-listeners/signer.listener.ts index 38b4940..6623490 100644 --- a/src/listeners/message-listeners/signer.listener.ts +++ b/src/listeners/message-listeners/signer.listener.ts @@ -7,6 +7,7 @@ import { Dialog } from '../../services/dialog.service' import { SessionFdpStorageProvider } from '../../services/fdp-storage/session-fdp-storage.provider' import { isOtherExtension } from '../../utils/extension' import { createMessageHandler } from './message-handler' +import { errorMessages } from '../../constants/errors' const dialogs = new Dialog() const dappService = new DappService() @@ -22,7 +23,7 @@ async function signMessage( // TODO should check permissions if (podName !== dappId) { - throw new Error('Blossom: Access denied') + throw new Error(errorMessages.ACCESS_DENIED) } const podWallet = await fdp.personalStorage.getPodWallet(fdp.account.seed, podName) @@ -37,7 +38,7 @@ async function signMessage( ) if (!confirmed) { - throw new Error('Blossom: Access denied') + throw new Error(errorMessages.ACCESS_DENIED) } return new Wallet(podWallet.privateKey).signMessage(message) diff --git a/src/messaging/content-api.messaging.ts b/src/messaging/content-api.messaging.ts index 5b0110f..21c4508 100644 --- a/src/messaging/content-api.messaging.ts +++ b/src/messaging/content-api.messaging.ts @@ -63,9 +63,9 @@ export function getLocales(): Promise { } export async function getAccountBalance(address: Address): Promise { - const { hex } = await sendMessage(BackgroundAction.GET_BALANCE, address) + const balance = await sendMessage(BackgroundAction.GET_BALANCE, address) - return BigNumber.from(hex) + return BigNumber.from(balance) } export function getSelectedNetwork(): Promise { diff --git a/src/messaging/message.asserts.ts b/src/messaging/message.asserts.ts index ce47aee..b752b0d 100644 --- a/src/messaging/message.asserts.ts +++ b/src/messaging/message.asserts.ts @@ -9,6 +9,7 @@ import { RegisterDataBase, RegisterDataMnemonic, SignerRequest, + Transaction, UsernameCheckData, } from '../model/internal-messages.model' import { Dapp, PodActions, PodPermission } from '../model/storage/dapps.model' @@ -145,3 +146,9 @@ export function isSerializedUint8Array(data: unknown): data is BytesMessage { return type === 'bytes' && isString(value) } + +export function isTransaction(data: unknown): data is Transaction { + const { to, amount } = (data || {}) as Transaction + + return isAddress(to) && isString(amount) +} diff --git a/src/model/internal-messages.model.ts b/src/model/internal-messages.model.ts index f1492f7..c7ec05b 100644 --- a/src/model/internal-messages.model.ts +++ b/src/model/internal-messages.model.ts @@ -83,3 +83,9 @@ export interface DialogQuestion { question: string placeholders: Record } + +export interface Transaction { + to: Address + // in wei + amount: string +} diff --git a/src/services/blockchain.service.ts b/src/services/blockchain.service.ts index 621e235..53d1607 100644 --- a/src/services/blockchain.service.ts +++ b/src/services/blockchain.service.ts @@ -1,5 +1,5 @@ -import { BigNumber, providers } from 'ethers' -import { Address } from '../model/general.types' +import { BigNumber, Wallet, providers } from 'ethers' +import { Address, PrivateKey } from '../model/general.types' import { Storage } from './storage/storage.service' export class Blockchain { @@ -9,6 +9,20 @@ export class Blockchain { return (await this.getProvider()).getBalance(address) } + public async sendTransaction( + privateKey: PrivateKey, + to: Address, + value: BigNumber, + ): Promise { + const provider = await this.getProvider() + + const wallet = new Wallet(privateKey).connect(provider) + + const tx = await wallet.sendTransaction({ to, value }) + + return tx.wait() + } + private async getProvider(): Promise { const { rpc } = await this.storage.getNetwork() diff --git a/src/services/error.service.ts b/src/services/error.service.ts index 25afc8e..0e508dd 100644 --- a/src/services/error.service.ts +++ b/src/services/error.service.ts @@ -3,7 +3,6 @@ import { ErrorObject } from '../model/error.model' import { removeWarningBadge, setWarningBadge } from '../utils/extension' import { Storage } from './storage/storage.service' import { Errors as ErrorsModel } from '../model/storage/general.model' -import { Locales } from './locales.service' export class Errors { private storage: Storage = new Storage() diff --git a/src/ui/dialog/components/dialog.tsx b/src/ui/dialog/components/dialog.tsx index 0c46245..81b932b 100644 --- a/src/ui/dialog/components/dialog.tsx +++ b/src/ui/dialog/components/dialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { Fragment, useEffect, useState } from 'react' import intl from 'react-intl-universal' import { AppBar, Button, GlobalStyles, Typography } from '@mui/material' import { FlexDiv } from '../../common/components/utils/utils' @@ -54,7 +54,15 @@ const Dialog = () => { - {intl.get(question, placeholders)} + {intl + .get(question, placeholders) + .split('\n') + .map((line, index) => ( + + {line} +
+
+ ))}
{ return new Promise((resolve, reject) => { diff --git a/test/dapp-library.spec.ts b/test/dapp-library.spec.ts index 3e2333a..b0eb03d 100644 --- a/test/dapp-library.spec.ts +++ b/test/dapp-library.spec.ts @@ -9,6 +9,7 @@ import { removeZeroFromHex } from './test-utils/ethers' import { click, getPageByTitle, openPage, wait, waitForElementText } from './test-utils/page' const FDP_STORAGE_PAGE_URL = `${BEE_URL}/bzz/${global.FDP_STORAGE_PAGE_REFERENCE}/` +const WALLET_PAGE_URL = `${BEE_URL}/bzz/${global.WALLET_PAGE_REFERENCE}/` describe('Dapp interaction with Blossom, using the library', () => { let page: Page @@ -118,4 +119,48 @@ describe('Dapp interaction with Blossom, using the library', () => { expect(await waitForElementText(page, '#sign-message[complete="true"]')).toEqual(hash) }) }) + + describe('Wallet tests', () => { + beforeAll(async () => { + await page.goto(WALLET_PAGE_URL) + }) + + // balance in wei, expectedBalance rounded value in ETH + const assertBalance = (balance: string, expectedBalance: string) => { + expect(balance.length).toEqual(17) + expect(`0.${balance.substring(0, 2)}`).toEqual(expectedBalance) + } + + test('Should get initial balance', async () => { + await click(page, 'get-balance-btn') + + assertBalance(await waitForElementText(page, '#balance[complete="true"]'), '0.99') + }) + + test("Shouldn't send transaction if user didn't confirm", async () => { + await click(page, 'send-transaction-btn-1') + + await wait(5000) + + const blossomPage = await getPageByTitle('Blossom') + + await click(blossomPage, 'dialog-cancel-btn') + + expect(await waitForElementText(page, '#updated-balance-1[complete="true"]')).toEqual( + 'Error: Blossom: Access denied', + ) + }) + + test('Should successfully send funds', async () => { + await click(page, 'send-transaction-btn-2') + + await wait(5000) + + const blossomPage = await getPageByTitle('Blossom') + + await click(blossomPage, 'dialog-confirm-btn') + + assertBalance(await waitForElementText(page, '#updated-balance-2[complete="true"]'), '0.89') + }) + }) }) diff --git a/test/dapps/wallet/index.html b/test/dapps/wallet/index.html new file mode 100644 index 0000000..9bbfb08 --- /dev/null +++ b/test/dapps/wallet/index.html @@ -0,0 +1,19 @@ + + + + + + + Wallet tests + + + + + +

Empty

+ +

Empty

+ +

Empty

+ + diff --git a/test/dapps/wallet/index.js b/test/dapps/wallet/index.js new file mode 100644 index 0000000..6885999 --- /dev/null +++ b/test/dapps/wallet/index.js @@ -0,0 +1,38 @@ +var blossom = new window.blossom.Blossom() + +function setText(id, text) { + const element = document.getElementById(id) + element.innerText = text + element.setAttribute('complete', 'true') +} + +async function getBalance(elementId) { + try { + const balance = await blossom.wallet.getUserBalance() + setText(elementId, balance) + } catch (error) { + setText(elementId, error.toString()) + } +} + +async function sendTransaction(elementId) { + try { + // send 0.1 ETH + await blossom.wallet.sendTransaction('0xb0B56d5fde62617907617d10479EaaE0DeE17773', '10000000000000000') + await getBalance(elementId) + } catch (error) { + setText(elementId, error.toString()) + } +} + +function getInitialBalance() { + return getBalance('balance') +} + +function sendTransaction1() { + return sendTransaction('updated-balance-1') +} + +function sendTransaction2() { + return sendTransaction('updated-balance-2') +} diff --git a/test/registration.spec.ts b/test/registration.spec.ts index 884c8cd..7852141 100644 --- a/test/registration.spec.ts +++ b/test/registration.spec.ts @@ -172,7 +172,7 @@ describe('Login tests', () => { await fillUsernamePasswordForm(page, username, password) - await wait(100) + await wait(500) await assertUserLogin(username)