diff --git a/packages/yoroi-ergo-connector/example-cardano/index.html b/packages/yoroi-ergo-connector/example-cardano/index.html index e292f87191..17afaef849 100644 --- a/packages/yoroi-ergo-connector/example-cardano/index.html +++ b/packages/yoroi-ergo-connector/example-cardano/index.html @@ -57,6 +57,10 @@

Cardano dApp Example

+
+ + +
Go to a subpage
diff --git a/packages/yoroi-ergo-connector/example-cardano/index.js b/packages/yoroi-ergo-connector/example-cardano/index.js index 79b602e494..950d9f8130 100644 --- a/packages/yoroi-ergo-connector/example-cardano/index.js +++ b/packages/yoroi-ergo-connector/example-cardano/index.js @@ -22,6 +22,7 @@ const submitTx = document.querySelector('#submit-tx') const signTx = document.querySelector('#sign-tx') const createTx = document.querySelector('#create-tx') const getCollateralUtxos = document.querySelector('#get-collateral-utxos') +const signData = document.querySelector('#sign-data') const alertEl = document.querySelector('#alert') const spinner = document.querySelector('#spinner') @@ -30,6 +31,7 @@ let cardanoApi let returnType = 'cbor' let utxos let usedAddresses +let unusedAddresses let changeAddress let unsignedTransactionHex let transactionHex @@ -199,6 +201,10 @@ function addressesFromCborIfNeeded(addresses) { CardanoWasm.Address.from_bytes(hexToBytes(a)).to_bech32()) : addresses; } +function addressToCbor(address) { + return bytesToHex(CardanoWasm.Address.from_bech32(address).to_bytes()); +} + getUnUsedAddresses.addEventListener('click', () => { if(!accessGranted) { alertError('Should request access first') @@ -211,6 +217,7 @@ getUnUsedAddresses.addEventListener('click', () => { return; } addresses = addressesFromCborIfNeeded(addresses) + unusedAddresses = addresses alertSuccess(`Address: `) alertEl.innerHTML = '

Unused addresses:

' + JSON.stringify(addresses, undefined, 2) + '
' }); @@ -626,11 +633,12 @@ createTx.addEventListener('click', () => { getCollateralUtxos.addEventListener('click', () => { toggleSpinner('show'); - + if (!accessGranted) { alertError('Should request access first'); return; } + const amount = '4900000'; cardanoApi.getCollateralUtxos( Buffer.from( @@ -649,6 +657,47 @@ getCollateralUtxos.addEventListener('click', () => { }) }) +signData.addEventListener('click', () => { + toggleSpinner('show'); + + if (!accessGranted) { + alertError('Should request access first'); + return; + } + + let address; + if (usedAddresses && usedAddresses.length > 0) { + address = usedAddresses[0]; + } else if (unusedAddresses && unusedAddresses.length > 0) { + address = unusedAddresses[0]; + } else { + alertError('Should request used or unused addresses first'); + return; + } + + if (isCBOR()) { + address = addressToCbor(address); + } + + const payload = document.querySelector('#sign-data-payload').value; + let payloadHex; + if (payload.startsWith('0x')) { + payloadHex = Buffer.from(payload.replace('^0x', ''), 'hex').toString('hex'); + } else { + payloadHex = Buffer.from(payload, 'utf8').toString('hex'); + } + + console.log('address >>> ', address); + cardanoApi.signData(address, payloadHex).then(sig => { + alertSuccess('Signature:' + JSON.stringify(sig)) + }).catch(error => { + console.error(error); + alertError(error.info); + }).then(() => { + toggleSpinner('hide'); + }); +}); + function alertError (text) { toggleSpinner('hide'); alertEl.className = 'alert alert-danger' diff --git a/packages/yoroi-ergo-connector/src/inject.js b/packages/yoroi-ergo-connector/src/inject.js index 3921e083a8..84302a7f31 100644 --- a/packages/yoroi-ergo-connector/src/inject.js +++ b/packages/yoroi-ergo-connector/src/inject.js @@ -254,9 +254,8 @@ class CardanoAPI { return this._cardano_rpc_call('sign_tx/cardano', [{ tx, partialSign, returnTx }]); } - signData(address, sigStructure) { - // TODO - throw new Error('Not implemented yet'); + signData(address, payload) { + return this._cardano_rpc_call("sign_data", [address, payload]); } getCollateralUtxos(requiredAmount) { diff --git a/packages/yoroi-extension/app/actions/profile-actions.js b/packages/yoroi-extension/app/actions/profile-actions.js index 656ef6b3fd..77b2eb044c 100644 --- a/packages/yoroi-extension/app/actions/profile-actions.js +++ b/packages/yoroi-extension/app/actions/profile-actions.js @@ -2,15 +2,13 @@ import { AsyncAction, Action } from './lib/Action'; import type { NetworkRow } from '../api/ada/lib/storage/database/primitives/tables'; import BaseProfileActions from './base/base-profile-actions'; - +import type { WalletsNavigation } from '../api/localStorage'; // ======= PROFILE ACTIONS ======= export default class ProfileActions extends BaseProfileActions { acceptTermsOfUse: AsyncAction = new AsyncAction(); acceptUriScheme: AsyncAction = new AsyncAction(); toggleSidebar: AsyncAction = new AsyncAction(); - updateSortedWalletList: AsyncAction<{| - sortedWallets: Array, - |}> = new AsyncAction(); + updateSortedWalletList: AsyncAction = new AsyncAction(); setSelectedNetwork: Action> = new Action(); } diff --git a/packages/yoroi-extension/app/api/ada/index.js b/packages/yoroi-extension/app/api/ada/index.js index 890ab392b6..129bf35dea 100644 --- a/packages/yoroi-extension/app/api/ada/index.js +++ b/packages/yoroi-extension/app/api/ada/index.js @@ -40,7 +40,11 @@ import { CoreAddressTypes, TxStatusCodes, } from './lib/storage/database/primiti import type { NetworkRow, TokenRow, } from './lib/storage/database/primitives/tables'; import { TransactionType } from './lib/storage/database/primitives/tables'; import { PublicDeriver, } from './lib/storage/models/PublicDeriver/index'; -import { asDisplayCutoff, asHasLevels, } from './lib/storage/models/PublicDeriver/traits'; +import { + asDisplayCutoff, + asHasLevels, + asGetAllUtxos, +} from './lib/storage/models/PublicDeriver/traits'; import { ConceptualWallet } from './lib/storage/models/ConceptualWallet/index'; import type { IHasLevels } from './lib/storage/models/ConceptualWallet/interfaces'; import type { @@ -116,7 +120,8 @@ import type { SendFunc, SignedRequest, SignedResponse, - TokenInfoFunc + TokenInfoFunc, + RemoteUnspentOutput, } from './lib/state-fetch/types'; import type { FilterFunc, } from '../common/lib/state-fetch/currencySpecificTypes'; import { getChainAddressesForDisplay, } from './lib/storage/models/utils'; @@ -153,6 +158,7 @@ import { GetAddress, GetPathWithSpecific, } from './lib/storage/database/primiti import { getAllSchemaTables, mapToTables, raii, } from './lib/storage/database/utils'; import { GetDerivationSpecific, } from './lib/storage/database/walletTypes/common/api/read'; import { bytesToHex, hexToBytes, hexToUtf } from '../../coreUtils'; +import type { PersistedSubmittedTransaction } from '../localStorage'; // ADA specific Request / Response params @@ -338,8 +344,9 @@ export type CardanoTxRequest = {| |}; export type CreateUnsignedTxForConnectorRequest = {| cardanoTxRequest: CardanoTxRequest, - publicDeriver: IPublicDeriver & IGetAllUtxos & IHasUtxoChains, + publicDeriver: PublicDeriver<>, absSlotNumber: BigNumber, + submittedTxs: Array, utxos: Array, |}; export type CreateUnsignedTxResponse = HaskellShelleyTxSignRequest; @@ -1149,7 +1156,12 @@ export default class AdaApi { } } - const { utxos } = request; + const utxos = await this.addressedUtxosWithSubmittedTxs( + request.utxos, + request.publicDeriver, + request.submittedTxs + ); + const allUtxoIds = new Set(utxos.map(utxo => utxo.utxo_id)); const utxoIdSet = new Set((includeInputs||[]).filter(utxoId => { if (!allUtxoIds.has(utxoId)) { @@ -2246,7 +2258,10 @@ export default class AdaApi { txId: string, defaultNetworkId: number, defaultToken: $ReadOnly, - ): Promise { + ): Promise<{| + transaction: CardanoShelleyTransaction, + usedUtxos: Array<{| txHash: string, index: number |}> + |}> { const p = asHasLevels(publicDeriver); if (!p) { throw new Error(`${nameof(this.createSubmittedTransactionData)} publicDerviver traits missing`); @@ -2296,8 +2311,10 @@ export default class AdaApi { isIntraWallet = false; } } - - return CardanoShelleyTransaction.fromData({ + const usedUtxos = signRequest.senderUtxos.map(utxo => ( + { txHash: utxo.tx_hash, index: utxo.tx_index } + )); + const transaction = CardanoShelleyTransaction.fromData({ txid: txId, type: isIntraWallet ? 'self' : 'expend', amount, @@ -2321,6 +2338,122 @@ export default class AdaApi { })), isValid: true, }); + return { usedUtxos, transaction }; + } + + utxosWithSubmittedTxs( + originalUtxos: Array, + publicDeriverId: number, + submittedTxs: Array, + ): Array { + const filteredSubmittedTxs = submittedTxs.filter( + submittedTxRecord => submittedTxRecord.publicDeriverId === publicDeriverId + ); + const usedUtxoIds = new Set( + filteredSubmittedTxs.flatMap(({ usedUtxos }) => usedUtxos.map(({ txHash, index }) => `${txHash}${index}`)) + ); + // take out UTxOs consumed by submitted transactions + const utxos = originalUtxos.filter(utxo => !usedUtxoIds.has(utxo.utxo_id)); + // put in UTxOs produced by submitted transactions + for (const { transaction } of filteredSubmittedTxs) { + for (const [index, { address, value }] of transaction.addresses.to.entries()) { + if (utxos.find(utxo => utxo.utxo_id === `${transaction.txid}${index}`)) { + // this output is already included + continue; + } + + const amount = value.values.find( + ({ identifier }) => identifier === value.defaults.defaultIdentifier + )?.amount || '0'; + const assets = value.values + .filter(({ identifier }) => identifier !== value.defaults.defaultIdentifier) + .map(v => { + const [policyId, name = ''] = v.identifier.split('.'); + return { + policyId, + name, + amount: v.amount, + assetId: v.identifier, + }; + }); + utxos.push({ + utxo_id: `${transaction.txid}${index}`, + tx_hash: transaction.txid, + tx_index: index, + receiver: address, + amount, + assets, + }); + } + } + return utxos; + } + + async addressedUtxosWithSubmittedTxs( + originalUtxos: Array, + publicDeriver: PublicDeriver<>, + submittedTxs: Array, + ): Promise> { + const filteredSubmittedTxs = submittedTxs.filter( + submittedTxRecord => submittedTxRecord.publicDeriverId === publicDeriver.publicDeriverId + ); + const usedUtxoIds = new Set( + filteredSubmittedTxs.flatMap(({ usedUtxos }) => usedUtxos.map(({ txHash, index }) => `${txHash}${index}`)) + ); + // take out UTxOs consumed by submitted transactions + const utxos = originalUtxos.filter(utxo => !usedUtxoIds.has(utxo.utxo_id)); + // put in UTxOs produced by submitted transactions + const withUtxos = asGetAllUtxos(publicDeriver); + if (!withUtxos) { + throw new Error('unable to get UTxO addresses from public deriver'); + } + const allAddresses = await withUtxos.getAllUtxoAddresses(); + + for (const { transaction } of filteredSubmittedTxs) { + for (const [index, { address, value }] of transaction.addresses.to.entries()) { + if (utxos.find(utxo => utxo.utxo_id === `${transaction.txid}${index}`)) { + // this output is already included + continue; + } + + const amount = value.values.find( + ({ identifier }) => identifier === value.defaults.defaultIdentifier + )?.amount || '0'; + const assets = value.values + .filter(({ identifier }) => identifier !== value.defaults.defaultIdentifier) + .map(v => { + const [policyId, name = ''] = v.identifier.split('.'); + return { + policyId, + name, + amount: v.amount, + assetId: v.identifier, + }; + }); + const findAddressing = () => { + for (const { addrs, addressing } of allAddresses) { + for (const { Hash } of addrs) { + if (Hash === address) { + return addressing; + } + } + } + }; + const addressing = findAddressing(); + if (addressing) { + utxos.push({ + utxo_id: `${transaction.txid}${index}`, + tx_hash: transaction.txid, + tx_index: index, + receiver: address, + amount, + assets, + addressing, + }); + } // else { should not happen } + } + } + return utxos; } } // ========== End of class AdaApi ========= diff --git a/packages/yoroi-extension/app/api/ada/lib/state-fetch/IFetcher.js b/packages/yoroi-extension/app/api/ada/lib/state-fetch/IFetcher.js index 9b42f6d3c2..0ea7f0f410 100644 --- a/packages/yoroi-extension/app/api/ada/lib/state-fetch/IFetcher.js +++ b/packages/yoroi-extension/app/api/ada/lib/state-fetch/IFetcher.js @@ -13,6 +13,7 @@ import type { BestBlockRequest, BestBlockResponse, TokenInfoRequest, TokenInfoResponse, MultiAssetMintMetadataRequest, MultiAssetMintMetadataResponse, + GetUtxoDataRequest, GetUtxoDataResponse, } from './types'; import type { FilterUsedRequest, FilterUsedResponse, @@ -33,4 +34,5 @@ export interface IFetcher { checkAddressesInUse(body: FilterUsedRequest): Promise; getMultiAssetMintMetadata(body: MultiAssetMintMetadataRequest) : Promise; + getUtxoData(body: GetUtxoDataRequest): Promise; } diff --git a/packages/yoroi-extension/app/api/ada/lib/state-fetch/batchedFetcher.js b/packages/yoroi-extension/app/api/ada/lib/state-fetch/batchedFetcher.js index 8787902ec1..8fd39f56ca 100644 --- a/packages/yoroi-extension/app/api/ada/lib/state-fetch/batchedFetcher.js +++ b/packages/yoroi-extension/app/api/ada/lib/state-fetch/batchedFetcher.js @@ -19,7 +19,8 @@ import type { UtxoSumFunc, RemoteTransaction, MultiAssetMintMetadataRequest, - MultiAssetMintMetadataResponse + MultiAssetMintMetadataResponse, + GetUtxoDataFunc, GetUtxoDataRequest, GetUtxoDataResponse, } from './types'; import type { FilterFunc, FilterUsedRequest, FilterUsedResponse, @@ -123,6 +124,10 @@ export class BatchedFetcher implements IFetcher { getCatalystRoundInfo: CatalystRoundInfoRequest => Promise = (body) => ( batchGetCatalystRoundInfo(this.baseFetcher.getCatalystRoundInfo)(body) ) + + getUtxoData: GetUtxoDataRequest => Promise = (body) => ( + batchGetUtxoData(this.baseFetcher.getUtxoData)(body) + ) } /** Sum up the UTXO for a list of addresses by batching backend requests */ @@ -491,3 +496,20 @@ export function batchGetTokenInfo( } }; } + +function batchGetUtxoData( + getUtxoData: GetUtxoDataFunc, +): GetUtxoDataFunc { + return async function (body: GetUtxoDataRequest): Promise { + return (await Promise.all( + body.utxos.map( + ({ txHash, txIndex }) => getUtxoData( + { + network: body.network, + utxos: [ { txHash, txIndex } ], + } + ) + ) + )).flat(); + }; +} diff --git a/packages/yoroi-extension/app/api/ada/lib/state-fetch/remoteFetcher.js b/packages/yoroi-extension/app/api/ada/lib/state-fetch/remoteFetcher.js index b226dc6461..6f3fa18739 100644 --- a/packages/yoroi-extension/app/api/ada/lib/state-fetch/remoteFetcher.js +++ b/packages/yoroi-extension/app/api/ada/lib/state-fetch/remoteFetcher.js @@ -27,6 +27,8 @@ import type { CatalystRoundInfoResponse, MultiAssetMintMetadataRequest, MultiAssetMintMetadataResponse, + GetUtxoDataRequest, + GetUtxoDataResponse, } from './types'; import type { FilterUsedRequest, FilterUsedResponse, } from '../../../common/lib/state-fetch/currencySpecificTypes'; @@ -48,6 +50,7 @@ import { InvalidWitnessError, RollbackApiError, SendTransactionApiError, + GetUtxoDataError, } from '../../../common/errors'; import { RustModule } from '../cardanoCrypto/rustLoader'; @@ -452,4 +455,32 @@ export class RemoteFetcher implements IFetcher { return {}; }); } + + getUtxoData: GetUtxoDataRequest => Promise = async (body) => { + const { BackendService } = body.network.Backend; + if (body.utxos.length !== 1) { + throw new Error('the RemoteFetcher.getUtxoData expects 1 UTXO'); + } + const { txHash, txIndex } = body.utxos[0]; + if (BackendService == null) throw new Error(`${nameof(this.getUtxoData)} missing backend url`); + return axios( + `${BackendService}/api/txs/io/${txHash}/o/${txIndex}`, + { + method: 'get', + timeout: 2 * CONFIG.app.walletRefreshInterval, + headers: { + 'yoroi-version': this.getLastLaunchVersion(), + 'yoroi-locale': this.getCurrentLocale() + } + } + ).then(response => [ response.data ]) + .catch((error) => { + if (error.response.status === 404 && error.response.data === 'Transaction not found') { + return [ null ]; + } + Logger.error(`${nameof(RemoteFetcher)}::${nameof(this.getUtxoData)} error: ` + stringifyError(error)); + throw new GetUtxoDataError(); + }); + + } } diff --git a/packages/yoroi-extension/app/api/ada/lib/state-fetch/types.js b/packages/yoroi-extension/app/api/ada/lib/state-fetch/types.js index d262ef39a9..14655040fc 100644 --- a/packages/yoroi-extension/app/api/ada/lib/state-fetch/types.js +++ b/packages/yoroi-extension/app/api/ada/lib/state-fetch/types.js @@ -390,4 +390,31 @@ export type MultiAssetMintMetadataResponse = {| export type MultiAssetMintMetadataResponseAsset = {| key: string, metadata: {[key: string]: any} -|} \ No newline at end of file +|} + +export type GetUtxoDataRequest = {| + ...BackendNetworkInfo, + utxos: Array<{| + txHash: string, + txIndex: number, + |}> +|} + +export type UtxoData = {| + output: {| + +address: string, + +amount: string, + +dataHash: string | null, + +assets: Array<{| + +assetId: string, + +policyId: string, + +name: string, + +amount: string, + |}>, + |}, + spendingTxHash: string | null, +|}; + +export type GetUtxoDataResponse = Array; + +export type GetUtxoDataFunc = (body: GetUtxoDataRequest) => Promise; diff --git a/packages/yoroi-extension/app/api/ada/transactions/shelley/coinSelection.test.js b/packages/yoroi-extension/app/api/ada/transactions/shelley/coinSelection.test.js index 8be26c7a01..bd7c019297 100644 --- a/packages/yoroi-extension/app/api/ada/transactions/shelley/coinSelection.test.js +++ b/packages/yoroi-extension/app/api/ada/transactions/shelley/coinSelection.test.js @@ -23,7 +23,7 @@ function withMockRandom(r: number, f: () => T): T { function multiToken( amount: number | string, - assets: Array<{| assetId: string, amount: string |}> = [], + assets: Array<{ assetId: string, amount: string, ... }> = [], ): MultiToken { // $FlowFixMe[incompatible-call] return createMultiToken(String(amount), assets || [], 0); diff --git a/packages/yoroi-extension/app/api/ada/transactions/utils.js b/packages/yoroi-extension/app/api/ada/transactions/utils.js index 67b5868a28..efaff0fc1d 100644 --- a/packages/yoroi-extension/app/api/ada/transactions/utils.js +++ b/packages/yoroi-extension/app/api/ada/transactions/utils.js @@ -169,10 +169,11 @@ export function cardanoValueFromRemoteFormat( } export function createMultiToken( amount: number | string | BigNumber, - assets: Array<{| + assets: Array<{ assetId: string, amount: number | string | BigNumber, - |}>, + ..., + }>, networkId: number, ): MultiToken { const result = new MultiToken( @@ -198,7 +199,14 @@ export function createMultiToken( } export function multiTokenFromRemote( utxo: $ReadOnly<{ - ...RemoteUnspentOutput, + +amount: string, + +assets: $ReadOnlyArray<$ReadOnly<{ + +assetId: string, + +policyId: string, + +name: string, + +amount: string, + ... + }>>, ..., }>, networkId: number, diff --git a/packages/yoroi-extension/app/api/common/errors.js b/packages/yoroi-extension/app/api/common/errors.js index 41255c965c..092b2e0397 100644 --- a/packages/yoroi-extension/app/api/common/errors.js +++ b/packages/yoroi-extension/app/api/common/errors.js @@ -188,6 +188,10 @@ const messages = defineMessages({ id: 'api.errors.hardwareUnsupportedError', defaultMessage: '!!!This action is not supported for the currently selected hardware.', }, + getUtxoDataError: { + id: 'api.errors.getUtxoDataError', + defaultMessage: '!!!Error received from server while getting UTXO data', + }, }); export class ServerStatusError extends LocalizableError { @@ -582,3 +586,12 @@ export class HardwareUnsupportedError extends LocalizableError { }); } } + +export class GetUtxoDataError extends LocalizableError { + constructor() { + super({ + id: messages.getUtxoDataError.id, + defaultMessage: messages.getUtxoDataError.defaultMessage || '', + }); + } +} diff --git a/packages/yoroi-extension/app/api/localStorage/index.js b/packages/yoroi-extension/app/api/localStorage/index.js index e7d89baf4b..efdc33bab5 100644 --- a/packages/yoroi-extension/app/api/localStorage/index.js +++ b/packages/yoroi-extension/app/api/localStorage/index.js @@ -30,7 +30,7 @@ const storageKeys = { COIN_PRICE_PUB_KEY_DATA: networkForLocalStorage + '-COIN-PRICE-PUB-KEY-DATA', EXTERNAL_STORAGE: networkForLocalStorage + '-EXTERNAL-STORAGE', TOGGLE_SIDEBAR: networkForLocalStorage + '-TOGGLE-SIDEBAR', - SORTED_WALLETS: networkForLocalStorage + '-SORTED-WALLET', + WALLETS_NAVIGATION: networkForLocalStorage + '-WALLETS-NAVIGATION', SUBMITTED_TRANSACTIONS: 'submittedTransactions', // ========== CONNECTOR ========== // ERGO_CONNECTOR_WHITELIST: 'connector_whitelist', @@ -40,6 +40,12 @@ export type SetCustomUserThemeRequest = {| cssCustomPropObject: Object, |}; +export type WalletsNavigation = {| + ergo: number[], + cardano: number[], + quickAccess: number[], +|} + /** * This api layer provides access to the electron local storage * for user settings that are not synced with any coin backend. @@ -269,14 +275,22 @@ export default class LocalStorageApi { // ========== Sort wallets - Revamp ========== // - getSortedWallets: void => Promise> = async () => { - const result = await getLocalItem(storageKeys.SORTED_WALLETS); + getWalletsNavigation: void => Promise = async () => { + let result = await getLocalItem(storageKeys.WALLETS_NAVIGATION); if (result === undefined || result === null) return undefined; - return JSON.parse(result); + result = JSON.parse(result); + // Added for backward compatibility + if(Array.isArray(result)) return { + cardano: [], + ergo: [], + quickAccess: [], + } + + return result }; - setSortedWallets: (Array) => Promise = value => - setLocalItem(storageKeys.SORTED_WALLETS, JSON.stringify(value ?? [])); + setWalletsNavigation: (WalletsNavigation) => Promise = value => + setLocalItem(storageKeys.WALLETS_NAVIGATION, JSON.stringify(value)); async reset(): Promise { await this.unsetUserLocale(); @@ -320,6 +334,16 @@ export default class LocalStorageApi { } +export type PersistedSubmittedTransaction = {| + publicDeriverId: number, + networkId: number, + transaction: Object, + usedUtxos: Array<{| + txHash: string, + index: number, + |}>, +|}; + export function persistSubmittedTransactions( submittedTransactions: any, ): void { diff --git a/packages/yoroi-extension/app/assets/images/my-wallets/icon_eye_off_24_revamp.png b/packages/yoroi-extension/app/assets/images/my-wallets/icon_eye_off_24_revamp.png new file mode 100644 index 0000000000..0f991815aa Binary files /dev/null and b/packages/yoroi-extension/app/assets/images/my-wallets/icon_eye_off_24_revamp.png differ diff --git a/packages/yoroi-extension/app/assets/images/my-wallets/icon_eye_open_24_revamp.png b/packages/yoroi-extension/app/assets/images/my-wallets/icon_eye_open_24_revamp.png new file mode 100644 index 0000000000..9bb78db413 Binary files /dev/null and b/packages/yoroi-extension/app/assets/images/my-wallets/icon_eye_open_24_revamp.png differ diff --git a/packages/yoroi-extension/app/assets/images/stared.inline.svg b/packages/yoroi-extension/app/assets/images/stared.inline.svg new file mode 100644 index 0000000000..ef21014ea6 --- /dev/null +++ b/packages/yoroi-extension/app/assets/images/stared.inline.svg @@ -0,0 +1,41 @@ + + + 2E21D996-C9C2-4FB5-90F1-8B4FAD2F9C9B + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/yoroi-extension/app/components/common/AmountDisplay.js b/packages/yoroi-extension/app/components/common/AmountDisplay.js new file mode 100644 index 0000000000..2e339ac38f --- /dev/null +++ b/packages/yoroi-extension/app/components/common/AmountDisplay.js @@ -0,0 +1,65 @@ +// @flow +import { Component } from 'react'; +import { getTokenName } from '../../stores/stateless/tokenHelpers'; +import { splitAmount, truncateToken } from '../../utils/formatters'; +import { hiddenAmount } from '../../utils/strings'; +import styles from './AmountDisplay.scss'; +import type { MultiToken, TokenLookupKey } from '../../api/common/lib/MultiToken'; +import type { TokenRow } from '../../api/ada/lib/storage/database/primitives/tables'; +import type { Node } from 'react'; + +type Props = {| + +showAmount?: boolean, + +showFiat?: boolean, + +shouldHideBalance: boolean, + +getTokenInfo: ($ReadOnly>) => $ReadOnly, + +amount?: null | MultiToken, +|} +export default class AmountDisplay extends Component { + static defaultProps: {| showAmount: boolean, showFiat: boolean, amount: null | MultiToken |} = { + showAmount: true, + showFiat: false, + amount: null, + }; + + render(): Node { + const { amount, shouldHideBalance, showFiat, showAmount } = this.props + if (amount == null) { + return
; + } + + const defaultEntry = amount.getDefaultEntry(); + const tokenInfo = this.props.getTokenInfo(defaultEntry); + const shiftedAmount = defaultEntry.amount.shiftedBy(-tokenInfo.Metadata.numberOfDecimals); + + let balanceDisplay; + if (shouldHideBalance) { + balanceDisplay = {hiddenAmount}; + } else { + const [beforeDecimalRewards, afterDecimalRewards] = splitAmount( + shiftedAmount, + tokenInfo.Metadata.numberOfDecimals + ); + + balanceDisplay = ( + <> + {beforeDecimalRewards} + {afterDecimalRewards} + + ); + } + + return ( + <> + {showAmount === true && +

+ {balanceDisplay} {truncateToken(getTokenName(tokenInfo))} +

} + {showFiat === true && +

+ {balanceDisplay} USD +

} + + ); + } +}; \ No newline at end of file diff --git a/packages/yoroi-extension/app/components/common/AmountDisplay.scss b/packages/yoroi-extension/app/components/common/AmountDisplay.scss new file mode 100644 index 0000000000..2646b52e52 --- /dev/null +++ b/packages/yoroi-extension/app/components/common/AmountDisplay.scss @@ -0,0 +1,20 @@ +@import '../../themes/mixins/loading-spinner'; + +.isLoading { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin-left: 12px; + @include loading-spinner('../../assets/images/spinner-dark.svg', 16); +} + +.amount { + font-weight: 500; + font-size: 16px; +} + +.fiat { + color: var(--yoroi-palette-gray-600); + font-size: 14px; +} \ No newline at end of file diff --git a/packages/yoroi-extension/app/components/common/TextField.js b/packages/yoroi-extension/app/components/common/TextField.js index 0bc4ee131f..7c7b99c7c0 100644 --- a/packages/yoroi-extension/app/components/common/TextField.js +++ b/packages/yoroi-extension/app/components/common/TextField.js @@ -63,7 +63,7 @@ function TextField({ onBlur={onBlur} onChange={onChange} type={type !== 'password' ? type : showPassword ? 'text' : 'password'} - variant={revamp ? 'standard' : 'outlined'} + variant={revamp === true ? 'standard' : 'outlined'} /* In order to show placeholders for classic theme we dont' need to override 'shrink' and 'notched' prop status so we pass an empty object diff --git a/packages/yoroi-extension/app/components/topbar/BuySellAdaButton.js b/packages/yoroi-extension/app/components/topbar/BuySellAdaButton.js index 304d0e3ff5..18c7a4d604 100644 --- a/packages/yoroi-extension/app/components/topbar/BuySellAdaButton.js +++ b/packages/yoroi-extension/app/components/topbar/BuySellAdaButton.js @@ -38,7 +38,15 @@ class BuySellAdaButton extends Component { ); const BuyAdaButtonRevamp = ( - ); diff --git a/packages/yoroi-extension/app/components/topbar/NavBarRevamp.js b/packages/yoroi-extension/app/components/topbar/NavBarRevamp.js index b95d540817..4a398681a5 100644 --- a/packages/yoroi-extension/app/components/topbar/NavBarRevamp.js +++ b/packages/yoroi-extension/app/components/topbar/NavBarRevamp.js @@ -2,8 +2,7 @@ import { Component } from 'react'; import type { Node } from 'react'; import { observer } from 'mobx-react'; -import NoticeBoardIcon from '../../assets/images/top-bar/notification.inline.svg'; -import { Box, IconButton } from '@mui/material'; +import { Box } from '@mui/material'; type Props = {| +children?: ?Node, @@ -45,7 +44,7 @@ class NavBarRevamp extends Component { maxWidth: 'calc(1366px - 90px)', position: 'relative', zIndex: 100, - height: menu != null ? '115px' : '90px', + height: menu != null ? '125px' : '90px', margin: 'auto', }} > @@ -72,21 +71,14 @@ class NavBarRevamp extends Component { sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }} > {children} + {this.props.buyButton != null && ( + {this.props.buyButton} + )} {this.props.walletDetails != null && ( {walletDetails} )} - - - - {this.props.buyButton != null && ( - {this.props.buyButton} - )} {menu != null ? ( diff --git a/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.js b/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.js index e61bcb2cc1..8539546f85 100644 --- a/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.js +++ b/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.js @@ -6,6 +6,7 @@ import styles from './NavDropdownContentRevamp.scss'; import { intlShape } from 'react-intl'; import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; import globalMessages from '../../i18n/global-messages'; +import { Button } from '@mui/material'; type Props = {| +openWalletInfoDialog: void => void, @@ -32,10 +33,10 @@ export default class NavDropdownContentRevamp extends Component {
{contentComponents}
- +
diff --git a/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.scss b/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.scss index 106cde11de..dff7f62386 100644 --- a/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.scss +++ b/packages/yoroi-extension/app/components/topbar/NavDropdownContentRevamp.scss @@ -1,7 +1,7 @@ .wrapper { position: absolute; padding-top: 33px; - top: 40px; + top: 30px; width: 100%; right: 0; z-index: 1; diff --git a/packages/yoroi-extension/app/components/topbar/NavDropdownRevamp.js b/packages/yoroi-extension/app/components/topbar/NavDropdownRevamp.js index fad50f674e..72fab3e5f0 100644 --- a/packages/yoroi-extension/app/components/topbar/NavDropdownRevamp.js +++ b/packages/yoroi-extension/app/components/topbar/NavDropdownRevamp.js @@ -1,10 +1,8 @@ // @flow import React, { Component } from 'react'; -import classnames from 'classnames'; import type { Node, ElementRef } from 'react'; import { observer } from 'mobx-react'; import styles from './NavDropdownRevamp.scss'; -import ArrowDown from '../../assets/images/my-wallets/arrow_down.inline.svg'; import NavDropdownContentRevamp from './NavDropdownContentRevamp'; @@ -48,17 +46,10 @@ export default class NavDropdownRevamp extends Component { return (
-
- {isExpanded ? ( - - ) : null} -
{headerComponent}
{isExpanded ? ( Promise, @@ -81,7 +80,6 @@ export default class NavWalletDetailsRevamp extends Component { const { shouldHideBalance, onUpdateHideBalance, - highlightTitle, showEyeIcon, plate, } = this.props; @@ -90,38 +88,38 @@ export default class NavWalletDetailsRevamp extends Component { const showEyeIconSafe = showEyeIcon != null && showEyeIcon; - const [, iconComponent] = plate ? constructPlate(plate, 0, styles.icon) : []; - + const [accountPlateId, iconComponent] = plate ? constructPlate(plate, 0, styles.icon) : []; return (
-
{iconComponent}
-
-
- {this.renderAmountDisplay({ - shouldHideBalance, - amount: totalAmount, - })} -
-
- {/* TODO: fix value to USD */} - {this.renderAmountDisplay({ - shouldHideBalance, - amount: totalAmount, - })}{' '} - USD +
+
{iconComponent}
+
+
+

+ {truncateLongName(this.props.wallet.conceptualWalletName)} +

+

{accountPlateId}

+
+
+
+ +
+
- {totalAmount != null && showEyeIconSafe && ( - - )} +
); @@ -136,40 +134,4 @@ export default class NavWalletDetailsRevamp extends Component { } return this.props.rewards.joinAddCopy(this.props.walletAmount); }; - - renderAmountDisplay: ({| - shouldHideBalance: boolean, - amount: ?MultiToken, - |}) => Node = request => { - if (request.amount == null) { - return
; - } - - const defaultEntry = request.amount.getDefaultEntry(); - const tokenInfo = this.props.getTokenInfo(defaultEntry); - const shiftedAmount = defaultEntry.amount.shiftedBy(-tokenInfo.Metadata.numberOfDecimals); - - let balanceDisplay; - if (request.shouldHideBalance) { - balanceDisplay = {hiddenAmount}; - } else { - const [beforeDecimalRewards, afterDecimalRewards] = splitAmount( - shiftedAmount, - tokenInfo.Metadata.numberOfDecimals - ); - - balanceDisplay = ( - <> - {beforeDecimalRewards} - {afterDecimalRewards} - - ); - } - - return ( - <> - {balanceDisplay} {truncateToken(getTokenName(tokenInfo))} - - ); - }; } diff --git a/packages/yoroi-extension/app/components/topbar/NavWalletDetailsRevamp.scss b/packages/yoroi-extension/app/components/topbar/NavWalletDetailsRevamp.scss index b7f847f26f..289e8689fe 100644 --- a/packages/yoroi-extension/app/components/topbar/NavWalletDetailsRevamp.scss +++ b/packages/yoroi-extension/app/components/topbar/NavWalletDetailsRevamp.scss @@ -5,16 +5,29 @@ flex-direction: column; justify-content: center; color: var(--yoroi-palette-gray-900); + border: 1px solid var(--yoroi-palette-gray-300); + border-radius: 8px; + min-width: 360px; } .outerWrapper { + display: flex; + align-items: stretch; + width: 100%; +} + +.contentWrapper { display: flex; align-items: center; + width: 100%; + padding: 0px; + padding: 0px 16px; } .currency { margin-right: 12px; display: flex; + flex-shrink: 0; border-radius: 50%; border: none; overflow: hidden; @@ -26,28 +39,78 @@ } } +.walletInfo { + max-width: 100px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: start; + margin-right: auto; + + .name { + font-size: 14px; + line-height: 22px; + color: #242838; + font-weight: 500; + } + + .plateId { + font-weight: 400; + font-size: 12px; + line-height: 16px; + letter-spacing: 0.2px; + color: #6B7384; + } +} + .content { + display: flex; + flex-direction: row; + justify-content: center; + font-size: 16px; + line-height: 22px; + width: 100%; +} +.balance { display: flex; flex-direction: column; justify-content: center; + align-items: flex-end; + text-align: left; font-size: 16px; line-height: 22px; + width: 100%; } -.isLoading { - position: relative; - display: inline-block; - margin-left: 15px; - margin-bottom: 4px; +.spinnerWrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: left; + font-size: 16px; + line-height: 22px; + width: 100%; } + .amount { font-weight: 500; - .isLoading { - margin-left: 9px; - margin-bottom: 6px; - @include loading-spinner('../../assets/images/spinner-dark.svg', 16); + + & > p { + text-align: left !important; + display: flex; + align-items: center; + justify-content: flex-end; + + &:nth-child(2) { + margin-top: -2px; + } } + + } .highlightAmount { @@ -56,9 +119,18 @@ .toggleButton { margin-left: auto; + background-color: var(--yoroi-palette-primary-200); + width: 56px; + height: 56px; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; &:hover { cursor: pointer; } + + g { + stroke: #ffffff; + } } .fixedAmount { diff --git a/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.js b/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.js index 6b14893ff9..1f293a585f 100644 --- a/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.js +++ b/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.js @@ -5,13 +5,9 @@ import { observer } from 'mobx-react'; import { defineMessages, intlShape } from 'react-intl'; import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; import styles from './NoWalletsAccessList.scss'; -import StarIcon from '../../assets/images/add-wallet/wallet-list/stared.inline.svg'; +import QuickAccessListheader from './QuickAccessListHeader'; const messages = defineMessages({ - quickAccess: { - id: 'wallet.nav.noWalletsAccessList.quickAccess', - defaultMessage: '!!!Quick access wallets', - }, noWallets: { id: 'wallet.nav.noWalletsAccessList.noWallets', defaultMessage: '!!!No wallets added to this list yet', @@ -35,10 +31,7 @@ export default class NoWalletsAccessList extends Component { return (
-
- -

{intl.formatMessage(messages.quickAccess)}

-
+

{intl.formatMessage(messages.noWallets)}

{intl.formatMessage(messages.goToWallets)}

diff --git a/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.scss b/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.scss index c12b16dd11..127f3e8018 100644 --- a/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.scss +++ b/packages/yoroi-extension/app/components/topbar/NoWalletsAccessList.scss @@ -8,18 +8,7 @@ padding: 40px 36px; text-align: center; } -.header { - display: flex; - align-items: center; - svg { - margin-right: 8px; - } - h3 { - font-size: 24px; - color: var(--yoroi-palette-gray-900); - line-height: 30px; - } -} + .content { padding: 60px 0; } diff --git a/packages/yoroi-extension/app/components/topbar/QuickAccessListHeader.js b/packages/yoroi-extension/app/components/topbar/QuickAccessListHeader.js new file mode 100644 index 0000000000..8eb263e532 --- /dev/null +++ b/packages/yoroi-extension/app/components/topbar/QuickAccessListHeader.js @@ -0,0 +1,49 @@ +// @flow +import { Component } from 'react'; +import type { Node } from 'react'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; +import StarIcon from '../../assets/images/add-wallet/wallet-list/stared.inline.svg'; +import { Box } from '@mui/system'; +import { Typography } from '@mui/material'; + +const messages = defineMessages({ + quickAccess: { + id: 'wallet.nav.noWalletsAccessList.quickAccess', + defaultMessage: '!!!Quick access wallets', + }, +}); + +type Props = {||}; + +@observer +export default class QuickAccessListheader extends Component { + static contextTypes: {| intl: $npm$ReactIntl$IntlFormat |} = { + intl: intlShape.isRequired, + }; + + render(): Node { + const { intl } = this.context; + + return ( + svg': { marginRight: '8px' } + }} + > + + {intl.formatMessage(messages.quickAccess)} + + + ); + } +} diff --git a/packages/yoroi-extension/app/components/topbar/QuickAccessWalletCard.js b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletCard.js new file mode 100644 index 0000000000..3ea2c6a3ba --- /dev/null +++ b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletCard.js @@ -0,0 +1,82 @@ +// @flow +import { Component } from 'react'; +import type { Node } from 'react'; +import { observer } from 'mobx-react'; +import { intlShape } from 'react-intl'; +import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; +import styles from './QuickAccessWalletCard.scss' +import { getType } from '../../utils/walletInfo'; +import { constructPlate } from './WalletCard'; +import { MultiToken } from '../../api/common/lib/MultiToken'; +import AmountDisplay from '../common/AmountDisplay'; +import type { WalletChecksum } from '@emurgo/cip4-js'; +import type { ConceptualWallet } from '../../api/ada/lib/storage/models/ConceptualWallet'; +import type { TokenLookupKey } from '../../api/common/lib/MultiToken'; +import type { TokenRow } from '../../api/ada/lib/storage/database/primitives/tables'; + +type Props = {| + +plate: null | WalletChecksum, + +wallet: {| + conceptualWallet: ConceptualWallet, + conceptualWalletName: string, + |}, + +rewards: null | void | MultiToken, + +shouldHideBalance: boolean, + +walletAmount: null | MultiToken, + +getTokenInfo: ($ReadOnly>) => $ReadOnly, +|} + +@observer +export default class QuickAccessWalletCard extends Component { + static contextTypes: {| intl: $npm$ReactIntl$IntlFormat |} = { + intl: intlShape.isRequired, + }; + + render(): Node { + const { intl } = this.context; + const { shouldHideBalance } = this.props; + + const [, iconComponent] = this.props.plate + ? constructPlate(this.props.plate, 0, styles.main) + : []; + + const typeText = [getType(this.props.wallet.conceptualWallet)] + .filter(text => text != null) + .map(text => intl.formatMessage(text)) + .join(' - '); + const totalAmount = this.getTotalAmount(); + + return ( +
+
+
{this.props.wallet.conceptualWalletName}
+ {' · '} +
{typeText}
+
+
+
{iconComponent}
+
+
+ +
+
+
+
+ ) + } + + getTotalAmount: void => null | MultiToken = () => { + if (this.props.rewards === undefined) { + return this.props.walletAmount; + } + if (this.props.rewards === null || this.props.walletAmount === null) { + return null; + } + return this.props.rewards.joinAddCopy(this.props.walletAmount); + }; +} \ No newline at end of file diff --git a/packages/yoroi-extension/app/components/topbar/QuickAccessWalletCard.scss b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletCard.scss new file mode 100644 index 0000000000..d74e3eb71a --- /dev/null +++ b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletCard.scss @@ -0,0 +1,62 @@ +@import '../../themes/mixins/loading-spinner'; + +.component { + margin-top: 20px; + + .header { + display: flex; + color: var(--yoroi-palette-gray-900); + font-size: 14px; + letter-spacing: 0; + line-height: 22px; + margin-bottom: 8px; + + .name { + margin-right: 4px; + } + + .type { + margin-left: 4px; + } + } + + .body { + display: flex; + align-items: center; + margin-top: 0px; + + .isLoading { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin-left: 15px; + } + + .amount { + margin-left: 12px; + flex: 1; + + & > p:nth-child(2) { + margin-top: 5px; + } + } + + .fixedAmount { + margin-left: 12px; + color: var(--yoroi-palette-gray-600); + font-size: 14px; + letter-spacing: 0; + line-height: 22px; + } + } +} + +.main { + flex: 1; + canvas { + width: 32px !important; + height: 32px !important; + border-radius: 50%; + } +} \ No newline at end of file diff --git a/packages/yoroi-extension/app/components/topbar/QuickAccessWalletsList.js b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletsList.js new file mode 100644 index 0000000000..cf5e585b2b --- /dev/null +++ b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletsList.js @@ -0,0 +1,30 @@ +// @flow +import { Component } from 'react'; +import type { Node } from 'react'; +import { observer } from 'mobx-react'; +import { intlShape } from 'react-intl'; +import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; +import QuickAccessListheader from './QuickAccessListHeader'; +import styles from './QuickAccessWalletsList.scss' +import QuickAccessWalletCard from './QuickAccessWalletCard'; + +type Props = {| + wallets: Array +|} +@observer +export default class QuickAccessWalletsList extends Component { + static contextTypes: {| intl: $npm$ReactIntl$IntlFormat |} = { + intl: intlShape.isRequired, + }; + + render(): Node { + return ( +
+ +
+ {this.props.wallets.map(wallet => )} +
+
+ ) + } +} \ No newline at end of file diff --git a/packages/yoroi-extension/app/components/topbar/QuickAccessWalletsList.scss b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletsList.scss new file mode 100644 index 0000000000..e3281ff76d --- /dev/null +++ b/packages/yoroi-extension/app/components/topbar/QuickAccessWalletsList.scss @@ -0,0 +1,9 @@ +.component { + padding: 40px 24px 24px 24px; + height: 330px; + overflow: auto; + + .walletsList { + margin-top: 20px; + } +} \ No newline at end of file diff --git a/packages/yoroi-extension/app/components/topbar/WalletCard.js b/packages/yoroi-extension/app/components/topbar/WalletCard.js index a97698d34e..df8336bf87 100644 --- a/packages/yoroi-extension/app/components/topbar/WalletCard.js +++ b/packages/yoroi-extension/app/components/topbar/WalletCard.js @@ -27,13 +27,21 @@ import type { TokenLookupKey } from '../../api/common/lib/MultiToken'; import type { TokenRow } from '../../api/ada/lib/storage/database/primitives/tables'; import DragIcon from '../../assets/images/add-wallet/wallet-list/drag.inline.svg'; import StarIcon from '../../assets/images/add-wallet/wallet-list/star.inline.svg'; +import StaredIcon from '../../assets/images/add-wallet/wallet-list/stared.inline.svg'; + import { Draggable } from 'react-beautiful-dnd'; +import { Tooltip, Typography } from '@mui/material'; +import AmountDisplay from '../common/AmountDisplay'; const messages = defineMessages({ tokenTypes: { id: 'wallet.topbar.dialog.tokenTypes', defaultMessage: '!!!Token types', }, + quickAccessTooltip: { + id: 'wallet.topbar.dialog.quickAccess', + defaultMessage: '!!!Add to quick acceess wallets list', + } }); type Props = {| @@ -50,11 +58,13 @@ type Props = {| +onSelect?: void => void, +walletId: string, +idx: number, + +toggleQuickAccess: string => void, + +isInQuickAccess: boolean, |}; type State = {| +isActionsShow: boolean |}; -function constructPlate( +export function constructPlate( plate: WalletChecksum, saturationFactor: number, divClass: string @@ -153,14 +163,18 @@ export default class WalletCard extends Component { this.props.isCurrentWallet === true && styles.currentCardWrapper, snapshot.isDragging === true && styles.isDragging )} - onClick={this.props.onSelect} - onKeyDown={this.props.onSelect} onMouseEnter={this.showActions} onMouseLeave={this.hideActions} {...provided.draggableProps} ref={provided.innerRef} > -
+
{this.props.wallet.conceptualWalletName}
{' · '} @@ -169,20 +183,13 @@ export default class WalletCard extends Component {
{iconComponent}
-
- {this.renderAmountDisplay({ - shouldHideBalance, - amount: totalAmount, - })} -
-
- {/* TODO: fix value to USD */} - {this.renderAmountDisplay({ - shouldHideBalance, - amount: totalAmount, - })}{' '} - USD -
+

@@ -204,10 +211,28 @@ export default class WalletCard extends Component {

- + {!this.props.isInQuickAccess && + + {intl.formatMessage(messages.quickAccessTooltip)} + + } + placement="bottom-end" + > + + }
+ {this.props.isInQuickAccess && + }
)} diff --git a/packages/yoroi-extension/app/components/topbar/WalletCard.scss b/packages/yoroi-extension/app/components/topbar/WalletCard.scss index a74f7e0864..8fe353752d 100644 --- a/packages/yoroi-extension/app/components/topbar/WalletCard.scss +++ b/packages/yoroi-extension/app/components/topbar/WalletCard.scss @@ -77,9 +77,12 @@ } } .actions { - min-width: 80px; opacity: 0; transition: opacity 0.3s; + display: flex; + align-items: center; + justify-content: center; + svg { max-width: 24px; max-height: 24px; @@ -97,6 +100,25 @@ } } } + +.quickAccessToggle { + svg { + max-width: 24px; + max-height: 24px; + color: var(--yoroi-palette-gray-600); + } + + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + &:hover { + background: #eaedf2; + } +} + .showActions { opacity: 1; } diff --git a/packages/yoroi-extension/app/components/topbar/WalletListDialog.js b/packages/yoroi-extension/app/components/topbar/WalletListDialog.js index 6dfb6fe5e9..0f5d6e703f 100644 --- a/packages/yoroi-extension/app/components/topbar/WalletListDialog.js +++ b/packages/yoroi-extension/app/components/topbar/WalletListDialog.js @@ -10,15 +10,14 @@ import styles from './WalletListDialog.scss'; import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; import IconEyeOpen from '../../assets/images/my-wallets/icon_eye_open.inline.svg'; import IconEyeClosed from '../../assets/images/my-wallets/icon_eye_closed.inline.svg'; -import { splitAmount, truncateToken } from '../../utils/formatters'; -import { getTokenName } from '../../stores/stateless/tokenHelpers'; -import { hiddenAmount } from '../../utils/strings'; import type { TokenLookupKey } from '../../api/common/lib/MultiToken'; import type { TokenRow } from '../../api/ada/lib/storage/database/primitives/tables'; import { MultiToken } from '../../api/common/lib/MultiToken'; import WalletCard from './WalletCard'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import globalMessages from '../../i18n/global-messages'; +import AmountDisplay from '../common/AmountDisplay'; +import type { WalletsNavigation } from '../../api/localStorage'; const messages = defineMessages({ addWallet: { @@ -29,6 +28,14 @@ const messages = defineMessages({ id: 'wallet.topbar.dialog.totalBalance', defaultMessage: '!!!Total Balance', }, + ergo: { + id: 'wallet.topbar.dialog.ergo', + defaultMessage: '!!!Ergo, ERG', + }, + cardano: { + id: 'wallet.topbar.dialog.cardano', + defaultMessage: '!!!Cardano, ADA', + }, }); type Props = {| @@ -38,12 +45,14 @@ type Props = {| +getTokenInfo: ($ReadOnly>) => $ReadOnly, +walletAmount: ?MultiToken, +onAddWallet: void => void, - +wallets: Array, - +currentSortedWallets: Array | void, - +updateSortedWalletList: ({| sortedWallets: Array |}) => Promise, + +ergoWallets: Array, + +cardanoWallets: Array, + +walletsNavigation: WalletsNavigation, + +updateSortedWalletList: WalletsNavigation => Promise, |}; type State = {| - walletListIdx: Array, + ergoWalletsIdx: number[], + cardanoWalletsIdx: number[], |}; const reorder = (list, startIndex, endIndex) => { @@ -52,80 +61,78 @@ const reorder = (list, startIndex, endIndex) => { result.splice(endIndex, 0, removed); return result; }; + +const getGeneratedWalletIds = (sortedWalletListIdx, currentWalletIdx) => { + let generatedWalletIds; + if (sortedWalletListIdx !== undefined && sortedWalletListIdx.length > 0) { + const newWalletIds = currentWalletIdx.filter(id => { + const index = sortedWalletListIdx.indexOf(id); + if (index === -1) { + return true; + } + return false; + }); + generatedWalletIds = [...sortedWalletListIdx, ...newWalletIds]; + } else { + generatedWalletIds = currentWalletIdx; + } + + return generatedWalletIds +} @observer export default class WalletListDialog extends Component { static contextTypes: {| intl: $npm$ReactIntl$IntlFormat |} = { intl: intlShape.isRequired, }; state: State = { - walletListIdx: [], + ergoWalletsIdx: [], + cardanoWalletsIdx: [], }; async componentDidMount(): Promise { - const sortedWalletListIdx = this.props.currentSortedWallets; - const currentWalletIdx = this.props.wallets.map(wallet => wallet.walletId); - - let generatedWalletIds; - if (sortedWalletListIdx !== undefined && sortedWalletListIdx.length > 0) { - const newWalletIds = currentWalletIdx.filter(id => { - const index = sortedWalletListIdx.indexOf(id); - if (index === -1) { - return true; - } - return false; - }); - generatedWalletIds = [...sortedWalletListIdx, ...newWalletIds]; - } else { - generatedWalletIds = currentWalletIdx; - } + const cardanoWalletsId = getGeneratedWalletIds( + this.props.walletsNavigation.cardano, + this.props.cardanoWallets.map(wallet => wallet.walletId) + ) + const ergoWalletsId = getGeneratedWalletIds( + this.props.walletsNavigation.ergo, + this.props.ergoWallets.map(wallet => wallet.walletId) + ) this.setState( { - walletListIdx: generatedWalletIds, + ergoWalletsIdx: ergoWalletsId, + cardanoWalletsIdx: cardanoWalletsId, }, async () => { - await this.props.updateSortedWalletList({ sortedWallets: generatedWalletIds }); + await this.props.updateSortedWalletList({ + ergo: ergoWalletsId, + cardano: cardanoWalletsId, + quickAccess: this.props.walletsNavigation.quickAccess || [], + }); } ); } - renderAmountDisplay: ({| - shouldHideBalance: boolean, - amount: ?MultiToken, - |}) => Node = request => { - if (request.amount == null) { - return
; - } - - const defaultEntry = request.amount.getDefaultEntry(); - const tokenInfo = this.props.getTokenInfo(defaultEntry); - const shiftedAmount = defaultEntry.amount.shiftedBy(-tokenInfo.Metadata.numberOfDecimals); - - let balanceDisplay; - if (request.shouldHideBalance) { - balanceDisplay = {hiddenAmount}; + toggleQuickAccess: number => Promise = async (walletId) => { + if(!walletId || typeof walletId !== 'number') throw new Error('Invalid wallet id.') + const currentQuickAccessList = this.props.walletsNavigation.quickAccess + let updatedQuickAccessList = [...currentQuickAccessList]; + // Remove wallet + if(currentQuickAccessList.indexOf(walletId) !== -1) { + updatedQuickAccessList = updatedQuickAccessList.filter(id => id !== walletId) } else { - const [beforeDecimalRewards, afterDecimalRewards] = splitAmount( - shiftedAmount, - tokenInfo.Metadata.numberOfDecimals - ); - - balanceDisplay = ( - <> - {beforeDecimalRewards} - {afterDecimalRewards} - - ); + // Add wallet + updatedQuickAccessList.push(walletId) } - return ( - <> - {balanceDisplay} {truncateToken(getTokenName(tokenInfo))} - - ); - }; + await this.props.updateSortedWalletList({ + ...this.props.walletsNavigation, + quickAccess: updatedQuickAccessList + }); + } - onDragEnd: Object => any = async result => { + onDragEnd: (network: 'ergo' | 'cardano' ,result:Object) => any = async (network, result) => { const { destination, source } = result; if (!destination || destination.index === source.index) { return; @@ -134,36 +141,44 @@ export default class WalletListDialog extends Component { this.setState( prev => { const walletListIdx = reorder( - prev.walletListIdx, + network === 'ergo' ? prev.ergoWalletsIdx : prev.cardanoWalletsIdx, result.source.index, result.destination.index ); return { - walletListIdx, + ergoWalletsIdx: network === 'ergo' ? walletListIdx : prev.ergoWalletsIdx, + cardanoWalletsIdx: network === 'cardano' ? walletListIdx: prev.cardanoWalletsIdx }; }, async function () { - await this.props.updateSortedWalletList({ sortedWallets: this.state.walletListIdx }); + await this.props.updateSortedWalletList({ + ergo: this.state.ergoWalletsIdx, + cardano: this.state.cardanoWalletsIdx, + quickAccess: this.props.walletsNavigation.quickAccess || [], + }); } ); }; render(): Node { const { intl } = this.context; - const { walletListIdx } = this.state; + const { ergoWalletsIdx, cardanoWalletsIdx } = this.state; const { shouldHideBalance, onAddWallet, walletAmount, onUpdateHideBalance, - wallets, + ergoWallets, + cardanoWallets } = this.props; + const quickAccessList = new Set(this.props.walletsNavigation.quickAccess) + return ( } onClose={this.props.close} @@ -174,11 +189,13 @@ export default class WalletListDialog extends Component {

{intl.formatMessage(messages.totalBalance)}

- {this.renderAmountDisplay({ - shouldHideBalance, - amount: walletAmount, - })}{' '} - USD +

} - - + {cardanoWalletsIdx.length > 0 && +
+

{intl.formatMessage(messages.cardano)}

+
} + this.onDragEnd('cardano', result)}> + + {provided => ( +
+ {cardanoWalletsIdx.length > 0 && + cardanoWalletsIdx.map((walletId, idx) => { + const wallet = this.props.cardanoWallets.find(w => w.walletId === walletId); + if (!wallet) { + return null; + } + return ( + ); + }).filter(Boolean)} + {provided.placeholder} +
+ )} +
+
+ {cardanoWalletsIdx.length > 0 && +
+

{intl.formatMessage(messages.ergo)}

+
} + this.onDragEnd('ergo', result)}> + {provided => (
- {walletListIdx.length > 0 && - walletListIdx.map((walletId, idx) => { - const wallet = this.props.wallets.find(w => w.walletId === walletId); - // Previously, after a wallet was deleted, the sorted wallet list was not - // updated to remove the deleted wallet, so `wallet` might be null. - // This should no longer happen but we keep filtering out the null - // value (instead of throwing an error) just in case some users - // have already deleted wallets before the fix. + {ergoWalletsIdx.length > 0 && + ergoWalletsIdx.map((walletId, idx) => { + const wallet = this.props.ergoWallets.find(w => w.walletId === walletId); if (!wallet) { return null; } - return ; + return ( + + ); }).filter(Boolean)} {provided.placeholder}
diff --git a/packages/yoroi-extension/app/components/topbar/WalletListDialog.scss b/packages/yoroi-extension/app/components/topbar/WalletListDialog.scss index d21d844aa9..41c9095acd 100644 --- a/packages/yoroi-extension/app/components/topbar/WalletListDialog.scss +++ b/packages/yoroi-extension/app/components/topbar/WalletListDialog.scss @@ -2,6 +2,7 @@ font-size: 16px; min-width: 673px !important; padding-right: 0 !important; + padding-bottom: 0px !important; .header { position: sticky; @@ -24,6 +25,11 @@ } .value { font-weight: 500; + margin-top: 10px; + & > p { + color: var(--yoroi-palette-gray-600) !important; + font-size: 16px; + } } } .toggleButton { @@ -31,13 +37,19 @@ align-self: flex-end; } + .sectionHeader { + color: #A7AFC0; + font-size: 14px; + margin-top: 26px; + padding-left: 40px; + } + .list { display: flex; flex-direction: column; - padding: 30px 0; + padding: 15px 0px 30px 0; padding-left: 24px; overflow-y: scroll; - min-height: 400px; } .footer { position: sticky; @@ -53,4 +65,4 @@ color: var(--yoroi-palette-secondary-300); } } -} +} \ No newline at end of file diff --git a/packages/yoroi-extension/app/containers/NavBarContainerRevamp.js b/packages/yoroi-extension/app/containers/NavBarContainerRevamp.js index 04661297f9..eef6209676 100644 --- a/packages/yoroi-extension/app/containers/NavBarContainerRevamp.js +++ b/packages/yoroi-extension/app/containers/NavBarContainerRevamp.js @@ -25,9 +25,11 @@ import NavWalletDetailsRevamp from '../components/topbar/NavWalletDetailsRevamp' import BuySellAdaButton from '../components/topbar/BuySellAdaButton'; import NoWalletsAccessList from '../components/topbar/NoWalletsAccessList'; import WalletListDialog from '../components/topbar/WalletListDialog'; -import { networks } from '../api/ada/lib/storage/database/prepackaged/networks'; +import { networks, isErgo } from '../api/ada/lib/storage/database/prepackaged/networks'; import { addressToDisplayString } from '../api/ada/lib/storage/bridge/utils'; import { getReceiveAddress } from '../stores/stateless/addressStores'; +import QuickAccessWalletsList from '../components/topbar/QuickAccessWalletsList' +import type { WalletsNavigation } from '../api/localStorage'; export type GeneratedData = typeof NavBarContainerRevamp.prototype.generated; @@ -107,11 +109,46 @@ export default class NavBarContainerRevamp extends Component { ); }; + const QuickAccessList = () => { + const quickAccessWallets = this.generated.stores.profile.walletsNavigation.quickAccess + if (!quickAccessWallets || quickAccessWallets.length === 0) return + + const publicDerivers = this.generated.stores.wallets.publicDerivers; + const walletsMap = [] + publicDerivers.forEach(wallet => { + const parent = wallet.getParent(); + const id = wallet.getPublicDeriverId() + if (quickAccessWallets.indexOf(id) === -1) return + const walletTxRequests = this.generated.stores.transactions.getTxRequests(wallet); + const balance = walletTxRequests.requests.getBalanceRequest.result || null; + const settingsCache = this.generated.stores.walletSettings.getConceptualWalletSettingsCache( + parent + ); + const withPubKey = asGetPublicKey(wallet); + const plate = + withPubKey == null + ? null + : this.generated.stores.wallets.getPublicKeyCache(withPubKey).plate; + walletsMap.push({ + walletAmount: balance, + getTokenInfo: genLookupOrFail(this.generated.stores.tokenInfoStore.tokenInfo), + wallet: settingsCache, + shouldHideBalance: this.generated.stores.profile.shouldHideBalance, + plate, + rewards: this.getRewardBalance(wallet), + }) + }) + + return ( + + ) + } + const DropdownComponent = () => { return ( } - contentComponents={} + contentComponents={} walletsCount={wallets.length} openWalletInfoDialog={() => { this.generated.actions.dialogs.open.trigger({ dialog: WalletListDialog }); @@ -127,11 +164,6 @@ export default class NavBarContainerRevamp extends Component { title={this.props.title} menu={this.props.menu} walletDetails={} - goToNotifications={() => - this.generated.actions.router.goToRoute.trigger({ - route: ROUTES.NOTICE_BOARD.ROOT, - }) - } buyButton={ @@ -153,7 +185,10 @@ export default class NavBarContainerRevamp extends Component { balance = txRequests.requests.getBalanceRequest.result; } - const walletsMap = wallets.map(wallet => { + const ergoWallets = [] + const cardanoWallets = [] + + wallets.forEach(wallet => { const walletTxRequests = this.generated.stores.transactions.getTxRequests(wallet); const walletBalance = walletTxRequests.requests.getBalanceRequest.result || null; const parent = wallet.getParent(); @@ -167,7 +202,7 @@ export default class NavBarContainerRevamp extends Component { ? null : this.generated.stores.wallets.getPublicKeyCache(withPubKey).plate; - return { + const walletMap = { walletId: wallet.getPublicDeriverId(), rewards: this.getRewardBalance(wallet), walletAmount: walletBalance, @@ -178,12 +213,16 @@ export default class NavBarContainerRevamp extends Component { onSelect: () => this.switchToNewWallet(wallet), isCurrentWallet: wallet === this.generated.stores.wallets.selected, }; + + if(isErgo(wallet.getParent().getNetworkInfo())) ergoWallets.push(walletMap) + else cardanoWallets.push(walletMap) }); if (this.generated.stores.uiDialogs.isOpen(WalletListDialog)) { return ( { this.generated.actions.router.goToRoute.trigger({ route: ROUTES.WALLETS.ADD }) }} updateSortedWalletList={this.generated.actions.profile.updateSortedWalletList.trigger} - currentSortedWallets={this.generated.stores.profile.currentSortedWallets ?? []} + walletsNavigation={this.generated.stores.profile.walletsNavigation} /> ); } @@ -285,7 +324,7 @@ export default class NavBarContainerRevamp extends Component { trigger: (params: void) => Promise, |}, updateSortedWalletList: {| - trigger: ({| sortedWallets: Array |}) => Promise, + trigger: (WalletsNavigation) => Promise, |}, |}, router: {| @@ -313,7 +352,7 @@ export default class NavBarContainerRevamp extends Component { |}, profile: {| shouldHideBalance: boolean, - currentSortedWallets: ?Array, + walletsNavigation: WalletsNavigation, |}, tokenInfoStore: {| tokenInfo: TokenInfoMap, @@ -363,7 +402,7 @@ export default class NavBarContainerRevamp extends Component { }, profile: { shouldHideBalance: stores.profile.shouldHideBalance, - currentSortedWallets: stores.profile.currentSortedWallets, + walletsNavigation: stores.profile.walletsNavigation, }, delegation: { getDelegationRequests: stores.delegation.getDelegationRequests, diff --git a/packages/yoroi-extension/app/containers/settings/categories/RemoveWalletDialogContainer.js b/packages/yoroi-extension/app/containers/settings/categories/RemoveWalletDialogContainer.js index 0582395f23..818358ca8c 100644 --- a/packages/yoroi-extension/app/containers/settings/categories/RemoveWalletDialogContainer.js +++ b/packages/yoroi-extension/app/containers/settings/categories/RemoveWalletDialogContainer.js @@ -15,6 +15,8 @@ import DangerousActionDialog from '../../../components/widgets/DangerousActionDi import LocalizableError from '../../../i18n/LocalizableError'; import { withLayout } from '../../../styles/context/layout'; import type { LayoutComponentMap } from '../../../styles/context/layout'; +import { getWalletType } from '../../../stores/toplevel/WalletSettingsStore'; +import type { WalletsNavigation } from '../../../api/localStorage' export type GeneratedData = typeof RemoveWalletDialogContainer.prototype.generated; @@ -61,16 +63,19 @@ class RemoveWalletDialogContainer extends Component { const settingsActions = this.generated.actions.walletSettings; const selectedWalletId = this.props.publicDeriver?.getPublicDeriverId(); - const walletIdList = this.generated.stores.profile.currentSortedWallets; - - const newSortedWalletIds = - walletIdList !== null && - walletIdList !== undefined && - walletIdList.filter(walletId => walletId !== selectedWalletId); - - await this.generated.actions.profile.updateSortedWalletList.trigger({ - sortedWallets: newSortedWalletIds || [], - }); + const walletsNavigation = this.generated.stores.profile.walletsNavigation; + if (this.props.publicDeriver) { + const walletType = getWalletType(this.props.publicDeriver) + const newWalletsNavigation = { + ...walletsNavigation, + // $FlowFixMe + [walletType]: walletsNavigation[walletType].filter( + walletId => walletId !== selectedWalletId + ), + quickAccess: walletsNavigation.quickAccess.filter(walletId => walletId !== selectedWalletId) + } + await this.generated.actions.profile.updateSortedWalletList.trigger(newWalletsNavigation); + } this.props.publicDeriver && settingsActions.removeWallet.trigger({ @@ -140,7 +145,7 @@ class RemoveWalletDialogContainer extends Component { actions: {| profile: {| updateSortedWalletList: {| - trigger: ({| sortedWallets: Array |}) => Promise, + trigger: WalletsNavigation => Promise, |}, |}, dialogs: {| @@ -158,7 +163,7 @@ class RemoveWalletDialogContainer extends Component { |}, stores: {| profile: {| - currentSortedWallets: ?Array, + walletsNavigation: WalletsNavigation, |}, walletSettings: {| removeWalletRequest: {| @@ -187,7 +192,7 @@ class RemoveWalletDialogContainer extends Component { publicDerivers: stores.wallets.publicDerivers, }, profile: { - currentSortedWallets: stores.profile.currentSortedWallets, + walletsNavigation: stores.profile.walletsNavigation, }, walletSettings: { removeWalletRequest: { diff --git a/packages/yoroi-extension/app/ergo-connector/api/index.js b/packages/yoroi-extension/app/ergo-connector/api/index.js index 64bab13b6d..4b3a562cd8 100644 --- a/packages/yoroi-extension/app/ergo-connector/api/index.js +++ b/packages/yoroi-extension/app/ergo-connector/api/index.js @@ -43,11 +43,11 @@ export const createAuthEntry: ({| stakingKey.to_public().hash() ) ).to_address(); - const entropy = await cip8Sign( + const entropy = (await cip8Sign( Buffer.from(address.to_bytes()), derivedSignKey, Buffer.from(`DAPP_LOGIN: ${appAuthID}`, 'utf8'), - ); + )).signature(); const appPrivKey = RustModule.WalletV4.Bip32PrivateKey.from_bip39_entropy( entropy, @@ -72,12 +72,11 @@ export const authSignHexPayload: ({| return appPrivKey.sign(Buffer.from(payloadHex, 'hex')).to_hex(); } -// return the hex string representation of the COSESign1 -const cip8Sign = async ( +export const cip8Sign = async ( address: Buffer, signKey: RustModule.WalletV4.PrivateKey, payload: Buffer, -): Promise => { +): Promise => { const protectedHeader = RustModule.MessageSigning.HeaderMap.new(); protectedHeader.set_algorithm_id( RustModule.MessageSigning.Label.from_algorithm_id( @@ -94,6 +93,5 @@ const cip8Sign = async ( const builder = RustModule.MessageSigning.COSESign1Builder.new(headers, payload, false); const toSign = builder.make_data_to_sign().to_bytes(); const signedSigStruct = signKey.sign(toSign).to_bytes(); - const coseSign1 = builder.build(signedSigStruct); - return Buffer.from(coseSign1.signature()); + return builder.build(signedSigStruct); } diff --git a/packages/yoroi-extension/app/ergo-connector/components/signin/CardanoSignTxPage.js b/packages/yoroi-extension/app/ergo-connector/components/signin/CardanoSignTxPage.js index 7c0960c335..954735d8b2 100644 --- a/packages/yoroi-extension/app/ergo-connector/components/signin/CardanoSignTxPage.js +++ b/packages/yoroi-extension/app/ergo-connector/components/signin/CardanoSignTxPage.js @@ -49,7 +49,7 @@ import { LoadingButton } from '@mui/lab'; import NoDappIcon from '../../../assets/images/dapp-connector/no-dapp.inline.svg'; type Props = {| - +txData: CardanoConnectorSignRequest, + +txData: ?CardanoConnectorSignRequest, +onCopyAddressTooltip: (string, string) => void, +onCancel: () => void, +onConfirm: string => Promise, @@ -66,6 +66,7 @@ type Props = {| +connectedWebsite: ?WhitelistEntry, +isReorg: boolean, +submissionError: ?SignSubmissionErrorType, + +signData: ?{| address: string, payload: string |}, |}; const messages = defineMessages({ @@ -298,6 +299,14 @@ class SignTxPage extends Component { ); }; + renderPayload(payloadHex: string): string { + const utf8 = Buffer.from(payloadHex, 'hex').toString('utf8'); + if (utf8.match(/^[\P{C}\t\r\n]+$/u)) { + return utf8; + } + return payloadHex; + } + render(): Node { const { form } = this; const walletPasswordField = form.$('walletPassword'); @@ -309,6 +318,7 @@ class SignTxPage extends Component { connectedWebsite, isReorg, submissionError, + signData, } = this.props; const { isSubmitting } = this.state; @@ -316,10 +326,111 @@ class SignTxPage extends Component { const url = connectedWebsite?.url ?? ''; const faviconUrl = connectedWebsite?.image ?? ''; - const txAmountDefaultToken = txData.amount.defaults.defaultIdentifier; - const txAmount = txData.amount.get(txAmountDefaultToken) ?? new BigNumber('0'); - const txFeeAmount = new BigNumber(txData.fee.amount).negated(); - const txTotalAmount = txAmount.plus(txFeeAmount); + let content; + let utxosContent; + if (txData) { + // signing a tx + const txAmountDefaultToken = txData.amount.defaults.defaultIdentifier; + const txAmount = txData.amount.get(txAmountDefaultToken) ?? new BigNumber('0'); + const txFeeAmount = new BigNumber(txData.fee.amount).negated(); + const txTotalAmount = txAmount.plus(txFeeAmount); + content = ( + + + {intl.formatMessage(signTxMessages.totals)} + + + + {intl.formatMessage(signTxMessages.transactionFee)} + + {this.renderAmountDisplay({ + entry: { + identifier: txData.fee.tokenId, + networkId: txData.fee.networkId, + amount: txFeeAmount, + }, + })} + + + + {intl.formatMessage(signTxMessages.totalAmount)} + + {this.renderAmountDisplay({ + entry: { + identifier: txAmountDefaultToken, + networkId: txData.amount.defaults.defaultNetworkId, + amount: txTotalAmount, + }, + })} + + + + + ); + utxosContent = ( + + + + ); + } else if (signData) { + // signing data + content = ( + + + {intl.formatMessage(signTxMessages.signMessage)} + + +
+              {this.renderPayload(signData.payload)}
+            
+
+
+ ); + utxosContent = null; + } else { + return null; + } + return ( { getTokenInfo={this.props.getTokenInfo} /> - - - {intl.formatMessage(signTxMessages.totals)} - - - - {intl.formatMessage(signTxMessages.transactionFee)} - - {this.renderAmountDisplay({ - entry: { - identifier: txData.fee.tokenId, - networkId: txData.fee.networkId, - amount: txFeeAmount, - }, - })} - - - - {intl.formatMessage(signTxMessages.totalAmount)} - - {this.renderAmountDisplay({ - entry: { - identifier: txAmountDefaultToken, - networkId: txData.amount.defaults.defaultNetworkId, - amount: txTotalAmount, - }, - })} - - - - - + {content} { } - utxoAddressContent={ - - - - } + utxoAddressContent={utxosContent} /> ); } diff --git a/packages/yoroi-extension/app/ergo-connector/components/signin/SignTxPage.js b/packages/yoroi-extension/app/ergo-connector/components/signin/SignTxPage.js index fae6d6fdc7..5b4df6c9b2 100644 --- a/packages/yoroi-extension/app/ergo-connector/components/signin/SignTxPage.js +++ b/packages/yoroi-extension/app/ergo-connector/components/signin/SignTxPage.js @@ -93,6 +93,10 @@ export const signTxMessages: Object = defineMessages({ id: 'api.errors.IncorrectPasswordError', defaultMessage: '!!!Incorrect wallet password.', }, + signMessage: { + id: 'connector.signin.signMessage', + defaultMessage: '!!!Sign Message', + }, }); type State = {| diff --git a/packages/yoroi-extension/app/ergo-connector/containers/SignTxContainer.js b/packages/yoroi-extension/app/ergo-connector/containers/SignTxContainer.js index 31609ef7d0..47b98b605f 100644 --- a/packages/yoroi-extension/app/ergo-connector/containers/SignTxContainer.js +++ b/packages/yoroi-extension/app/ergo-connector/containers/SignTxContainer.js @@ -153,10 +153,14 @@ export default class SignTxContainer extends Component< ); break; } - case 'tx/cardano': - case 'tx-reorg/cardano': { + case 'tx-reorg/cardano': + case 'data': + case 'tx/cardano': { const txData = this.generated.stores.connector.adaTransaction; - if (txData == null) return this.renderLoading(); + const signData = signingMessage.sign.type === 'data' + ? { address: signingMessage.sign.address, payload: signingMessage.sign.payload } + : null; + if (txData == null && signData == null) return this.renderLoading(); component = ( ); break; diff --git a/packages/yoroi-extension/app/ergo-connector/stores/ConnectorStore.js b/packages/yoroi-extension/app/ergo-connector/stores/ConnectorStore.js index ed47db161d..66554c8736 100644 --- a/packages/yoroi-extension/app/ergo-connector/stores/ConnectorStore.js +++ b/packages/yoroi-extension/app/ergo-connector/stores/ConnectorStore.js @@ -63,6 +63,7 @@ import { } from '../../../chrome/extension/ergo-connector/api'; import { getWalletChecksum } from '../../api/export/utils'; import { WalletTypeOption } from '../../api/ada/lib/storage/models/ConceptualWallet/interfaces'; +import { loadSubmittedTransactions } from '../../api/localStorage'; import { signTransaction as shelleySignTransaction } from '../../api/ada/transactions/shelley/transactions'; @@ -327,9 +328,6 @@ export default class ConnectorStore extends Store { throw new Error(`${nameof(this._confirmSignInTx)} confirming a tx but no signing message set`); } const { signingMessage } = this; - if (signingMessage.sign.tx == null) { - throw new Error(`${nameof(this._confirmSignInTx)} signing non-tx is not supported`); - } const wallet = this.wallets.find(w => w.publicDeriver.getPublicDeriverId() === this.signingMessage?.publicDeriverId ); @@ -401,6 +399,14 @@ export default class ConnectorStore extends Store { tabId: signingMessage.tabId, pw: password, }; + } else if (signingMessage.sign.type === 'data') { + sendData = { + type: 'sign_confirmed', + tx: null, + uid: signingMessage.sign.uid, + tabId: signingMessage.tabId, + pw: password, + }; } else { throw new Error(`unkown sign data type ${signingMessage.sign.type}`); } @@ -562,7 +568,13 @@ export default class ConnectorStore extends Store { if (!response.utxos) { throw new Error('Missgin utxos for signing tx') } - const addressedUtxos = asAddressedUtxo(response.utxos); + + const submittedTxs = loadSubmittedTransactions() || []; + const addressedUtxos = await this.api.ada.addressedUtxosWithSubmittedTxs( + asAddressedUtxo(response.utxos), + selectedWallet.publicDeriver, + submittedTxs, + ); const defaultToken = this.stores.tokenInfoStore.getDefaultTokenInfo( network.NetworkId @@ -586,10 +598,28 @@ export default class ConnectorStore extends Store { const inputs = []; const foreignInputs = []; + const allUsedUtxoIdsSet = new Set( + submittedTxs.flatMap(({ usedUtxos }) => + usedUtxos.map(({ txHash, index }) => `${txHash}${index}`)) + ); + for (let i = 0; i < txBody.inputs().len(); i++) { const input = txBody.inputs().get(i); const txHash = Buffer.from(input.transaction_id().to_bytes()).toString('hex'); const txIndex = input.index(); + if (allUsedUtxoIdsSet.has(`${txHash}${txIndex}`)) { + window.chrome.runtime.sendMessage( + { + type: 'sign_error', + errorType: 'spent_utxo', + data: `${txHash}${txIndex}`, + uid: signingMessage.sign.uid, + tabId: signingMessage.tabId, + } + ); + this._closeWindow(); + return; + } // eslint-disable-next-line camelcase const utxo = addressedUtxos.find(({ tx_hash, tx_index }) => // eslint-disable-next-line camelcase @@ -641,8 +671,56 @@ export default class ConnectorStore extends Store { ownAddresses, ); + if (foreignInputs.length) { + const foreignUtxos = await this.stores.substores.ada.stateFetchStore.fetcher.getUtxoData( + { + network: selectedWallet.publicDeriver.getParent().networkInfo, + utxos: foreignInputs, + } + ) + for (let i = 0; i < foreignUtxos.length; i++) { + const foreignUtxo = foreignUtxos[i]; + if (foreignUtxo === null) { + window.chrome.runtime.sendMessage( + { + type: 'sign_error', + errorType: 'missing_utxo', + data: `${foreignInputs[i].txHash}${foreignInputs[i].txIndex}`, + uid: signingMessage.sign.uid, + tabId: signingMessage.tabId, + } + ); + this._closeWindow(); + return; + } + if (foreignUtxo.spendingTxHash !== null) { + window.chrome.runtime.sendMessage( + { + type: 'sign_error', + errorType: 'spent_utxo', + data: `${foreignInputs[i].txHash}${foreignInputs[i].txIndex}`, + uid: signingMessage.sign.uid, + tabId: signingMessage.tabId, + } + ); + this._closeWindow(); + return; + } + const value = multiTokenFromRemote( + foreignUtxo.output, + defaultToken.NetworkId + ); + inputs.push({ + address: Buffer.from(RustModule.WalletV4.Address.from_bech32( + foreignUtxo.output.address + ).to_bytes()).toString('hex'), + value, + }); + amount.joinAddMutable(value); + } + } + runInAction(() => { - // // $FlowFixMe[prop-missing] this.adaTransaction = { inputs, foreignInputs, outputs, fee, total, amount }; }); @@ -681,6 +759,7 @@ export default class ConnectorStore extends Store { publicDeriver: withHasUtxoChains, absSlotNumber, cardanoTxRequest: (signingMessage.sign: any).tx, + submittedTxs: [], utxos: [], }); const fee = { @@ -721,12 +800,14 @@ export default class ConnectorStore extends Store { throw new Error('unexpected signing data type'); } const { usedUtxoIds, reorgTargetAmount, utxos } = signingMessage.sign.tx; + const submittedTxs = loadSubmittedTransactions() || []; const { unsignedTx, collateralOutputAddressSet } = await connectorGenerateReorgTx( selectedWallet.publicDeriver, usedUtxoIds, reorgTargetAmount, asAddressedUtxo(utxos), + submittedTxs, ); // record the unsigned tx, so that after the user's approval, we can sign // it without re-generating diff --git a/packages/yoroi-extension/app/i18n/locales/en-US.json b/packages/yoroi-extension/app/i18n/locales/en-US.json index d7cbe2d2f6..14f0c9ac81 100644 --- a/packages/yoroi-extension/app/i18n/locales/en-US.json +++ b/packages/yoroi-extension/app/i18n/locales/en-US.json @@ -28,6 +28,7 @@ "api.errors.getTxHistoryForAddressesApiError": "Error received from server while getting transactions.", "api.errors.getTxsBodiesForUTXOsApiError": "Error received from server while getting TxBodies.", "api.errors.getTxsBodiesForUTXOsError": "Error received from api method call while getting TxBodies.", + "api.errors.getUtxoDataError": "Error received from server while getting UTXO data", "api.errors.getUtxosForAddressesApiError": "Error received from server while getting UTxOs.", "api.errors.getUtxosSumsForAddressesApiError": "Error received from server while getting balance.", "api.errors.hardwareUnsupportedError": "This action is not supported for the currently selected hardware.", @@ -72,6 +73,7 @@ "connector.signin.connectedTo": "Connected To", "connector.signin.more": "more", "connector.signin.receiver": "Receiver", + "connector.signin.signMessage": "Sign Message", "connector.signin.title": "Sign transaction", "connector.signin.totalAmount": "Total Amount", "connector.signin.totals": "Totals", @@ -805,6 +807,9 @@ "wallet.syncingOverlay.title": "Wallet Syncing", "wallet.topbar.dialog.tokenTypes": "Token types", "wallet.topbar.dialog.totalBalance": "Total Balance", + "wallet.topbar.dialog.ergo": "Ergo, ERG", + "wallet.topbar.dialog.cardano": "Cardano, ADA", + "wallet.topbar.dialog.quickAccess": "Add to quick acceess wallets list", "wallet.transaction.address.from": "From address", "wallet.transaction.address.to": "To address", "wallet.transaction.address.type": "Address Type", diff --git a/packages/yoroi-extension/app/stores/ada/AdaTransactionsStore.js b/packages/yoroi-extension/app/stores/ada/AdaTransactionsStore.js index a4d9c95e2d..0cd7a30408 100644 --- a/packages/yoroi-extension/app/stores/ada/AdaTransactionsStore.js +++ b/packages/yoroi-extension/app/stores/ada/AdaTransactionsStore.js @@ -50,7 +50,7 @@ export default class AdaTransactionsStore extends Store { const defaultToken = this.stores.tokenInfoStore.getDefaultTokenInfo( defaultNetworkId, ); - const transaction = await this.api.ada.createSubmittedTransactionData( + const { usedUtxos, transaction } = await this.api.ada.createSubmittedTransactionData( publicDeriver, signRequest, txId, @@ -61,6 +61,7 @@ export default class AdaTransactionsStore extends Store { this.stores.transactions.recordSubmittedTransaction( publicDeriver, transaction, + usedUtxos, ); } } diff --git a/packages/yoroi-extension/app/stores/ergo/ErgoTransactionsStore.js b/packages/yoroi-extension/app/stores/ergo/ErgoTransactionsStore.js index 7c5cf876af..0c1b1ef4ea 100644 --- a/packages/yoroi-extension/app/stores/ergo/ErgoTransactionsStore.js +++ b/packages/yoroi-extension/app/stores/ergo/ErgoTransactionsStore.js @@ -50,6 +50,7 @@ export default class ErgoTransactionsStore extends Store this.stores.transactions.recordSubmittedTransaction( publicDeriver, transaction, + [], ); } } diff --git a/packages/yoroi-extension/app/stores/toplevel/ProfileStore.js b/packages/yoroi-extension/app/stores/toplevel/ProfileStore.js index 3875f7c184..3a7a380a4a 100644 --- a/packages/yoroi-extension/app/stores/toplevel/ProfileStore.js +++ b/packages/yoroi-extension/app/stores/toplevel/ProfileStore.js @@ -9,6 +9,7 @@ import type { NetworkRow } from '../../api/ada/lib/storage/database/primitives/t import type { ActionsMap } from '../../actions/index'; import type { StoresMap } from '../index'; import { ComplexityLevels } from '../../types/complexityLevelType'; +import type { WalletsNavigation } from '../../api/localStorage' export default class ProfileStore extends BaseProfileStore { @observable __selectedNetwork: void | $ReadOnly = undefined; @@ -133,14 +134,15 @@ export default class ProfileStore extends BaseProfileStore Promise >(this.api.localStorage.setToggleSidebar); - @observable getSortedWalletsRequest: Request<(void) => Promise>> = new Request< - (void) => Promise> - >(this.api.localStorage.getSortedWallets); + @observable getWalletsNavigationRequest: + Request<(void) => Promise> = new Request< + (void) => Promise + >(this.api.localStorage.getWalletsNavigation); - @observable setSortedWalletsRequest: Request< - ({| sortedWallets: Array |}) => Promise - > = new Request<({| sortedWallets: Array |}) => Promise>( - ({ sortedWallets }) => this.api.localStorage.setSortedWallets(sortedWallets) + @observable setWalletsNavigationRequest: Request< + WalletsNavigation => Promise + > = new Request Promise>( + (walletsNavigation) => this.api.localStorage.setWalletsNavigation(walletsNavigation) ); setup(): void { @@ -246,21 +248,21 @@ export default class ProfileStore extends BaseProfileStore { - let { result } = this.getSortedWalletsRequest; + @computed get walletsNavigation(): WalletsNavigation { + let { result } = this.getWalletsNavigationRequest; if (result == null) { - result = this.getSortedWalletsRequest.execute().result; + result = this.getWalletsNavigationRequest.execute().result; } - return result ?? []; + return result ?? { ergo: [], cardano: [], quickAccess: [] }; } _getSortedWalletList: void => Promise = async () => { - await this.getSortedWalletsRequest.execute(); + await this.getWalletsNavigationRequest.execute(); }; - _updateSortedWalletList: ({| sortedWallets: Array |}) => Promise = async ({ - sortedWallets, - }) => { - await this.setSortedWalletsRequest.execute({ sortedWallets }); - await this.getSortedWalletsRequest.execute(); + + _updateSortedWalletList: WalletsNavigation => Promise + = async (walletsNavigation) => { + await this.setWalletsNavigationRequest.execute(walletsNavigation); + await this.getWalletsNavigationRequest.execute(); }; } diff --git a/packages/yoroi-extension/app/stores/toplevel/TransactionsStore.js b/packages/yoroi-extension/app/stores/toplevel/TransactionsStore.js index 5dcc37254b..6048db8493 100644 --- a/packages/yoroi-extension/app/stores/toplevel/TransactionsStore.js +++ b/packages/yoroi-extension/app/stores/toplevel/TransactionsStore.js @@ -100,6 +100,7 @@ type SubmittedTransactionEntry = {| networkId: number, publicDeriverId: number, transaction: WalletTransaction, + usedUtxos: Array<{| txHash: string, index: number |}>, |}; function getCoinsPerUtxoWord(network: $ReadOnly): RustModule.WalletV4.BigNum { @@ -813,14 +814,17 @@ export default class TransactionsStore extends Store { recordSubmittedTransaction: ( PublicDeriver<>, WalletTransaction, + Array<{| txHash: string, index: number |}>, ) => void = ( publicDeriver, transaction, + usedUtxos, ) => { this._submittedTransactions.push({ publicDeriverId: publicDeriver.publicDeriverId, networkId: publicDeriver.getParent().getNetworkInfo().NetworkId, transaction, + usedUtxos, }); this._persistSubmittedTransactions(); } diff --git a/packages/yoroi-extension/app/stores/toplevel/WalletSettingsStore.js b/packages/yoroi-extension/app/stores/toplevel/WalletSettingsStore.js index 374148af9d..4cd446fee8 100644 --- a/packages/yoroi-extension/app/stores/toplevel/WalletSettingsStore.js +++ b/packages/yoroi-extension/app/stores/toplevel/WalletSettingsStore.js @@ -30,6 +30,7 @@ import { import type { ActionsMap } from '../../actions/index'; import type { StoresMap } from '../index'; import { removeWalletFromLS } from '../../utils/localStorage'; +import { isErgo } from '../../api/ada/lib/storage/database/prepackaged/networks'; export type PublicDeriverSettingsCache = {| publicDeriver: PublicDeriver<>, @@ -49,6 +50,10 @@ export type WarningList = {| dialogs: Array Node>, |}; +export const getWalletType: PublicDeriver<> => 'ergo' | 'cardano' = (publicDeriver) => { + return isErgo(publicDeriver.getParent().getNetworkInfo()) ? 'ergo': 'cardano' +} + export default class WalletSettingsStore extends Store { @observable renameModelRequest: Request @@ -272,10 +277,16 @@ export default class WalletSettingsStore extends Store { await removeWalletFromLS(request.publicDeriver) // remove this wallet from wallet sort list - const sortedWallets = this.stores.profile.currentSortedWallets.filter( - id => id !== request.publicDeriver.publicDeriverId - ); - this.actions.profile.updateSortedWalletList.trigger({ sortedWallets }); + const walletType = getWalletType(request.publicDeriver) + const walletsNavigation = this.stores.profile.walletsNavigation + const newWalletsNavigation = { + ...walletsNavigation, + // $FlowFixMe + [walletType]: walletsNavigation[walletType].filter( + walletId => walletId !== request.publicDeriver.publicDeriverId) + } + + await this.actions.profile.updateSortedWalletList.trigger(newWalletsNavigation); await this.removeWalletRequest.execute({ publicDeriver: request.publicDeriver, diff --git a/packages/yoroi-extension/app/utils/walletInfo.js b/packages/yoroi-extension/app/utils/walletInfo.js new file mode 100644 index 0000000000..0c27c9564e --- /dev/null +++ b/packages/yoroi-extension/app/utils/walletInfo.js @@ -0,0 +1,42 @@ +// @flow + +import { isCardanoHaskell } from '../api/ada/lib/storage/database/prepackaged/networks'; +import { Bip44Wallet } from '../api/ada/lib/storage/models/Bip44Wallet/wrapper'; +import { isLedgerNanoWallet, isTrezorTWallet } from '../api/ada/lib/storage/models/ConceptualWallet'; +import globalMessages from '../i18n/global-messages'; +import ConceptualIcon from '../assets/images/wallet-nav/conceptual-wallet.inline.svg'; +import TrezorIcon from '../assets/images/wallet-nav/trezor-wallet.inline.svg'; +import LedgerIcon from '../assets/images/wallet-nav/ledger-wallet.inline.svg'; +import type { $npm$ReactIntl$MessageDescriptor } from 'react-intl'; +import type { ConceptualWallet } from '../api/ada/lib/storage/models/ConceptualWallet/index'; + +export const getEra: + ConceptualWallet => void | $Exact<$npm$ReactIntl$MessageDescriptor> = wallet => { + if (!isCardanoHaskell(wallet.getNetworkInfo())) { + return undefined; + } + if (wallet instanceof Bip44Wallet) { + return globalMessages.byronLabel; + } + return globalMessages.shelleyLabel; +}; + +export const getType: ConceptualWallet => $Exact<$npm$ReactIntl$MessageDescriptor> = wallet => { + if (isLedgerNanoWallet(wallet)) { + return globalMessages.ledgerWallet; + } + if (isTrezorTWallet(wallet)) { + return globalMessages.trezorWallet; + } + return globalMessages.standardWallet; +}; + +export const getIcon: ConceptualWallet => string = wallet => { + if (isLedgerNanoWallet(wallet)) { + return LedgerIcon; + } + if (isTrezorTWallet(wallet)) { + return TrezorIcon; + } + return ConceptualIcon; +}; \ No newline at end of file diff --git a/packages/yoroi-extension/chrome/extension/background.js b/packages/yoroi-extension/chrome/extension/background.js index 4d46cb89c5..0c9a417139 100644 --- a/packages/yoroi-extension/chrome/extension/background.js +++ b/packages/yoroi-extension/chrome/extension/background.js @@ -33,6 +33,7 @@ import { asTx, asValue, ConnectorError, + DataSignErrorCodes, } from './ergo-connector/types'; import { connectorCreateCardanoTx, @@ -51,6 +52,8 @@ import { connectorSendTxCardano, connectorSignCardanoTx, connectorSignTx, + getAddressing, + connectorSignData, } from './ergo-connector/api'; import { updateTransactions as ergoUpdateTransactions } from '../../app/api/ergo/lib/storage/bridge/updateTransactions'; import { @@ -63,7 +66,9 @@ import { RemoteFetcher as ErgoRemoteFetcher } from '../../app/api/ergo/lib/state import { RemoteFetcher as CardanoRemoteFetcher } from '../../app/api/ada/lib/state-fetch/remoteFetcher'; import { BatchedFetcher as ErgoBatchedFetcher } from '../../app/api/ergo/lib/state-fetch/batchedFetcher'; import { BatchedFetcher as CardanoBatchedFetcher } from '../../app/api/ada/lib/state-fetch/batchedFetcher'; -import LocalStorageApi from '../../app/api/localStorage/index'; +import LocalStorageApi, { + loadSubmittedTransactions, +} from '../../app/api/localStorage/index'; import { RustModule } from '../../app/api/ada/lib/cardanoCrypto/rustLoader'; import { Logger, stringifyError } from '../../app/utils/logging'; import type { lf$Database, } from 'lovefield'; @@ -525,8 +530,9 @@ const yoroiMessageHandler = async ( } break; case 'data': - // mocked data sign - responseData.resolve({ err: 'Generic data signing is not implemented yet' }); + { + responseData.resolve({ ok: { password } }); + } break; case 'tx-reorg/cardano': { @@ -554,6 +560,21 @@ const yoroiMessageHandler = async ( // eslint-disable-next-line no-console console.error(`couldn't find tabId: ${request.tabId} in ${JSON.stringify(connectedSites.entries())}`); } + } else if (request.type === 'sign_error') { + const connection = connectedSites.get(request.tabId); + const responseData = connection?.pendingSigns.get(request.uid); + if (connection && responseData) { + responseData.resolve({ + err: { + code: 3, + info: `utxo error: ${request.errorType} (${request.data})` + } + }); + connection.pendingSigns.delete(request.uid); + } else { + // eslint-disable-next-line no-console + console.error(`couldn't find tabId: ${request.tabId} in ${JSON.stringify(connectedSites.entries())}`); + } } else if (request.type === 'tx_sign_window_retrieve_data') { for (const [tabId, connection] of connectedSites) { for (const [/* uid */, responseData] of connection.pendingSigns.entries()) { @@ -1042,18 +1063,86 @@ function handleInjectorConnect(port) { handleError(e); } break; - // unsupported until EIP-0012's definition is finalized - // case 'sign_data': - // { - // const resp = await confirmSign(tabId, { - // type: 'data', - // address: message.params[0], - // bytes: message.params[1], - // uid: message.uid - // }); - // rpcResponse(resp); - // } - // break; + case 'sign_data': + try { + const rawAddress = message.params[0]; + const payload = message.params[1]; + await withDb(async (db, localStorageApi) => { + await withSelectedWallet( + tabId, + async (wallet) => { + if (isCardano) { + await RustModule.load(); + const connection = connectedSites.get(tabId); + if (connection == null) { + Logger.error(`ERR - sign_data could not find connection with tabId = ${tabId}`); + rpcResponse(undefined); // shouldn't happen + return; + } + let address; + try { + address = Buffer.from( + RustModule.WalletV4.Address.from_bech32(rawAddress).to_bytes() + ).toString('hex'); + } catch { + address = rawAddress; + } + const addressing = await getAddressing(wallet, address); + if (!addressing) { + rpcResponse({ + err: { + code: DataSignErrorCodes.DATA_SIGN_ADDRESS_NOT_PK, + info: 'address not found', + } + }); + return; + } + const resp = await confirmSign( + tabId, + { + type: 'data', + address, + payload, + uid: message.uid + }, + connection, + ); + if (!resp.ok) { + rpcResponse(resp); + return; + } + let dataSig; + try { + dataSig = await connectorSignData( + wallet, + resp.ok.password, + addressing, + address, + payload, + ); + } catch (error) { + Logger.error(`error when signing data ${error}`); + rpcResponse({ + err: { + code: DataSignErrorCodes.DATA_SIGN_PROOF_GENERATION, + info: error.message, + } + }); + return; + } + rpcResponse({ ok: dataSig }); + } else { + rpcResponse({ err: 'not implemented' }); + } + }, + db, + localStorageApi, + ) + }); + } catch (e) { + handleError(e); + } + break; case 'get_balance': try { checkParamCount(1); @@ -1410,7 +1499,7 @@ function handleInjectorConnect(port) { } const walletUtxos = await withUtxos.getAllUtxos(); const addressedUtxos = asAddressedUtxoCardano(walletUtxos); - + const submittedTxs = loadSubmittedTransactions() || []; const { utxosToUse, reorgTargetAmount @@ -1423,6 +1512,7 @@ function handleInjectorConnect(port) { const { addressing, ...rest } = u; return rest; }), + submittedTxs, ); // do have enough if (reorgTargetAmount == null) { @@ -1447,6 +1537,7 @@ function handleInjectorConnect(port) { usedUtxoIds, reorgTargetAmount, addressedUtxos, + submittedTxs, ); } catch (error) { if (error instanceof NotEnoughMoneyToSendError) { diff --git a/packages/yoroi-extension/chrome/extension/ergo-connector/api.js b/packages/yoroi-extension/chrome/extension/ergo-connector/api.js index b647d18591..da06edc132 100644 --- a/packages/yoroi-extension/chrome/extension/ergo-connector/api.js +++ b/packages/yoroi-extension/chrome/extension/ergo-connector/api.js @@ -15,8 +15,9 @@ import type { import { ConnectorError, TxSendErrorCodes } from './types'; import { RustModule } from '../../../app/api/ada/lib/cardanoCrypto/rustLoader'; import type { + Addressing, IGetAllUtxosResponse, - IPublicDeriver + IPublicDeriver, } from '../../../app/api/ada/lib/storage/models/PublicDeriver/interfaces'; import { PublicDeriver, } from '../../../app/api/ada/lib/storage/models/PublicDeriver/index'; import { @@ -25,6 +26,7 @@ import { asGetSigningKey, asHasLevels, asHasUtxoChains, + asGetAllAccounting, } from '../../../app/api/ada/lib/storage/models/PublicDeriver/traits'; import { ConceptualWallet } from '../../../app/api/ada/lib/storage/models/ConceptualWallet/index'; import BigNumber from 'bignumber.js'; @@ -76,6 +78,9 @@ import type { } from '../../../app/api/ada/transactions/shelley/HaskellShelleyTxSignRequest'; import type { CardanoAddressedUtxo, } from '../../../app/api/ada/transactions/types'; import { coinSelectionForValues } from '../../../app/api/ada/transactions/shelley/coinSelection'; +import { derivePrivateByAddressing } from '../../../app/api/ada/lib/cardanoCrypto/utils'; +import { cip8Sign } from '../../../app/ergo-connector/api'; +import type { PersistedSubmittedTransaction } from '../../../app/api/localStorage'; function paginateResults(results: T[], paginate: ?Paginate): T[] { if (paginate != null) { @@ -242,8 +247,14 @@ export async function connectorGetUtxosCardano( utxo_id: utxo.utxo_id, assets: utxo.assets, }); + const submittedTxs = loadSubmittedTransactions() || []; + const adaApi = new AdaApi(); const formattedUtxos: Array = - asAddressedUtxoCardano(utxos).map(toRemoteUnspentOutput) + adaApi.utxosWithSubmittedTxs( + asAddressedUtxoCardano(utxos).map(toRemoteUnspentOutput), + wallet.publicDeriverId, + submittedTxs, + ); const valueStr = valueExpected?.trim() ?? ''; if (valueStr.length === 0) { return Promise.resolve(paginateResults(formattedUtxos, paginate)); @@ -279,12 +290,18 @@ export async function connectorGetCollateralUtxos( pendingTxs: PendingTransaction[], requiredAmount: Value, utxos: Array, + submittedTxs: Array, ): Promise { const required = new BigNumber(requiredAmount) if (required.gt(MAX_COLLATERAL)) { throw new Error('requested collateral amount is beyond the allowed limits') } - const utxosToConsider = utxos.filter( + const adaApi = new AdaApi(); + const utxosToConsider = (await adaApi.utxosWithSubmittedTxs( + utxos, + wallet.publicDeriverId, + submittedTxs, + )).filter( utxo => utxo.assets.length === 0 && new BigNumber(utxo.amount).lt(required.plus(MAX_PER_UTXO_SURPLUS)) ) @@ -390,15 +407,39 @@ async function getAllAddresses(wallet: PublicDeriver<>, usedFilter: boolean): Pr .then(arr => arr.map(a => a.base58)); } +function getOutputAddressesInSubmittedTxs(publicDeriverId: number) { + const submittedTxs = loadSubmittedTransactions() || []; + return submittedTxs + .filter(submittedTxRecord => submittedTxRecord.publicDeriverId === publicDeriverId) + .flatMap(({ transaction }) => { + return transaction.addresses.to.map(({ address }) => address); + }); +} + export async function connectorGetUsedAddresses( wallet: PublicDeriver<>, paginate: ?Paginate ): Promise { - return getAllAddresses(wallet, true).then(addresses => paginateResults(addresses, paginate)); + const usedAddresses = await getAllAddresses(wallet, true); + + const outputAddressesInSubmittedTxs = new Set( + getOutputAddressesInSubmittedTxs(wallet.publicDeriverId) + ); + const usedInSubmittedTxs = (await getAllAddresses(wallet, false)) + .filter(address => outputAddressesInSubmittedTxs.has(address)); + + return paginateResults( + [...usedAddresses, ...usedInSubmittedTxs], + paginate + ); } export async function connectorGetUnusedAddresses(wallet: PublicDeriver<>): Promise { - return getAllAddresses(wallet, false); + const result = await getAllAddresses(wallet, false); + const outputAddressesInSubmittedTxs = new Set( + getOutputAddressesInSubmittedTxs(wallet.publicDeriverId) + ); + return result.filter(address => !outputAddressesInSubmittedTxs.has(address)); } export async function connectorGetCardanoRewardAddresses( @@ -655,7 +696,7 @@ function getScriptRequiredSigningKeys( } export async function connectorSignCardanoTx( - publicDeriver: IPublicDeriver, + publicDeriver: PublicDeriver<>, password: string, tx: CardanoTx, ): Promise { @@ -745,7 +786,13 @@ export async function connectorSignCardanoTx( } } - const addressedUtxos = asAddressedUtxoCardano(utxos); + const submittedTxs = loadSubmittedTransactions() || []; + const adaApi = new AdaApi(); + const addressedUtxos = await adaApi.addressedUtxosWithSubmittedTxs( + asAddressedUtxoCardano(utxos), + publicDeriver, + submittedTxs + ); const withLevels = asHasLevels(publicDeriver); if (!withLevels) { @@ -788,7 +835,7 @@ export async function connectorSignCardanoTx( } export async function connectorCreateCardanoTx( - publicDeriver: IPublicDeriver, + publicDeriver: PublicDeriver<>, password: ?string, cardanoTxRequest: CardanoTxRequest, ): Promise { @@ -809,16 +856,19 @@ export async function connectorCreateCardanoTx( time: new Date(), }).slot); + const submittedTxs = loadSubmittedTransactions() || []; + const utxos = asAddressedUtxoCardano( await withUtxos.getAllUtxos() ); const adaApi = new AdaApi(); const signRequest = await adaApi.createUnsignedTxForConnector({ - publicDeriver: withHasUtxoChains, + publicDeriver, absSlotNumber, // $FlowFixMe[incompatible-exact] cardanoTxRequest, + submittedTxs, utxos, }); @@ -1056,6 +1106,7 @@ export async function connectorRecordSubmittedCardanoTransaction( (await withUtxos.getAllUtxoAddresses()) .flatMap(utxoAddr => utxoAddr.addrs.map(addr => addr.Hash)) ); + let utxos; if (addressedUtxos) { utxos = addressedUtxos; @@ -1064,6 +1115,13 @@ export async function connectorRecordSubmittedCardanoTransaction( await withUtxos.getAllUtxos() ); } + const submittedTxs = loadSubmittedTransactions() || []; + const adaApi = new AdaApi(); + utxos = await adaApi.addressedUtxosWithSubmittedTxs( + utxos, + publicDeriver, + submittedTxs, + ); const txId = Buffer.from( RustModule.WalletV4.hash_transaction(tx.body()).to_bytes() @@ -1080,6 +1138,7 @@ export async function connectorRecordSubmittedCardanoTransaction( let isIntraWallet = true; const txBody = tx.body(); const txInputs = txBody.inputs(); + const usedUtxos = []; for (let i = 0; i < txInputs.len(); i++) { const input = txInputs.get(i); const txHash = Buffer.from(input.transaction_id().to_bytes()).toString('hex'); @@ -1089,6 +1148,7 @@ export async function connectorRecordSubmittedCardanoTransaction( if (!utxo) { throw new Error('missing UTXO'); } + usedUtxos.push({ txHash, index }); const value = new MultiToken([], defaults); @@ -1175,17 +1235,15 @@ export async function connectorRecordSubmittedCardanoTransaction( isValid: true, }; - const submittedTxs = loadSubmittedTransactions() || []; submittedTxs.push({ publicDeriverId: publicDeriver.publicDeriverId, transaction: submittedTx, networkId: publicDeriver.getParent().getNetworkInfo().NetworkId, + usedUtxos, }); persistSubmittedTransactions(submittedTxs); } -// TODO: generic data sign - const REORG_OUTPUT_AMOUNT = '1000000'; export async function connectorGenerateReorgTx( @@ -1193,6 +1251,7 @@ export async function connectorGenerateReorgTx( usedUtxoIds: Array, reorgTargetAmount: string, utxos: Array, + submittedTxs: Array, ): Promise<{| unsignedTx: HaskellShelleyTxSignRequest, collateralOutputAddressSet: Set, @@ -1241,7 +1300,114 @@ export async function connectorGenerateReorgTx( cardanoTxRequest: { includeTargets, }, - utxos: utxos.filter(utxo => !dontUseUtxoIds.has(utxo.utxo_id)), + utxos: (await adaApi.addressedUtxosWithSubmittedTxs( + utxos, + publicDeriver, + submittedTxs, + )).filter(utxo => !dontUseUtxoIds.has(utxo.utxo_id)), + // we already factored in submitted transactions above, no need to handle it + // any more, so just use an empty array here + submittedTxs: [], }); return { unsignedTx, collateralOutputAddressSet }; } + +export async function getAddressing( + publicDeriver: PublicDeriver<>, + address: string, +): Promise { + const findAddressing = (addresses) => { + for (const { addrs, addressing } of addresses) { + for (const { Hash } of addrs) { + if (Hash === address) { + return { addressing }; + } + } + } + }; + + const withAccounting = asGetAllAccounting(publicDeriver); + if (!withAccounting) { + throw new Error('unable to get accounting addresses from public deriver'); + } + const rewardAddressing = findAddressing( + await withAccounting.getAllAccountingAddresses(), + ); + if (rewardAddressing) { + return rewardAddressing; + } + + const withUtxos = asGetAllUtxos(publicDeriver); + if (!withUtxos) { + throw new Error('unable to get UTxO addresses from public deriver'); + } + return findAddressing( + await withUtxos.getAllUtxoAddresses(), + ); +} + +export async function connectorSignData( + publicDeriver: PublicDeriver<>, + password: string, + addressing: Addressing, + address: string, + payload: string, +): Promise<{| signature: string, key: string |}> { + const withSigningKey = asGetSigningKey(publicDeriver); + if (!withSigningKey) { + throw new Error('unable to get signing key'); + } + const normalizedKey = await withSigningKey.normalizeKey({ + ...(await withSigningKey.getSigningKey()), + password, + }); + + const withLevels = asHasLevels(publicDeriver); + if (!withLevels) { + throw new Error('unable to get levels'); + } + + const signingKey = derivePrivateByAddressing({ + addressing: addressing.addressing, + startingFrom: { + key: RustModule.WalletV4.Bip32PrivateKey.from_bytes( + Buffer.from(normalizedKey.prvKeyHex, 'hex') + ), + level: withLevels.getParent().getPublicDeriverLevel(), + }, + }).to_raw_key(); + + const coseSign1 = await cip8Sign( + Buffer.from(address, 'hex'), + signingKey, + Buffer.from(payload, 'hex'), + ); + + const key = RustModule.MessageSigning.COSEKey.new( + RustModule.MessageSigning.Label.from_key_type(RustModule.MessageSigning.KeyType.OKP) + ); + key.set_algorithm_id( + RustModule.MessageSigning.Label.from_algorithm_id(RustModule.MessageSigning.AlgorithmId.EdDSA) + ); + key.set_header( + RustModule.MessageSigning.Label.new_int( + RustModule.MessageSigning.Int.new_negative(RustModule.MessageSigning.BigNum.from_str('1')) + ), + RustModule.MessageSigning.CBORValue.new_int( + RustModule.MessageSigning.Int.new_i32(6) + ) + ); + key.set_header( + RustModule.MessageSigning.Label.new_int( + RustModule.MessageSigning.Int.new_negative(RustModule.MessageSigning.BigNum.from_str('2')) + ), + RustModule.MessageSigning.CBORValue.new_bytes( + signingKey.to_public().as_bytes() + ) + ); + + return { + signature: Buffer.from(coseSign1.to_bytes()).toString('hex'), + key: Buffer.from(key.to_bytes()).toString('hex'), + }; +} diff --git a/packages/yoroi-extension/chrome/extension/ergo-connector/types.js b/packages/yoroi-extension/chrome/extension/ergo-connector/types.js index 18e6859c67..abc062c7ee 100644 --- a/packages/yoroi-extension/chrome/extension/ergo-connector/types.js +++ b/packages/yoroi-extension/chrome/extension/ergo-connector/types.js @@ -455,7 +455,7 @@ export type PendingSignData = {| type: 'data', uid: RpcUid, address: Address, - bytes: string + payload: string |} | {| type: 'tx/cardano', uid: RpcUid, @@ -476,7 +476,7 @@ export type PendingSignData = {| export type ConfirmedSignData = {| type: 'sign_confirmed', - tx: Tx | CardanoTx | CardanoTxRequest | Array, + tx: Tx | CardanoTx | CardanoTxRequest | Array | null, uid: RpcUid, tabId: number, pw: string, @@ -486,8 +486,13 @@ export type FailedSignData = {| type: 'sign_rejected', uid: RpcUid, tabId: number, +|} | {| + type: 'sign_error', + errorType: 'string', + data: string, + uid: RpcUid, + tabId: number, |} - export type ConnectResponseData = {| type: 'connect_response', accepted: true, diff --git a/packages/yoroi-extension/features/connector-anonymous-wallet-errors-checking.feature b/packages/yoroi-extension/features/connector-anonymous-wallet-errors-checking.feature new file mode 100644 index 0000000000..a95cad541c --- /dev/null +++ b/packages/yoroi-extension/features/connector-anonymous-wallet-errors-checking.feature @@ -0,0 +1,55 @@ +@dApp +Feature: dApp connector anonymous wallet errors checking + + Background: + Given I have opened the extension + And I have completed the basic setup + Then I should see the Create wallet screen + Given There is a Shelley wallet stored named shelley-simple-15 + Then Revamp. I switch to revamp version + Then I open the mock dApp tab + + @dApp-1003 + Scenario: dApp, anonymous wallet, connecting wallet, close pop-up (DAPP-1003) + And I request anonymous access to Yoroi + Then I should see the connector popup for connection + Then I close the dApp-connector pop-up window + And The user reject is received + + @dApp-1004 + Scenario: dApp, anonymous wallet, signing transaction, close pop-up (DAPP-1004) + And I request anonymous access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then The popup window should be closed + And The access request should succeed + Then I request signing the transaction: + | amount | toAddress | + | 3 | addr1q97xu8uvdgjpqum6sjv9vptzulkc53x7tk69vj2lynywxppq3e92djqml4tjxz2avcgem3u8z7r54yvysm20qasxx5gqyx8evw | + Then I should see the connector popup for signing + Then I close the dApp-connector pop-up window + And The user reject for signing is received + + @dApp-1005 + Scenario: dApp, anonymous wallet, disconnect wallet (DAPP-1005) + And I request anonymous access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then The popup window should be closed + And The access request should succeed + Then I disconnect the wallet shelley-simple-15 from the dApp localhost + And I receive the wallet disconnection message + + @dApp-1013 + Scenario: dApp, anonymous wallet, signing transaction, cancel signing (DAPP-1013) + And I request anonymous access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then The popup window should be closed + And The access request should succeed + Then I request signing the transaction: + | amount | toAddress | + | 3 | addr1q97xu8uvdgjpqum6sjv9vptzulkc53x7tk69vj2lynywxppq3e92djqml4tjxz2avcgem3u8z7r54yvysm20qasxx5gqyx8evw | + Then I should see the connector popup for signing + Then I cancel signing the transaction + And The user reject for signing is received \ No newline at end of file diff --git a/packages/yoroi-extension/features/connector-authorized-wallet-errors-checking.feature b/packages/yoroi-extension/features/connector-authorized-wallet-errors-checking.feature new file mode 100644 index 0000000000..bb305454f0 --- /dev/null +++ b/packages/yoroi-extension/features/connector-authorized-wallet-errors-checking.feature @@ -0,0 +1,74 @@ +@dApp +Feature: dApp connector errors checking + + Background: + Given I have opened the extension + And I have completed the basic setup + Then I should see the Create wallet screen + Given There is a Shelley wallet stored named shelley-simple-15 + Then Revamp. I switch to revamp version + Then I open the mock dApp tab + + @dApp-1006 + Scenario: dApp, authorised wallet, connecting wallet, wrong password -> correct password (DAPP-1006) + When I request access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + When I enter the spending password wrongPassword and click confirm + Then I see the error Incorrect wallet password + When I enter the spending password asdfasdfasdf and click confirm + Then The popup window should be closed + And The access request should succeed + + @dApp-1007 + Scenario: dApp, authorised wallet, connecting wallet, back to wallets and close pop-up (DAPP-1007) + When I request access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + When I enter the spending password wrongPassword and click confirm + Then I see the error Incorrect wallet password + When I click the back button (Connector pop-up window) + And I should see the wallet's list + Then I close the dApp-connector pop-up window + And The user reject is received + + @dApp-1008 + Scenario: dApp, authorised wallet, signing transaction, close pop-up (DAPP-1008) + When I request access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then I enter the spending password asdfasdfasdf and click confirm + Then The popup window should be closed + And The access request should succeed + Then I request signing the transaction: + | amount | toAddress | + | 3 | addr1q97xu8uvdgjpqum6sjv9vptzulkc53x7tk69vj2lynywxppq3e92djqml4tjxz2avcgem3u8z7r54yvysm20qasxx5gqyx8evw | + Then I should see the connector popup for signing + Then I close the dApp-connector pop-up window + And The user reject for signing is received + + @dApp-1009 + Scenario: dApp, authorised wallet, disconnect wallet (DAPP-1009) + When I request access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then I enter the spending password asdfasdfasdf and click confirm + Then The popup window should be closed + And The access request should succeed + Then I disconnect the wallet shelley-simple-15 from the dApp localhost + And I receive the wallet disconnection message + + @dApp-1014 + Scenario: dApp, authorised wallet, signing transaction, cancel signing (DAPP-1014) + When I request access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then I enter the spending password asdfasdfasdf and click confirm + Then The popup window should be closed + And The access request should succeed + Then I request signing the transaction: + | amount | toAddress | + | 3 | addr1q97xu8uvdgjpqum6sjv9vptzulkc53x7tk69vj2lynywxppq3e92djqml4tjxz2avcgem3u8z7r54yvysm20qasxx5gqyx8evw | + Then I should see the connector popup for signing + Then I cancel signing the transaction + And The user reject for signing is received \ No newline at end of file diff --git a/packages/yoroi-extension/features/connector-cardano.feature b/packages/yoroi-extension/features/connector-cardano.feature index 93683e1068..bf5ad1d6a7 100644 --- a/packages/yoroi-extension/features/connector-cardano.feature +++ b/packages/yoroi-extension/features/connector-cardano.feature @@ -7,28 +7,30 @@ Feature: dApp connector data signing Then I should see the Create wallet screen Given There is a Shelley wallet stored named shelley-simple-15 Then Revamp. I switch to revamp version - Then I open the mock dApp + Then I open the mock dApp tab -@dApp-1000 - Scenario: dApp can get balance (DAPP-1000) + @dApp-1000 + Scenario: dApp, anonymous wallet, can get balance (DAPP-1000) And I request anonymous access to Yoroi - Then I should see the connector popup + Then I should see the connector popup for connection And I select the only wallet named shelley-simple-15 with 5.5 balance Then The popup window should be closed And The access request should succeed + And The wallet shelley-simple-15 is connected to the website localhost Then The dApp should see balance 5500000 -@dApp-1001 - Scenario: dApp can sign Cardano transaction, anonymous wallet (DAPP-1001) + @dApp-1001 + Scenario: dApp, anonymous wallet, sign Cardano transaction (DAPP-1001) And I request anonymous access to Yoroi - Then I should see the connector popup + Then I should see the connector popup for connection And I select the only wallet named shelley-simple-15 with 5.5 balance Then The popup window should be closed And The access request should succeed + And The wallet shelley-simple-15 is connected to the website localhost Then I request signing the transaction: | amount | toAddress | | 3 | addr1q97xu8uvdgjpqum6sjv9vptzulkc53x7tk69vj2lynywxppq3e92djqml4tjxz2avcgem3u8z7r54yvysm20qasxx5gqyx8evw | - Then I should see the connector popup + Then I should see the connector popup for signing And I should see the transaction amount data: | amount | fee | | 3 | 0.168317 | @@ -39,18 +41,19 @@ Feature: dApp connector data signing Then The popup window should be closed Then The signing transaction API should return a10081825820cc9809944150c00f3913cd2b103e9b42fe6243fc36a76f9eb800692e2bda3f2e5840f601303c9cce7307e7aeac1b4c37f52758bf0ae8ba67dd1c1619d007aa4922a69e1516e1c4319d533ce4894ab16cd2de48a8c0e490e66470d9431fdee12ae207 -@dApp-1002 - Scenario: dApp can sign Cardano transaction, request auth (DAPP-1002) + @dApp-1002 + Scenario: dApp, authorised wallet, sign Cardano transaction (DAPP-1002) And I request access to Yoroi - Then I should see the connector popup + Then I should see the connector popup for connection And I select the only wallet named shelley-simple-15 with 5.5 balance Then I enter the spending password asdfasdfasdf and click confirm Then The popup window should be closed And The access request should succeed + And The wallet shelley-simple-15 is connected to the website localhost Then I request signing the transaction: | amount | toAddress | | 3 | addr1q97xu8uvdgjpqum6sjv9vptzulkc53x7tk69vj2lynywxppq3e92djqml4tjxz2avcgem3u8z7r54yvysm20qasxx5gqyx8evw | - Then I should see the connector popup + Then I should see the connector popup for signing And I should see the transaction amount data: | amount | fee | | 3 | 0.168317 | @@ -60,3 +63,30 @@ Feature: dApp connector data signing Then I enter the spending password asdfasdfasdf and click confirm Then The popup window should be closed Then The signing transaction API should return a10081825820cc9809944150c00f3913cd2b103e9b42fe6243fc36a76f9eb800692e2bda3f2e5840f601303c9cce7307e7aeac1b4c37f52758bf0ae8ba67dd1c1619d007aa4922a69e1516e1c4319d533ce4894ab16cd2de48a8c0e490e66470d9431fdee12ae207 + + @dApp-1011 + Scenario: dApp, anonymous wallet, connect and reload dApp page (DAPP-1011) + And I request anonymous access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then The popup window should be closed + And The access request should succeed + When I refresh the dApp page + Then I request anonymous access to Yoroi + And There is no the connector popup + And The access request should succeed + Then The dApp should see balance 5500000 + + @dApp-1012 + Scenario: dApp, authorised wallet, connect and reload dApp page (DAPP-1012) + And I request access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then I enter the spending password asdfasdfasdf and click confirm + Then The popup window should be closed + And The access request should succeed + When I refresh the dApp page + And I request access to Yoroi + And The access request should succeed + And There is no the connector popup + Then The dApp should see balance 5500000 \ No newline at end of file diff --git a/packages/yoroi-extension/features/connector-errors-checking.feature b/packages/yoroi-extension/features/connector-errors-checking.feature deleted file mode 100644 index efa159d195..0000000000 --- a/packages/yoroi-extension/features/connector-errors-checking.feature +++ /dev/null @@ -1,54 +0,0 @@ -@dApp -Feature: dApp connector errors checking - - Background: - Given I have opened the extension - And I have completed the basic setup - Then I should see the Create wallet screen - Given There is a Shelley wallet stored named shelley-simple-15 - Then Revamp. I switch to revamp version - Then I open the mock dApp - - @dApp-1003 - Scenario: dApp connecting wallet, wrong password -> correct password, request auth (DAPP-1003) - And I request access to Yoroi - Then I should see the connector popup - And I select the only wallet named shelley-simple-15 with 5.5 balance - When I enter the spending password wrongPassword and click confirm - Then I see the error Incorrect wallet password - When I enter the spending password asdfasdfasdf and click confirm - Then The popup window should be closed - And The access request should succeed - - @dApp-1004 - Scenario: dApp connecting wallet, back to wallets and cancel, request auth (DAPP-1004) - And I request access to Yoroi - Then I should see the connector popup - And I select the only wallet named shelley-simple-15 with 5.5 balance - When I enter the spending password wrongPassword and click confirm - Then I see the error Incorrect wallet password - When I click the back button (Connector pop-up window) - And I should see the wallet's list - Then I close the dApp-connector pop-up window - And The user reject is received - - @dApp-1005 - Scenario: dApp, disconnect wallet, anonymous wallet (DAPP-1005) - And I request anonymous access to Yoroi - Then I should see the connector popup - And I select the only wallet named shelley-simple-15 with 5.5 balance - Then The popup window should be closed - And The access request should succeed - Then I disconnect the wallet shelley-simple-15 from the dApp localhost - And I receive the wallet disconnection message - - @dApp-1006 - Scenario: dApp, disconnect wallet, authorised wallet (DAPP-1006) - And I request access to Yoroi - Then I should see the connector popup - And I select the only wallet named shelley-simple-15 with 5.5 balance - Then I enter the spending password asdfasdfasdf and click confirm - Then The popup window should be closed - And The access request should succeed - Then I disconnect the wallet shelley-simple-15 from the dApp localhost - And I receive the wallet disconnection message \ No newline at end of file diff --git a/packages/yoroi-extension/features/connector-no-wallets.feature b/packages/yoroi-extension/features/connector-no-wallets.feature new file mode 100644 index 0000000000..d364296013 --- /dev/null +++ b/packages/yoroi-extension/features/connector-no-wallets.feature @@ -0,0 +1,22 @@ +@dApp +Feature: dApp connector no wallets + Background: + Given I have opened the mock dApp + + @dApp-1010 + Scenario: dApp, no wallets, connecting wallet (DAPP-1010) + And I request anonymous access to Yoroi + Then I should see the connector popup for connection + And I should see "No Cardano wallets is found" message + When I press the "Create wallet" button (Connector pop-up window) + Then The pop-up is closed and the extension tab is opened + And I have completed the basic setup + Then I should see the Create wallet screen + Given There is a Shelley wallet stored named shelley-simple-15 + Then Revamp. I switch to revamp version + Given I switch back to the mock dApp + And I request anonymous access to Yoroi + Then I should see the connector popup for connection + And I select the only wallet named shelley-simple-15 with 5.5 balance + Then The popup window should be closed + And The access request should succeed \ No newline at end of file diff --git a/packages/yoroi-extension/features/mock-chain/mockCardanoImporter.js b/packages/yoroi-extension/features/mock-chain/mockCardanoImporter.js index 4dba85fb0f..ae2fff71d8 100644 --- a/packages/yoroi-extension/features/mock-chain/mockCardanoImporter.js +++ b/packages/yoroi-extension/features/mock-chain/mockCardanoImporter.js @@ -11,6 +11,7 @@ import type { RemoteAccountState, HistoryFunc, BestBlockFunc, + UtxoData, } from '../../app/api/ada/lib/state-fetch/types'; import { ShelleyCertificateTypes @@ -2271,6 +2272,51 @@ const getAccountState: AccountStateFunc = async (request) => { return result; }; +const mockScriptOutputs = [ + { + txHash: '156f481d054e1e2798ef3cae84c0e7902b6ec18641c571d54c913e489327ab2d', + txIndex: 0, + output: { + address: '31d7a345ebead42207d4321763c8172869843254c81d007dfa2a7ee279d7a345ebead42207d4321763c8172869843254c81d007dfa2a7ee279', + amount: '2000000', + dataHash: null, + assets: [], + }, + spendingTxHash: '4a3f86762383f1d228542d383ae7ac89cf75cf7ff84dec8148558ea92b0b92d0', + }, + { + txHash: 'e7db1f809fcc21d3dd108ced6218bf0f0cbb6a0f679f848ff1790b68d3a35872', + txIndex: 0, + output: { + address: 'addr1w9jur974vh5g5gygtef4lym426pygnfuqt75fhts3ql738sez7sqy', + amount: '1000000', + dataHash: null, + assets: [ + { + assetId: '3652a89686608c45ca5b7768f44a961fe0e3459e21db4ea61b713aa6.4465764578', + policyId: '3652a89686608c45ca5b7768f44a961fe0e3459e21db4ea61b713aa6', + name: '4465764578', + amount: '10' + } + ], + }, + spendingTxHash: null, + } +]; + +const getUtxoData = (txHash: string, txIndex: number): UtxoData | null => { + const output = mockScriptOutputs.find( + entry => entry.txHash === txHash && entry.txIndex === txIndex + ); + if (!output) { + return null; + } + return { + output: output.output, + spendingTxHash: output.spendingTxHash + }; +} + export default { utxoForAddresses, utxoSumForAddresses, @@ -2282,4 +2328,5 @@ export default { getPoolInfo, getRewardHistory, getAccountState, + getUtxoData, }; diff --git a/packages/yoroi-extension/features/mock-chain/mockCardanoServer.js b/packages/yoroi-extension/features/mock-chain/mockCardanoServer.js index 0d8c878e41..988aaedece 100644 --- a/packages/yoroi-extension/features/mock-chain/mockCardanoServer.js +++ b/packages/yoroi-extension/features/mock-chain/mockCardanoServer.js @@ -189,7 +189,10 @@ export function getMockServer( // $FlowFixMe[incompatible-call] res.send(` - + + + MockDApp + @@ -197,6 +200,19 @@ export function getMockServer( ); }); + server.get('/api/txs/io/:txHash/o/:txIndex', (req, res) => { + const result = mockImporter.getUtxoData( + req.params.txHash, + Number(req.params.txIndex) + ); + if (result) { + res.send(result); + return; + } + res.status(404); + res.send('Transaction not found'); + }); + installCoinPriceRequestHandlers(server); MockServer = server.listen(Ports.DevBackendServe, () => { diff --git a/packages/yoroi-extension/features/mock-dApp-webpage/index.js b/packages/yoroi-extension/features/mock-dApp-webpage/index.js index 0aeb623d35..5e08395fcb 100644 --- a/packages/yoroi-extension/features/mock-dApp-webpage/index.js +++ b/packages/yoroi-extension/features/mock-dApp-webpage/index.js @@ -32,13 +32,16 @@ type Utxo = {| export class MockDAppWebpage { driver: WebDriver; + logger: Object; - constructor(driver: WebDriver) { + constructor(driver: WebDriver, logger: Object) { this.driver = driver; + this.logger = logger; } _transactionBuilder(): TransactionBuilder { - return CardanoWasm.TransactionBuilder.new( + this.logger.info('MockDApp: Calling the transaction builder'); + const transactionBuilder = CardanoWasm.TransactionBuilder.new( CardanoWasm.TransactionBuilderConfigBuilder.new() // all of these are taken from the mainnet genesis settings // linear fee parameters (a*size + b) @@ -55,9 +58,12 @@ export class MockDAppWebpage { .max_tx_size(16384) .build() ); + this.logger.info('MockDApp: -> The transaction builder is created'); + return transactionBuilder; } async _requestAccess(auth: boolean = false) { + this.logger.info(`MockDApp: Requesting the access ${auth ? 'with' : 'without'} authentication`); const scriptString = `window.accessRequestPromise = cardano.yoroi.enable(${ auth ? '{requestIdentification: true}' : '' })`; @@ -65,7 +71,12 @@ export class MockDAppWebpage { } _addressesFromCborIfNeeded(addresses: Array): Array { - return addresses.map(a => CardanoWasm.Address.from_bytes(hexToBytes(a)).to_bech32()); + this.logger.info(`MockDApp: Converting the addresses "${JSON.stringify(addresses)}" from CBOR`); + const resultOfConverting = addresses.map(a => + CardanoWasm.Address.from_bytes(hexToBytes(a)).to_bech32() + ); + this.logger.info(`MockDApp: -> Result of converting ${JSON.stringify(resultOfConverting)}`); + return resultOfConverting; } _reduceWasmMultiAsset( @@ -73,6 +84,7 @@ export class MockDAppWebpage { reducer: any, initValue: Array ): Array { + this.logger.info(`MockDApp: Reduce multiAsset`); let result = initValue; if (multiAsset) { const policyIds = multiAsset.keys(); @@ -96,11 +108,13 @@ export class MockDAppWebpage { } } } + this.logger.info(`MockDApp: -> Reduced multiAsset ${JSON.stringify(result)}`); return result; } _mapCborUtxos(cborUtxos: Array): Array { - return cborUtxos.map(hex => { + this.logger.info(`MockDApp: Mapping cborUTXOs "${JSON.stringify(cborUtxos)}" to UTXOs`); + const mappedUtxos = cborUtxos.map(hex => { const u = CardanoWasm.TransactionUnspentOutput.from_bytes(hexToBytes(hex)); const input = u.input(); const output = u.output(); @@ -123,9 +137,12 @@ export class MockDAppWebpage { ), }; }); + this.logger.info(`MockDApp: -> Mapped UTXOs "${JSON.stringify(mappedUtxos)}"`); + return mappedUtxos; } async _getChangeAddress(): Promise { + this.logger.info(`MockDApp: Getting the change address`); const changeAddresses = await this.driver.executeAsyncScript((...args) => { const callback = args[args.length - 1]; window.api @@ -133,18 +150,25 @@ export class MockDAppWebpage { .then(addresses => { // eslint-disable-next-line promise/always-return if (addresses.length === 0) { - throw new MockDAppWebpageError('No change addresses'); + callback({ success: false, errMsg: 'No change addresses' }); } - callback(addresses); + callback({ success: true, retValue: addresses }); }) .catch(error => { - throw new MockDAppWebpageError(JSON.stringify(error)); + callback({ success: false, errMsg: error.message }); }); }); - return this._addressesFromCborIfNeeded([changeAddresses])[0]; + if (changeAddresses.success) { + const changeAddress = this._addressesFromCborIfNeeded([changeAddresses.retValue])[0]; + this.logger.info(`MockDApp: -> The change address is ${changeAddress}`); + return changeAddress; + } + this.logger.error(`MockDApp: -> The error is received: ${changeAddresses.errMsg}`); + throw new MockDAppWebpageError(changeAddresses.errMsg); } async _getUTXOs(): Promise> { + this.logger.info(`MockDApp: Getting UTXOs`); const walletUTXOsResponse = await this.driver.executeAsyncScript((...args) => { const callback = args[args.length - 1]; window.api @@ -152,16 +176,23 @@ export class MockDAppWebpage { .then(utxosResponse => { // eslint-disable-next-line promise/always-return if (utxosResponse.length === 0) { - throw new MockDAppWebpageError('NO UTXOS'); + callback({ success: false, errMsg: 'NO UTXOS' }); } else { - callback(utxosResponse); + callback({ success: true, retValue: utxosResponse }); } }) .catch(error => { - throw new MockDAppWebpageError(JSON.stringify(error)); + callback({ success: false, errMsg: JSON.stringify(error) }); }); }); - return this._mapCborUtxos(walletUTXOsResponse); + this.logger.info( + `MockDApp: -> The walletUTXOsResponse: ${JSON.stringify(walletUTXOsResponse)}` + ); + if (walletUTXOsResponse.success) { + return this._mapCborUtxos(walletUTXOsResponse.retValue); + } + this.logger.error(`MockDApp: -> The error is received: ${walletUTXOsResponse.errMsg}`); + throw new MockDAppWebpageError(walletUTXOsResponse.errMsg); } async requestNonAuthAccess() { @@ -173,27 +204,45 @@ export class MockDAppWebpage { } async checkAccessRequest(): Promise { + this.logger.info(`MockDApp: Checking the access request`); const accessResponse = await this.driver.executeAsyncScript((...args) => { const callback = args[args.length - 1]; window.accessRequestPromise - // eslint-disable-next-line promise/always-return - .then(api => { - window.api = api; - callback({ success: true }); - }) + .then( + // eslint-disable-next-line promise/always-return + api => { + window.api = api; + callback({ success: true }); + }, + error => { + callback({ success: false, errMsg: error.message }); + } + ) .catch(error => { callback({ success: false, errMsg: error.message }); }); }); + this.logger.info(`MockDApp: -> The access response: ${JSON.stringify(accessResponse)}`); await this.driver.executeScript(accResp => { - if (accResp.success) window.walletConnected = true; + if (accResp.success) { + window.walletConnected = true; + } else { + window.walletConnected = null; + } }, accessResponse); + if (accessResponse.success) { + this.logger.info(`MockDApp: -> window.walletConnected = true is set`); + } else { + this.logger.info(`MockDApp: -> window.walletConnected = null is set`); + } + return accessResponse; } async addOnDisconnect() { + this.logger.info(`MockDApp: Setting the onDisconnect hook`); await this.driver.executeScript(() => { window.api.experimental.onDisconnect(() => { window.walletConnected = false; @@ -201,29 +250,73 @@ export class MockDAppWebpage { }); } + async isEnabled(): Promise { + this.logger.info(`MockDApp: Checking is a wallet enabled`); + const isEnabled = await this.driver.executeAsyncScript((...args) => { + const callback = args[args.length - 1]; + window.cardano.yoroi + .isEnabled().then( + // eslint-disable-next-line promise/always-return + onSuccess => { + callback({ success: true, retValue: onSuccess }) + }, + onReject => { + callback({ success: false, errMsg: onReject.message }) + } + ) + .catch(error => { + callback({ success: false, errMsg: error.message }); + }); + }); + if (isEnabled.success) { + this.logger.info(`MockDApp: -> The wallet is enabled`); + return isEnabled.retValue; + } + this.logger.error( + `MockDApp: -> The wallet is disabled. Error message: ${JSON.stringify(isEnabled)}` + ); + throw new MockDAppWebpageError(isEnabled.errMsg); + } + async getConnectionState(): Promise { - return await this.driver.executeScript(() => window.walletConnected); + this.logger.info(`MockDApp: Getting the connection state`); + const walletConnectedState = await this.driver.executeScript(() => window.walletConnected); + this.logger.info(`MockDApp: -> The connection state is ${walletConnectedState}`); + return walletConnectedState; } async getBalance(): Promise { + this.logger.info(`MockDApp: Getting the balance`); const balanceCborHex = await this.driver.executeAsyncScript((...args) => { const callback = args[args.length - 1]; window.api .getBalance() // eslint-disable-next-line promise/always-return .then(balance => { - callback(balance); + callback({ success: true, retValue: balance }); }) .catch(error => { - throw new MockDAppWebpageError(JSON.stringify(error)); + callback({ success: false, errMsg: error.message }); }); }); - - const value = CardanoWasm.Value.from_bytes(Buffer.from(balanceCborHex, 'hex')); - return value.coin().to_str(); + if (balanceCborHex.success) { + const value = CardanoWasm.Value.from_bytes(Buffer.from(balanceCborHex.retValue, 'hex')); + const valueStr = value.coin().to_str(); + this.logger.info(`MockDApp: -> The balance is ${valueStr}`); + return valueStr; + } + this.logger.error( + `MockDApp: -> The error is received while getting the balance. Error: ${JSON.stringify( + balanceCborHex + )}` + ); + throw new MockDAppWebpageError(balanceCborHex.errMsg); } async requestSigningTx(amount: string, toAddress: string) { + this.logger.info( + `MockDApp: Requesting signing the transaction: amount="${amount}", toAddress="${toAddress}"` + ); const UTXOs = await this._getUTXOs(); const changeAddress = await this._getChangeAddress(); const txBuilder = this._transactionBuilder(); @@ -232,10 +325,12 @@ export class MockDAppWebpage { const addr = CardanoWasm.Address.from_bech32(utxo.receiver); const baseAddr = CardanoWasm.BaseAddress.from_address(addr); if (!baseAddr) { + this.logger.error(`MockDApp: -> The error is received: No baseAddr`); throw new MockDAppWebpageError('No baseAddr'); } const keyHash = baseAddr.payment_cred().to_keyhash(); if (!keyHash) { + this.logger.error(`MockDApp: -> The error is received: No keyHash`); throw new MockDAppWebpageError('No keyHash'); } @@ -264,16 +359,30 @@ export class MockDAppWebpage { txBuilder.add_change_if_needed(shelleyChangeAddress); const unsignedTransactionHex = bytesToHex(txBuilder.build_tx().to_bytes()); + this.logger.info(`MockDApp: -> unsignedTransactionHex: ${unsignedTransactionHex}`); this.driver.executeScript(unsignedTxHex => { window.signTxPromise = window.api.signTx({ tx: unsignedTxHex }); }, unsignedTransactionHex); } - async getSigningTxResult(): Promise { - return await this.driver.executeAsyncScript((...args) => { + async getSigningTxResult(): Promise { + this.logger.info(`MockDApp: Getting signing result`); + const signingResult = await this.driver.executeAsyncScript((...args) => { const callback = args[args.length - 1]; - window.signTxPromise.then(callback).catch(callback); + window.signTxPromise + .then( + // eslint-disable-next-line promise/always-return + onSuccess => { + callback(onSuccess); + }, + onReject => { + callback(onReject); + } + ) + .catch(callback); }); + this.logger.info(`MockDApp: -> Signing result: ${JSON.stringify(signingResult)}`); + return signingResult; } } diff --git a/packages/yoroi-extension/features/pages/connectedWebsitesPage.js b/packages/yoroi-extension/features/pages/connectedWebsitesPage.js index 81d9d32927..cac734df99 100644 --- a/packages/yoroi-extension/features/pages/connectedWebsitesPage.js +++ b/packages/yoroi-extension/features/pages/connectedWebsitesPage.js @@ -102,7 +102,7 @@ export const getWalletsWithConnectedWebsites = async ( amount: walletAmountAndCurrency.amount, currency: walletAmountAndCurrency.currency, websiteTitle, - connectionStatus + connectionStatus, }); } return result; diff --git a/packages/yoroi-extension/features/pages/connector-connectWalletPage.js b/packages/yoroi-extension/features/pages/connector-connectWalletPage.js index 4f648418f6..6247e226d6 100644 --- a/packages/yoroi-extension/features/pages/connector-connectWalletPage.js +++ b/packages/yoroi-extension/features/pages/connector-connectWalletPage.js @@ -2,22 +2,32 @@ import { By, WebElement } from 'selenium-webdriver'; import { getMethod } from '../support/helpers/helpers'; +import type { LocatorObject } from '../support/webdriver'; -export const walletListElement = { locator: '.ConnectPage_list', method: 'css' }; -export const walletNameField = { locator: 'div.WalletCard_name', method: 'css' }; -export const walletItemButton = { locator: './button', method: 'xpath' }; -export const walletBalanceField = { locator: '.WalletCard_balance', method: 'css' }; -export const spendingPasswordInput = { +export const logoElement: LocatorObject = { locator: '.Layout_logo', method: 'css' }; +export const noWalletsImg: LocatorObject = { + locator: '.ConnectPage_noWalletsImage', + method: 'css', +}; +export const createWalletBtn: LocatorObject = { + locator: '.ConnectPage_createWallet', + method: 'css', +}; +export const walletListElement: LocatorObject = { locator: '.ConnectPage_list', method: 'css' }; +export const walletNameField: LocatorObject = { locator: 'div.WalletCard_name', method: 'css' }; +export const walletItemButton: LocatorObject = { locator: './button', method: 'xpath' }; +export const walletBalanceField: LocatorObject = { locator: '.WalletCard_balance', method: 'css' }; +export const spendingPasswordInput: LocatorObject = { locator: '//input[@name="walletPassword"]', method: 'xpath', }; -export const spendingPasswordErrorField = { +export const spendingPasswordErrorField: LocatorObject = { locator: '//p[starts-with(@id, "walletPassword--") and contains(@id, "-helper-text")]', method: 'xpath', }; -export const eyeButton = { locator: '.MuiIconButton-edgeEnd', method: 'css' }; -export const confirmButton = { locator: '.MuiButton-primary', method: 'css' }; -export const backButton = { locator: '.MuiButton-secondary', method: 'css' }; +export const eyeButton: LocatorObject = { locator: '.MuiIconButton-edgeEnd', method: 'css' }; +export const confirmButton: LocatorObject = { locator: '.MuiButton-primary', method: 'css' }; +export const backButton: LocatorObject = { locator: '.MuiButton-secondary', method: 'css' }; export const getWallets = async (customWorld: Object): Promise> => { const walletList = await customWorld.waitForElement(walletListElement); diff --git a/packages/yoroi-extension/features/pages/connector-signingTxPage.js b/packages/yoroi-extension/features/pages/connector-signingTxPage.js index 52e56fb648..f5c293eeb7 100644 --- a/packages/yoroi-extension/features/pages/connector-signingTxPage.js +++ b/packages/yoroi-extension/features/pages/connector-signingTxPage.js @@ -109,11 +109,6 @@ export const getTransactionAmount = async (customWorld: Object): Promise return (await amountFieldElement.getText()).split(' ')[0]; }; -export const spendingPasswordInput: LocatorObject = { - locator: '//input[@name="walletPassword"]', - method: 'xpath', -}; - export const confirmButton: LocatorObject = { locator: '.MuiButton-primary', method: 'css' }; export const cancelButton: LocatorObject = { locator: '.MuiButton-secondary', method: 'css' }; diff --git a/packages/yoroi-extension/features/step_definitions/common-steps.js b/packages/yoroi-extension/features/step_definitions/common-steps.js index 9e0b806883..d1272aa224 100644 --- a/packages/yoroi-extension/features/step_definitions/common-steps.js +++ b/packages/yoroi-extension/features/step_definitions/common-steps.js @@ -18,6 +18,11 @@ import { getPlates } from './wallet-restoration-steps'; import { testWallets } from '../mock-chain/TestWallets'; import * as ErgoImporter from '../mock-chain/mockErgoImporter'; import * as CardanoImporter from '../mock-chain/mockCardanoImporter'; +import { + testRunsDataDir, + snapshotsDir, + testRunsLogsDir, +} from '../support/helpers/common-constants'; import { expect } from 'chai'; import { satisfies } from 'semver'; // eslint-disable-next-line import/named @@ -39,9 +44,6 @@ const { promisify } = require('util'); const fs = require('fs'); const rimraf = require('rimraf'); -const testRunsDataDir = './testRunsData/'; -const snapshotsDir = './features/yoroi_snapshots/'; - /** We need to keep track of our progress in testing to give unique names to screenshots */ const testProgress = { scenarioName: '', @@ -52,6 +54,7 @@ const testProgress = { BeforeAll(() => { rimraf.sync(testRunsDataDir); fs.mkdirSync(testRunsDataDir); + fs.mkdirSync(testRunsLogsDir); setDefaultTimeout(20 * 1000); CardanoServer.getMockServer({}); @@ -125,6 +128,7 @@ After({ tags: '@invalidWitnessTest' }, () => { }); After(async function (scenario) { + this.sendToAllLoggers(`#### The scenario "${scenario.pickle.name}" has done ####`); if (scenario.result.status === 'failed') { await takeScreenshot(this.driver, 'failedStep'); await takePageSnapshot(this.driver, 'failedStep'); @@ -199,8 +203,14 @@ async function inputMnemonicForWallet( ): Promise { await customWorld.input({ locator: "input[name='walletName']", method: 'css' }, restoreInfo.name); await enterRecoveryPhrase(customWorld, restoreInfo.mnemonic); - await customWorld.input({ locator: "input[name='walletPassword']", method: 'css' }, restoreInfo.password); - await customWorld.input({ locator: "input[name='repeatPassword']", method: 'css' }, restoreInfo.password); + await customWorld.input( + { locator: "input[name='walletPassword']", method: 'css' }, + restoreInfo.password + ); + await customWorld.input( + { locator: "input[name='repeatPassword']", method: 'css' }, + restoreInfo.password + ); await customWorld.click({ locator: '.WalletRestoreDialog .primary', method: 'css' }); const plateElements = await getPlates(customWorld); @@ -208,13 +218,16 @@ async function inputMnemonicForWallet( expect(plateText).to.be.equal(restoreInfo.plate); await customWorld.click({ locator: '.confirmButton', method: 'css' }); - await customWorld.waitUntilText({ locator: '.NavPlate_name', method: 'css' }, truncateLongName(walletName)); + await customWorld.waitUntilText( + { locator: '.NavPlate_name', method: 'css' }, + truncateLongName(walletName) + ); } export async function checkErrorByTranslationId( client: Object, errorSelector: LocatorObject, - errorObject: Object, + errorObject: Object ) { await client.waitUntilText(errorSelector, await client.intl(errorObject.message), 15000); } diff --git a/packages/yoroi-extension/features/step_definitions/connector-steps.js b/packages/yoroi-extension/features/step_definitions/connector-steps.js index 153ffcd9d4..62672a1f4c 100644 --- a/packages/yoroi-extension/features/step_definitions/connector-steps.js +++ b/packages/yoroi-extension/features/step_definitions/connector-steps.js @@ -1,18 +1,21 @@ // @flow -import { Then, When } from 'cucumber'; +import { Given, Then, When } from 'cucumber'; import { expect } from 'chai'; import { Ports } from '../../scripts/connections'; import { backButton, confirmButton, + createWalletBtn, getWalletBalance, getWalletName, getWallets, + logoElement, + noWalletsImg, selectWallet, spendingPasswordErrorField, spendingPasswordInput, } from '../pages/connector-connectWalletPage'; -import { disconnectWallet } from '../pages/connectedWebsitesPage'; +import { disconnectWallet, getWalletsWithConnectedWebsites } from '../pages/connectedWebsitesPage'; import { getTransactionFee, overviewTabButton, @@ -20,18 +23,38 @@ import { utxoAddressesTabButton, getUTXOAddresses, transactionFeeTitle, + cancelButton, + transactionTotalAmountField, } from '../pages/connector-signingTxPage'; +import { mockDAppName, extensionTabName, popupConnectorName } from '../support/windowManager'; -const mockDAppName = 'mockDAppTab'; -const popupConnectorName = 'popupConnectorWindow'; const userRejectMsg = 'user reject'; -const extensionTabName = 'main'; +const userRejectSigningMsg = 'User rejected'; +const mockDAppUrl = `http://localhost:${Ports.DevBackendServe}/mock-dapp`; -Then(/^I open the mock dApp$/, async function () { - await this.windowManager.openNewTab( - mockDAppName, - `http://localhost:${Ports.DevBackendServe}/mock-dapp` - ); +const connectorPopUpIsDisplayed = async (customWorld: Object) => { + await customWorld.windowManager.findNewWindowAndSwitchTo(popupConnectorName); + const windowTitle = await customWorld.driver.getTitle(); + expect(windowTitle).to.equal('Yoroi dApp Connector'); +}; + +Given(/^I have opened the mock dApp$/, async function () { + await this.driver.get(mockDAppUrl); +}); + +Then(/^I open the mock dApp tab$/, async function () { + await this.windowManager.openNewTab(mockDAppName, mockDAppUrl); +}); + +Given(/^I switch back to the mock dApp$/, async function () { + await this.windowManager.switchTo(mockDAppName); +}); + +When(/^I refresh the dApp page$/, async function () { + await this.windowManager.switchTo(mockDAppName); + await this.driver.executeScript('location.reload(true);'); + // wait for page to refresh + await this.driver.sleep(500); }); Then(/^I request anonymous access to Yoroi$/, async function () { @@ -42,10 +65,19 @@ Then(/^I request access to Yoroi$/, async function () { await this.mockDAppPage.requestAuthAccess(); }); -Then(/^I should see the connector popup$/, async function () { - await this.windowManager.findNewWindowAndSwitchTo(popupConnectorName); - const windowTitle = await this.driver.getTitle(); - expect(windowTitle).to.equal('Yoroi dApp Connector'); +Then(/^I should see the connector popup for connection$/, async function () { + await connectorPopUpIsDisplayed(this); + await this.waitForElement(logoElement); +}); + +Then(/^I should see the connector popup for signing$/, async function () { + await connectorPopUpIsDisplayed(this); + await this.waitForElement(transactionTotalAmountField); +}); + +Then(/^There is no the connector popup$/, async function () { + const newWindows = await this.windowManager.findNewWindows(); + expect(newWindows.length).to.equal(0, 'A new window is displayed'); }); Then( @@ -172,7 +204,10 @@ Then(/^The signing transaction API should return (.+)$/, async function (txHex) Then(/^I see the error Incorrect wallet password$/, async function () { await this.waitForElement(spendingPasswordErrorField); - expect(await this.isDisplayed(spendingPasswordErrorField), "The error isn't displayed").to.be.true; + expect( + await this.isDisplayed(spendingPasswordErrorField), + "The error isn't displayed" + ).to.be.true; const errorText = await this.getText(spendingPasswordErrorField); expect(errorText).to.equal('Incorrect wallet password.'); }); @@ -195,6 +230,19 @@ Then(/^I close the dApp-connector pop-up window$/, async function () { await this.windowManager.closeTabWindow(popupConnectorName, mockDAppName); }); +Then(/^The wallet (.+) is connected to the website (.+)$/, async function (walletName, websiteUrl) { + await this.windowManager.switchTo(extensionTabName); + const connectedWebsitesAddress = `${this.getExtensionUrl()}#/connector/connected-websites`; + // it should be reworked by using ui components when it is done + await this.driver.get(connectedWebsitesAddress); + const wallets = await getWalletsWithConnectedWebsites(this); + const result = wallets.filter( + wallet => wallet.walletTitle === walletName && wallet.websiteTitle === websiteUrl + ); + expect(result.length, `Result is not equal to 1:\n${JSON.stringify(result)}`).to.equal(1); + await this.windowManager.switchTo(mockDAppName); +}); + Then(/^I disconnect the wallet (.+) from the dApp (.+)$/, async function (walletName, dAppUrl) { await this.windowManager.switchTo(extensionTabName); const connectedWebsitesAddress = `${this.getExtensionUrl()}#/connector/connected-websites`; @@ -205,6 +253,39 @@ Then(/^I disconnect the wallet (.+) from the dApp (.+)$/, async function (wallet Then(/^I receive the wallet disconnection message$/, async function () { await this.windowManager.switchTo(mockDAppName); + const isEnabledState = await this.mockDAppPage.isEnabled(); + expect(isEnabledState, 'The wallet is still enabled').to.be.false; const connectionState = await this.mockDAppPage.getConnectionState(); expect(connectionState, 'No message from the dApp-connector is received').to.be.false; -}); \ No newline at end of file +}); + +Then(/^The user reject for signing is received$/, async function () { + await this.windowManager.switchTo(mockDAppName); + const signingResult = await this.mockDAppPage.getSigningTxResult(); + expect(signingResult.code, `The reject signing code is different`).to.equal(2); + expect(signingResult.info).to.equal(userRejectSigningMsg, 'Wrong error message'); +}); + +Then(/^I should see "No Cardano wallets is found" message$/, async function () { + await this.waitForElement(noWalletsImg); + const state = await this.isDisplayed(noWalletsImg); + expect(state, 'There is no "Ooops, no Cardano wallets found" message').to.be.true; +}); + +Then(/^I press the "Create wallet" button \(Connector pop-up window\)$/, async function () { + await this.waitForElement(createWalletBtn); + await this.click(createWalletBtn); +}); + +Then(/^The pop-up is closed and the extension tab is opened$/, async function () { + const result = await this.windowManager.isClosed(popupConnectorName); + expect(result, 'The window|tab is still opened').to.be.true; + + await this.windowManager.findNewWindowAndSwitchTo(extensionTabName); + const windowTitle = await this.driver.getTitle(); + expect(windowTitle).to.equal(extensionTabName); +}); + +Then(/^I cancel signing the transaction$/, async function () { + await this.click(cancelButton); +}); diff --git a/packages/yoroi-extension/features/support/helpers/common-constants.js b/packages/yoroi-extension/features/support/helpers/common-constants.js new file mode 100644 index 0000000000..3275704ecd --- /dev/null +++ b/packages/yoroi-extension/features/support/helpers/common-constants.js @@ -0,0 +1,7 @@ +// @flow + +export const testRunsDataDir = './testRunsData/'; +export const snapshotsDir = './features/yoroi_snapshots/'; +export const testRunsLogsDir = `${testRunsDataDir}Logs/`; +export const mockDAppLogsDir = `${testRunsLogsDir}mockDApp/`; +export const windowManagerLogsDir = `${testRunsLogsDir}windowManager/`; \ No newline at end of file diff --git a/packages/yoroi-extension/features/support/helpers/helpers.js b/packages/yoroi-extension/features/support/helpers/helpers.js index b4efa29a9a..354549f8af 100644 --- a/packages/yoroi-extension/features/support/helpers/helpers.js +++ b/packages/yoroi-extension/features/support/helpers/helpers.js @@ -48,4 +48,8 @@ export async function enterRecoveryPhrase(customWorld: any, phrase: string) { await recoveryPhraseElement.sendKeys(recoveryPhrase[i], Key.RETURN); if (i === 0) await customWorld.driver.sleep(500); } +} + +export function getLogDate(): string { + return new Date().toISOString().replace(/:/g, '_'); } \ No newline at end of file diff --git a/packages/yoroi-extension/features/support/webdriver.js b/packages/yoroi-extension/features/support/webdriver.js index 27e1e66970..f73b815f6c 100644 --- a/packages/yoroi-extension/features/support/webdriver.js +++ b/packages/yoroi-extension/features/support/webdriver.js @@ -7,11 +7,13 @@ import firefox from 'selenium-webdriver/firefox'; import path from 'path'; // eslint-disable-next-line import/named import { RustModule } from '../../app/api/ada/lib/cardanoCrypto/rustLoader'; -import { getMethod } from './helpers/helpers'; +import { getMethod, getLogDate } from './helpers/helpers'; import { WindowManager } from './windowManager'; import { MockDAppWebpage } from '../mock-dApp-webpage'; +import { testRunsLogsDir } from './helpers/common-constants'; const fs = require('fs'); +const simpleNodeLogger= require('simple-node-logger'); function encode(file) { return fs.readFileSync(file, { encoding: 'base64' }); @@ -53,17 +55,19 @@ function getBraveBuilder() { } function getChromeBuilder() { - return new Builder().forBrowser('chrome').setChromeOptions( - new chrome.Options() - .addExtensions(encode(path.resolve(__dirname, '../../yoroi-test.crx'))) - .addArguments( - '--no-sandbox', - '--disable-gpu', - '--disable-dev-shm-usage', - '--disable-setuid-sandbox', - '--start-maximized' - ) - ); + return new Builder() + .forBrowser('chrome') + .setChromeOptions( + new chrome.Options() + .addExtensions(encode(path.resolve(__dirname, '../../yoroi-test.crx'))) + .addArguments( + '--no-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-setuid-sandbox', + '--start-maximized' + ) + ); } function getFirefoxBuilder() { @@ -105,7 +109,6 @@ export type LocatorObject = {| | 'tagName', |}; -// TODO: We should add methods to `this.driver` object, instead of use `this` directly function CustomWorld(cmdInput: WorldInput) { switch (cmdInput.parameters.browser) { case 'brave': { @@ -125,15 +128,25 @@ function CustomWorld(cmdInput: WorldInput) { break; } default: { + this._allLoggers = []; const chromeBuilder = getChromeBuilder(); this.driver = chromeBuilder.build(); - this.windowManager = new WindowManager(this.driver); + const mockAndWMLogPath = `${testRunsLogsDir}mockAndWMLog_${getLogDate()}.log`; + const mockAndWMLogger = simpleNodeLogger.createSimpleFileLogger(mockAndWMLogPath); + this.windowManager = new WindowManager(this.driver, mockAndWMLogger); this.windowManager.init().then().catch(); - this.mockDAppPage = new MockDAppWebpage(this.driver); + this._allLoggers.push(mockAndWMLogger); + this.mockDAppPage = new MockDAppWebpage(this.driver, mockAndWMLogger); break; } } + this.sendToAllLoggers = (message: string, level: string = 'info') => { + for (const someLogger of this._allLoggers) { + someLogger[level](message); + } + }; + this.getBrowser = (): string => cmdInput.parameters.browser; this.getExtensionUrl = (): string => { diff --git a/packages/yoroi-extension/features/support/windowManager.js b/packages/yoroi-extension/features/support/windowManager.js index 92fb60bcaf..7668ec6f8f 100644 --- a/packages/yoroi-extension/features/support/windowManager.js +++ b/packages/yoroi-extension/features/support/windowManager.js @@ -9,35 +9,72 @@ type CustomWindowHandle = {| class WindowManagerError extends Error {} +export const mockDAppName = 'MockDApp'; +export const popupConnectorName = 'popupConnectorWindow'; +export const extensionTabName = 'Yoroi'; + export class WindowManager { windowHandles: Array; driver: WebDriver; + logger: Object; - constructor(driver: WebDriver) { + constructor(driver: WebDriver, logger: Object) { this.driver = driver; this.windowHandles = []; + this.logger = logger; } async init() { + this.logger.info(`WindowManager: Initializing the Window manager`); const mainWindowHandle = await this._getCurrentWindowHandle(); - this.windowHandles.push({ title: 'main', handle: mainWindowHandle }); + const windowTitle = await this._getWindowTitle(); + this.logger.info( + `WindowManager: -> The first and main window is { "${windowTitle}": "${mainWindowHandle}" }` + ); + this.windowHandles.push({ title: windowTitle, handle: mainWindowHandle }); + } + + async _getWindowTitle(): Promise { + this.logger.info(`WindowManager: Getting a window title`); + const windowTitle = await this.driver.getTitle(); + this.logger.info(`WindowManager: -> The window title is "${windowTitle}"`); + if (windowTitle === extensionTabName) { + return extensionTabName; + } + if (windowTitle === mockDAppName) { + return mockDAppName; + } + return 'main'; } _getHandleByTitle(title: string): Array { - return this.windowHandles.filter(customHandle => customHandle.title === title); + this.logger.info(`WindowManager: Getting a handle by the title "${title}"`); + const handles = this.windowHandles.filter(customHandle => customHandle.title === title); + this.logger.info(`WindowManager: -> The handles for title "${title}" are ${JSON.stringify(handles)}`); + return handles; } async _getCurrentWindowHandle(): Promise { - return await this.driver.getWindowHandle(); + this.logger.info(`WindowManager: Getting the current handle`); + const currentHandle = await this.driver.getWindowHandle(); + this.logger.info(`WindowManager: -> The current handle is "${currentHandle}"`); + return currentHandle; } async getAllWindowHandles(): Promise> { - return await this.driver.getAllWindowHandles(); + this.logger.info(`WindowManager: Getting all window handles`); + const allHandles = await this.driver.getAllWindowHandles(); + this.logger.info(`WindowManager: -> All handles: ${JSON.stringify(allHandles)}`); + return allHandles; } async _openNew(type: WindowType, windowName: string): Promise { + this.logger.info(`WindowManager: Opening a new ${type} with a name "${windowName}"`); await this.driver.switchTo().newWindow(type); const currentWindowHandle = await this._getCurrentWindowHandle(); + this.logger.info( + `WindowManager: -> The new ${type} with a name "${windowName}" has handle "${currentWindowHandle}"` + ); return { title: windowName, handle: currentWindowHandle }; } @@ -47,6 +84,9 @@ export class WindowManager { windowName: string, url: string ): Promise { + this.logger.info( + `WindowManager: Opening with checking a new ${type} "${url}" with a name "${windowName}"` + ); const checkTitle = this._getHandleByTitle(windowName); if (!checkTitle.length) { const handle = await this._openNew(type, windowName); @@ -54,6 +94,7 @@ export class WindowManager { this.windowHandles.push(handle); return handle; } + this.logger.error(`WindowManager: -> The handle with the title ${windowName} already exists`); throw new WindowManagerError(`The handle with the title ${windowName} already exists`); } @@ -66,37 +107,67 @@ export class WindowManager { } async closeTabWindow(titleToClose: string, switchToTitle: string): Promise { - const handleToClose = this._getHandleByTitle(titleToClose); - const switchToHandle = this._getHandleByTitle(switchToTitle); - await this.driver.switchTo().window(handleToClose[0].handle); + this.logger.info( + `WindowManager: Closing the tab "${titleToClose}" and switching to the tab "${switchToTitle}"` + ); + const handleToClose = this._getHandleByTitle(titleToClose)[0]; + const switchToHandle = this._getHandleByTitle(switchToTitle)[0]; + await this.driver.switchTo().window(handleToClose.handle); await this.driver.close(); - await this.driver.switchTo().window(switchToHandle[0].handle); + await this.driver.switchTo().window(switchToHandle.handle); const indexOfHandle = this.windowHandles.indexOf(handleToClose); this.windowHandles.splice(indexOfHandle, 1); + this.logger.info( + `WindowManager: -> The tab "${titleToClose}" is closed and removed from this.windowHandles` + ); } async switchTo(title: string): Promise { + this.logger.info(`WindowManager: Switching to the tab|window "${title}"`); const searchHandle = this._getHandleByTitle(title); if (searchHandle.length !== 1) { + this.logger.error( + `WindowManger: -> Unable to switch to the window ${title} because found ${searchHandle.length} handles for the title` + ); throw new WindowManagerError( `Unable to switch to the window ${title} because found ${searchHandle.length} handles for the title` ); } await this.driver.switchTo().window(searchHandle[0].handle); + this.logger.info(`WindowManager: -> Switching to the tab|window "${title}" is done`); } - async findNewWindowAndSwitchTo(newWindowTitle: string): Promise { + _filterHandles(newWindowHandles: Array): Array { + const oldHandles = this.windowHandles.map(customHandle => customHandle.handle); + return newWindowHandles.filter(handle => !oldHandles.includes(handle)); + } + + async findNewWindows(): Promise> { + this.logger.info(`WindowManager: Finding a new window`); let newWindowHandles: Array = []; - for (;;) { + for (let i = 0; i < 50; i++) { + this.logger.info(`WindowManager: -> Try ${i} to find a new window`); await new Promise(resolve => setTimeout(resolve, 100)); newWindowHandles = await this.getAllWindowHandles(); + this.logger.info(`WindowManager: -> newWindowHandles: ${JSON.stringify(newWindowHandles)}`); + this.logger.info(`WindowManager: -> oldHandles: ${JSON.stringify(this.windowHandles)}`); if (newWindowHandles.length > this.windowHandles.length) { - break; + const newHandle = this._filterHandles(newWindowHandles); + this.logger.info(`WindowManager: -> The new window handle is "${JSON.stringify(newHandle)}"`); + return newHandle; } } - const oldHandles = this.windowHandles.map(customHandle => customHandle.handle); - const popupWindowHandleArr = newWindowHandles.filter(handle => !oldHandles.includes(handle)); + this.logger.info(`WindowManager: -> The new window handle is not found`); + return this._filterHandles(newWindowHandles); + } + + async findNewWindowAndSwitchTo(newWindowTitle: string): Promise { + this.logger.info( + `WindowManager: Finding a new window and switching to it and set the title "${newWindowTitle}" to it` + ); + const popupWindowHandleArr = await this.findNewWindows(); if (popupWindowHandleArr.length !== 1) { + this.logger.error(`WindowManager: -> Can not find the popup window`); throw new WindowManagerError('Can not find the popup window'); } const popupWindowHandle = popupWindowHandleArr[0]; @@ -104,15 +175,18 @@ export class WindowManager { this.windowHandles.push(popUpCustomHandle); await this.driver.switchTo().window(popupWindowHandle); + this.logger.info(`WindowManager: -> Switched to the new window ${JSON.stringify(popUpCustomHandle)}`); return popUpCustomHandle; } async isClosed(title: string): Promise { + this.logger.info(`WindowManager: Checking the window with the title "${title}" is closed`); const expectToBeClosedHandle: Array = this.windowHandles.filter( customHandle => customHandle.title === title ); if (!expectToBeClosedHandle.length) { + this.logger.error(`WindowManager: -> There is no handle for the title ${title}`); throw new WindowManagerError(`There is no handle for the title ${title}`); } for (let i = 0; i < 20; i++) { @@ -123,8 +197,10 @@ export class WindowManager { } const indexOfHandle = this.windowHandles.indexOf(expectToBeClosedHandle); this.windowHandles.splice(indexOfHandle, 1); + this.logger.info(`WindowManager: -> The window with the title "${title}" is closed`); return true; } + this.logger.info(`WindowManager: -> The window with the title "${title}" is still opened`); return false; } } diff --git a/packages/yoroi-extension/package-lock.json b/packages/yoroi-extension/package-lock.json index 3e861fcfc2..3da23a2714 100644 --- a/packages/yoroi-extension/package-lock.json +++ b/packages/yoroi-extension/package-lock.json @@ -1,6 +1,6 @@ { "name": "yoroi", - "version": "4.12.100", + "version": "4.13.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -10380,9 +10380,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001274", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001274.tgz", - "integrity": "sha512-+Nkvv0fHyhISkiMIjnyjmf5YJcQ1IQHZN6U9TLUMroWR38FNwpsC51Gb68yueafX1V6ifOisInSgP9WJFS13ew==" + "version": "1.0.30001332", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001332.tgz", + "integrity": "sha512-10T30NYOEQtN6C11YGg411yebhvpnC6Z102+B95eAsN0oB6KUs01ivE8u+G6FMIRtIrVlYXhL+LUwQ3/hXwDWw==" }, "canvas-renderer": { "version": "2.2.0", @@ -24892,6 +24892,16 @@ "integrity": "sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==", "dev": true }, + "simple-node-logger": { + "version": "21.8.12", + "resolved": "https://registry.npmjs.org/simple-node-logger/-/simple-node-logger-21.8.12.tgz", + "integrity": "sha512-RPImnYDq3jdUjaTvYLghaF1n65Dd0LV8hdZtlT0X1NZBAkw+lx0ZJtFydcUyYKjg0Yxd27AW9IAIc3OLhTjBzA==", + "dev": true, + "requires": { + "lodash": "^4.17.12", + "moment": "^2.20.1" + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", diff --git a/packages/yoroi-extension/package.json b/packages/yoroi-extension/package.json index 80c1638adb..515778a7f4 100644 --- a/packages/yoroi-extension/package.json +++ b/packages/yoroi-extension/package.json @@ -1,6 +1,6 @@ { "name": "yoroi", - "version": "4.12.100", + "version": "4.13.0", "description": "Cardano ADA wallet", "scripts": { "dev:build": "rimraf dev/ && babel-node scripts/build --type=debug", @@ -154,6 +154,7 @@ "sass-loader": "11.0.1", "selenium-webdriver": "4.0.0-alpha.7", "shelljs": "0.8.4", + "simple-node-logger": "^21.8.12", "storycap": "2.3.5", "style-loader": "2.0.0", "url-loader": "4.1.1",