1
0
mirror of https://github.com/trezor/trezor-wallet synced 2024-12-30 19:00:53 +00:00

merge master

This commit is contained in:
slowbackspace 2018-12-12 17:25:26 +01:00
commit ba95de788f
79 changed files with 3824 additions and 1762 deletions

View File

@ -79,6 +79,8 @@ deploy review:
- mkdir -p "${DEPLOY_BASE_DIR}/${CI_BUILD_REF_NAME}"
- echo "Copy dev build to web server ${DEPLOY_BASE_DIR}/${CI_BUILD_REF_NAME}..."
- rsync --delete -va build/dev/ "${DEPLOY_BASE_DIR}/${CI_BUILD_REF_NAME}/"
- curl "https://api.telegram.org/bot699197118:AAGXNTaC5Q-ljmy_dMvaIvAKy1XjlkA3Iss/sendMessage?chat_id=-1001354778014&text=https://trezor-wallet-dev.trezor.io/${CI_BUILD_REF_NAME}"
- 'echo "Remove working dir, workaround for cache" && rm -r ./*'
only:
- branches
tags:

View File

@ -1,3 +1,11 @@
## feature/ripple
__changed__
- Split code to coin specific components. actions and reducers
- Use TrezorConnect to communicate with trezor-blockchain-link
__added__
- Ripple support
## 1.0.2-beta
__changed__
- Fiat rates from coingecko (https://github.com/trezor/trezor-wallet/pull/242)

View File

@ -28,14 +28,14 @@ At the root of the `src/` folder are all files or folders that are shared.
Component is what you'd intuitively think it is. It's a regular React component (doesn't matter whether statefull or stateless).
### **Global components**
All global components are are stored in `src/views/components/` folder.
All global components are stored in `src/views/components/` folder.
Global components are such components that are shared across multiple different components or views.
- For example there's a `Button` component that is used in both `ConnectDevice` and `AccountSend`. `ConnectDevice` and `AccountSend` are both placed accross different views so the `Button` component they're both using must be stored in the global `components` folder.
### **Naming & structure convention**
Each component has it's own folder. Name of the folder is same as is the name of the component (camel case and first letter is capitalized, e.g.: *MyComponent*).
If you want to create multiple components of the same type you should put them into a common folder with a lowercase name like this `views/componets/type/MyComponent`.
If you want to create multiple components of the same type you should put them into a common folder with a lowercase name like this `views/components/type/MyComponent`.
- For example there are different types of modals like `confirm` or `device`.
Because the `confirm` and `device` modals are subtypes of modal the folder structure looks like this
@ -64,7 +64,7 @@ Both naming and structure conventions are similar to components conventions.
Each view has its own folder in `views/` folder. Name of this folder is same as is the view's name (camel case and first letter is capitalized, e.g.: *MyView*).
Inside the view's folder is always an `index.js` file containing view's code itself.
View may contain own components inside view's folder - in the `components/` folder. One of the differences between a component and a view is that view can hav another views. Of course those views may have their own components and views, etc.
View may contain own components inside view's folder - in the `components/` folder. One of the differences between a component and a view is that view can have another views. Of course those views may have their own components and views, etc.
```
views/

View File

@ -68,8 +68,9 @@
"redux-thunk": "^2.2.0",
"rimraf": "^2.6.2",
"styled-components": "^4.1.2",
"styled-media-query": "^2.0.2",
"styled-normalize": "^8.0.4",
"trezor-connect": "6.0.2",
"trezor-connect": "6.0.3-beta.4",
"web3": "1.0.0-beta.35",
"webpack": "^4.16.3",
"webpack-build-notifier": "^0.1.29",

View File

@ -1,6 +1,19 @@
{
"networks": [
{
"type": "ripple",
"name": "Ripple Testnet",
"testnet": true,
"symbol": "XRP",
"shortcut": "xrp",
"bip44": "m/44'/144'/a'/0/0",
"explorer": {
"tx": "https://sisyfos.trezor.io/ripple-testnet-explorer/tx/",
"address": "https://sisyfos.trezor.io/ripple-testnet-explorer/address/"
}
},
{
"type": "ethereum",
"name": "Ethereum",
"symbol": "ETH",
"shortcut": "eth",
@ -19,6 +32,7 @@
}
},
{
"type": "ethereum",
"name": "Ethereum Classic",
"symbol": "ETC",
"shortcut": "etc",
@ -37,7 +51,9 @@
}
},
{
"type": "ethereum",
"name": "Ethereum Ropsten",
"testnet": true,
"symbol": "tROP",
"shortcut": "trop",
"chainId": 3,
@ -64,6 +80,10 @@
{
"network": "etc",
"url": "https://api.coingecko.com/api/v3/coins/ethereum-classic"
},
{
"network": "xrp",
"url": "https://api.coingecko.com/api/v3/coins/ripple"
}
],

View File

@ -1,184 +1,25 @@
/* @flow */
import TrezorConnect from 'trezor-connect';
import BigNumber from 'bignumber.js';
import * as BLOCKCHAIN from 'actions/constants/blockchain';
import * as EthereumBlockchainActions from 'actions/ethereum/BlockchainActions';
import * as RippleBlockchainActions from 'actions/ripple/BlockchainActions';
import type {
TrezorDevice,
Dispatch,
GetState,
PromiseAction,
} from 'flowtype';
import type { EthereumAccount } from 'trezor-connect';
import type { Token } from 'reducers/TokensReducer';
import type { NetworkToken } from 'reducers/LocalStorageReducer';
import * as Web3Actions from './Web3Actions';
import * as AccountsActions from './AccountsActions';
import type { BlockchainBlock, BlockchainNotification, BlockchainError } from 'trezor-connect';
export type BlockchainAction = {
type: typeof BLOCKCHAIN.READY,
} | {
type: typeof BLOCKCHAIN.UPDATE_FEE,
shortcut: string,
fee: string,
}
export const discoverAccount = (device: TrezorDevice, address: string, network: string): PromiseAction<EthereumAccount> => async (dispatch: Dispatch): Promise<EthereumAccount> => {
// get data from connect
// Temporary disabled, enable after trezor-connect@5.0.32 release
const txs = await TrezorConnect.ethereumGetAccountInfo({
account: {
address,
block: 0,
transactions: 0,
balance: '0',
nonce: 0,
},
coin: network,
});
if (!txs.success) {
throw new Error(txs.payload.error);
}
// blockbook web3 fallback
const web3account = await dispatch(Web3Actions.discoverAccount(address, network));
// return { transactions: txs.payload, ...web3account };
return {
address,
transactions: txs.payload.transactions,
block: txs.payload.block,
balance: web3account.balance,
nonce: web3account.nonce,
};
};
export const getTokenInfo = (input: string, network: string): PromiseAction<NetworkToken> => async (dispatch: Dispatch): Promise<NetworkToken> => dispatch(Web3Actions.getTokenInfo(input, network));
export const getTokenBalance = (token: Token): PromiseAction<string> => async (dispatch: Dispatch): Promise<string> => dispatch(Web3Actions.getTokenBalance(token));
export const getGasPrice = (network: string, defaultGasPrice: number): PromiseAction<BigNumber> => async (dispatch: Dispatch): Promise<BigNumber> => {
try {
const gasPrice = await dispatch(Web3Actions.getCurrentGasPrice(network));
return gasPrice === '0' ? new BigNumber(defaultGasPrice) : new BigNumber(gasPrice);
} catch (error) {
return new BigNumber(defaultGasPrice);
}
};
const estimateProxy: Array<Promise<string>> = [];
export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction<string> => async (dispatch: Dispatch): Promise<string> => {
// Since this method could be called multiple times in short period of time
// check for pending calls in proxy and if there more than two (first is current running and the second is waiting for result of first)
// TODO: should reject second call immediately?
if (estimateProxy.length > 0) {
// wait for proxy result (but do not process it)
await estimateProxy[0];
}
const call = dispatch(Web3Actions.estimateGasLimit(network, {
to: '',
data,
value,
gasPrice,
}));
// add current call to proxy
estimateProxy.push(call);
// wait for result
const result = await call;
// remove current call from proxy
estimateProxy.splice(0, 1);
// return result
return result;
};
export const onBlockMined = (coinInfo: any): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
// incoming "coinInfo" from TrezorConnect is CoinInfo | EthereumNetwork type
const network: string = coinInfo.shortcut.toLowerCase();
// try to resolve pending transactions
await dispatch(Web3Actions.resolvePendingTransactions(network));
await dispatch(Web3Actions.updateGasPrice(network));
const accounts: Array<any> = getState().accounts.filter(a => a.network === network);
if (accounts.length > 0) {
// find out which account changed
const response = await TrezorConnect.ethereumGetAccountInfo({
accounts,
coin: network,
});
if (response.success) {
response.payload.forEach((a, i) => {
if (a.transactions > 0) {
// load additional data from Web3 (balance, nonce, tokens)
dispatch(Web3Actions.updateAccount(accounts[i], a, network));
} else {
// there are no new txs, just update block
dispatch(AccountsActions.update({ ...accounts[i], block: a.block }));
// HACK: since blockbook can't work with smart contracts for now
// try to update tokens balances added to this account using Web3
dispatch(Web3Actions.updateAccountTokens(accounts[i]));
}
});
}
}
};
// not used for now, waiting for fix in blockbook
/*
export const onNotification = (payload: any): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
// this event can be triggered multiple times
// 1. check if pair [txid + address] is already in reducer
const network: string = payload.coin.shortcut.toLowerCase();
const address: string = EthereumjsUtil.toChecksumAddress(payload.tx.address);
const txInfo = await dispatch(Web3Actions.getPendingInfo(network, payload.tx.txid));
// const exists = getState().pending.filter(p => p.id === payload.tx.txid && p.address === address);
const exists = getState().pending.filter(p => p.address === address);
if (exists.length < 1) {
if (txInfo) {
dispatch({
type: PENDING.ADD,
payload: {
type: 'send',
id: payload.tx.txid,
network,
currency: 'tETH',
amount: txInfo.value,
total: '0',
tx: {},
nonce: txInfo.nonce,
address,
rejected: false,
},
});
} else {
// tx info not found (yet?)
// dispatch({
// type: PENDING.ADD_UNKNOWN,
// payload: {
// network,
// ...payload.tx,
// }
// });
}
}
};
*/
export const subscribe = (network: string): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const addresses: Array<string> = getState().accounts.filter(a => a.network === network).map(a => a.address); // eslint-disable-line no-unused-vars
await TrezorConnect.blockchainSubscribe({
// accounts: addresses,
accounts: [],
coin: network,
});
// init web3 instance if not exists
await dispatch(Web3Actions.initWeb3(network));
};
// Conditionally subscribe to blockchain backend
// called after TrezorConnect.init successfully emits TRANSPORT.START event
// checks if there are discovery processes loaded from LocalStorage
@ -205,8 +46,75 @@ export const init = (): PromiseAction<void> => async (dispatch: Dispatch, getSta
});
};
export const subscribe = (networkName: string): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const { config } = getState().localStorage;
const network = config.networks.find(c => c.shortcut === networkName);
if (!network) return;
switch (network.type) {
case 'ethereum':
await dispatch(EthereumBlockchainActions.subscribe(networkName));
break;
case 'ripple':
await dispatch(RippleBlockchainActions.subscribe(networkName));
break;
default: break;
}
};
export const onBlockMined = (payload: $ElementType<BlockchainBlock, 'payload'>): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const shortcut = payload.coin.shortcut.toLowerCase();
if (getState().router.location.state.network !== shortcut) return;
const { config } = getState().localStorage;
const network = config.networks.find(c => c.shortcut === shortcut);
if (!network) return;
switch (network.type) {
case 'ethereum':
await dispatch(EthereumBlockchainActions.onBlockMined(shortcut));
break;
case 'ripple':
await dispatch(RippleBlockchainActions.onBlockMined(shortcut));
break;
default: break;
}
};
export const onNotification = (payload: $ElementType<BlockchainNotification, 'payload'>): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const shortcut = payload.coin.shortcut.toLowerCase();
const { config } = getState().localStorage;
const network = config.networks.find(c => c.shortcut === shortcut);
if (!network) return;
switch (network.type) {
case 'ethereum':
// this is not working until blockchain-link will start support blockbook backends
await dispatch(EthereumBlockchainActions.onNotification());
break;
case 'ripple':
await dispatch(RippleBlockchainActions.onNotification(payload));
break;
default: break;
}
};
// Handle BLOCKCHAIN.ERROR event from TrezorConnect
// disconnect and remove Web3 webscocket instance if exists
export const error = (payload: any): PromiseAction<void> => async (dispatch: Dispatch): Promise<void> => {
dispatch(Web3Actions.disconnect(payload.coin));
export const onError = (payload: $ElementType<BlockchainError, 'payload'>): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const shortcut = payload.coin.shortcut.toLowerCase();
const { config } = getState().localStorage;
const network = config.networks.find(c => c.shortcut === shortcut);
if (!network) return;
switch (network.type) {
case 'ethereum':
await dispatch(EthereumBlockchainActions.onError(shortcut));
break;
case 'ripple':
// this error is handled in BlockchainReducer
// await dispatch(RippleBlockchainActions.onBlockMined(shortcut));
break;
default: break;
}
};

View File

@ -1,7 +1,6 @@
/* @flow */
import TrezorConnect from 'trezor-connect';
import EthereumjsUtil from 'ethereumjs-util';
import TrezorConnect, { UI } from 'trezor-connect';
import * as DISCOVERY from 'actions/constants/discovery';
import * as ACCOUNT from 'actions/constants/account';
import * as NOTIFICATION from 'actions/constants/notification';
@ -14,29 +13,25 @@ import type {
GetState,
Dispatch,
TrezorDevice,
Account,
} from 'flowtype';
import type { Discovery, State } from 'reducers/DiscoveryReducer';
import * as BlockchainActions from './BlockchainActions';
import * as EthereumDiscoveryActions from './ethereum/DiscoveryActions';
import * as RippleDiscoveryActions from './ripple/DiscoveryActions';
export type DiscoveryStartAction = {
type: typeof DISCOVERY.START,
device: TrezorDevice,
network: string,
publicKey: string,
chainCode: string,
basePath: Array<number>,
}
export type DiscoveryStartAction = EthereumDiscoveryActions.DiscoveryStartAction | RippleDiscoveryActions.DiscoveryStartAction;
export type DiscoveryWaitingAction = {
type: typeof DISCOVERY.WAITING_FOR_DEVICE | typeof DISCOVERY.WAITING_FOR_BLOCKCHAIN,
type: typeof DISCOVERY.WAITING_FOR_DEVICE | typeof DISCOVERY.WAITING_FOR_BLOCKCHAIN | typeof DISCOVERY.FIRMWARE_NOT_SUPPORTED | typeof DISCOVERY.FIRMWARE_OUTDATED,
device: TrezorDevice,
network: string
network: string,
}
export type DiscoveryCompleteAction = {
type: typeof DISCOVERY.COMPLETE,
device: TrezorDevice,
network: string
network: string,
}
export type DiscoveryAction = {
@ -122,44 +117,43 @@ const start = (device: TrezorDevice, network: string, ignoreCompleted?: boolean)
// first iteration
// generate public key for this account
// start discovery process
const begin = (device: TrezorDevice, network: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const begin = (device: TrezorDevice, networkName: string): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const { config } = getState().localStorage;
const networkData = config.networks.find(c => c.shortcut === network);
if (!networkData) return;
const network = config.networks.find(c => c.shortcut === networkName);
if (!network) return;
dispatch({
type: DISCOVERY.WAITING_FOR_DEVICE,
device,
network,
network: networkName,
});
// get xpub from TREZOR
const response = await TrezorConnect.getPublicKey({
device: {
path: device.path,
instance: device.instance,
state: device.state,
},
path: networkData.bip44,
keepSession: true, // acquire and hold session
//useEmptyPassphrase: !device.instance,
useEmptyPassphrase: device.useEmptyPassphrase,
});
let startAction: DiscoveryStartAction;
// handle TREZOR response error
if (!response.success) {
try {
switch (network.type) {
case 'ethereum':
startAction = await dispatch(EthereumDiscoveryActions.begin(device, network));
break;
case 'ripple':
startAction = await dispatch(RippleDiscoveryActions.begin(device, network));
break;
default:
throw new Error(`DiscoveryActions.begin: Unknown network type: ${network.type}`);
}
} catch (error) {
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'error',
title: 'Discovery error',
message: response.payload.error,
message: error.message,
cancelable: true,
actions: [
{
label: 'Try again',
callback: () => {
dispatch(start(device, network));
dispatch(start(device, networkName));
},
},
],
@ -174,64 +168,52 @@ const begin = (device: TrezorDevice, network: string): AsyncAction => async (dis
const discoveryProcess = getState().discovery.find(d => d.deviceState === device.state && d.network === network);
if (dispatch(isProcessInterrupted(discoveryProcess))) return;
const basePath: Array<number> = response.payload.path;
// send data to reducer
dispatch({
type: DISCOVERY.START,
network,
device,
publicKey: response.payload.publicKey,
chainCode: response.payload.chainCode,
basePath,
});
dispatch(startAction);
// next iteration
dispatch(start(device, network));
dispatch(start(device, networkName));
};
const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch): Promise<void> => {
const { completed } = discoveryProcess;
const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const { config } = getState().localStorage;
const network = config.networks.find(c => c.shortcut === discoveryProcess.network);
if (!network) return;
const derivedKey = discoveryProcess.hdKey.derive(`m/${discoveryProcess.accountIndex}`);
const path = discoveryProcess.basePath.concat(discoveryProcess.accountIndex);
const publicAddress: string = EthereumjsUtil.publicToAddress(derivedKey.publicKey, true).toString('hex');
const ethAddress: string = EthereumjsUtil.toChecksumAddress(publicAddress);
const { network } = discoveryProcess;
// TODO: check if address was created before
const { completed, accountIndex } = discoveryProcess;
let account: Account;
try {
const account = await dispatch(BlockchainActions.discoverAccount(device, ethAddress, network));
// check for interruption
if (dispatch(isProcessInterrupted(discoveryProcess))) return;
// const accountIsEmpty = account.transactions <= 0 && account.nonce <= 0 && account.balance === '0';
const accountIsEmpty = account.nonce <= 0 && account.balance === '0';
if (!accountIsEmpty || (accountIsEmpty && completed) || (accountIsEmpty && discoveryProcess.accountIndex === 0)) {
dispatch({
type: ACCOUNT.CREATE,
payload: {
index: discoveryProcess.accountIndex,
loaded: true,
network,
deviceID: device.features ? device.features.device_id : '0',
deviceState: device.state || '0',
addressPath: path,
address: ethAddress,
balance: account.balance,
nonce: account.nonce,
block: account.block,
transactions: account.transactions,
},
});
}
if (accountIsEmpty) {
dispatch(finish(device, discoveryProcess));
} else if (!completed) {
dispatch(discoverAccount(device, discoveryProcess));
switch (network.type) {
case 'ethereum':
account = await dispatch(EthereumDiscoveryActions.discoverAccount(device, discoveryProcess));
break;
case 'ripple':
account = await dispatch(RippleDiscoveryActions.discoverAccount(device, discoveryProcess));
break;
default:
throw new Error(`DiscoveryActions.discoverAccount: Unknown network type: ${network.type}`);
}
} catch (error) {
// handle not supported firmware error
if (error.message === UI.FIRMWARE_NOT_SUPPORTED) {
dispatch({
type: DISCOVERY.FIRMWARE_NOT_SUPPORTED,
device,
network: discoveryProcess.network,
});
return;
}
// handle outdated firmware error
if (error.message === UI.FIRMWARE) {
dispatch({
type: DISCOVERY.FIRMWARE_OUTDATED,
device,
network: discoveryProcess.network,
});
return;
}
dispatch({
type: DISCOVERY.STOP,
device,
@ -254,6 +236,23 @@ const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): Asy
],
},
});
return;
}
if (dispatch(isProcessInterrupted(discoveryProcess))) return;
const accountIsEmpty = account.empty;
if (!accountIsEmpty || (accountIsEmpty && completed) || (accountIsEmpty && accountIndex === 0)) {
dispatch({
type: ACCOUNT.CREATE,
payload: account,
});
}
if (accountIsEmpty) {
dispatch(finish(device, discoveryProcess));
} else if (!completed) {
dispatch(discoverAccount(device, discoveryProcess));
}
};

View File

@ -141,10 +141,9 @@ const loadJSON = (): AsyncAction => async (dispatch: Dispatch): Promise<void> =>
try {
const config: Config = await httpRequest(AppConfigJSON, 'json');
// remove ropsten testnet from config networks
// remove testnets from config networks
if (!buildUtils.isDev()) {
const index = config.networks.findIndex(c => c.shortcut === 'trop');
delete config.networks[index];
config.networks = config.networks.filter(n => !n.testnet);
}
const ERC20Abi = await httpRequest(Erc20AbiJSON, 'json');
@ -156,8 +155,10 @@ const loadJSON = (): AsyncAction => async (dispatch: Dispatch): Promise<void> =>
// load tokens
const tokens = await config.networks.reduce(async (promise: Promise<TokensCollection>, network: Network): Promise<TokensCollection> => {
const collection: TokensCollection = await promise;
const json = await httpRequest(network.tokens, 'json');
collection[network.shortcut] = json;
if (network.tokens) {
const json = await httpRequest(network.tokens, 'json');
collection[network.shortcut] = json;
}
return collection;
}, Promise.resolve({}));
@ -175,7 +176,7 @@ const loadJSON = (): AsyncAction => async (dispatch: Dispatch): Promise<void> =>
}
};
const VERSION: string = '1';
const VERSION: string = '2';
const loadStorageData = (): ThunkAction => (dispatch: Dispatch): void => {
// validate version

View File

@ -13,12 +13,11 @@ export type PendingTxAction = {
payload: PendingTx
} | {
type: typeof PENDING.TX_RESOLVED,
tx: PendingTx,
receipt?: Object,
hash: string,
} | {
type: typeof PENDING.TX_REJECTED,
tx: PendingTx,
hash: string,
} | {
type: typeof PENDING.TX_TOKEN_ERROR,
tx: PendingTx,
hash: string,
}

View File

@ -50,7 +50,9 @@ export const showUnverifiedAddress = (): Action => ({
//export const showAddress = (address_n: string): AsyncAction => {
export const showAddress = (path: Array<number>): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const selected = getState().wallet.selectedDevice;
if (!selected) return;
const { network } = getState().selectedAccount;
if (!selected || !network) return;
if (selected && (!selected.connected || !selected.available)) {
dispatch({
@ -60,18 +62,30 @@ export const showAddress = (path: Array<number>): AsyncAction => async (dispatch
return;
}
const response = await TrezorConnect.ethereumGetAddress({
const params = {
device: {
path: selected.path,
instance: selected.instance,
state: selected.state,
},
path,
// useEmptyPassphrase: !selected.instance,
useEmptyPassphrase: selected.useEmptyPassphrase,
});
};
if (response && response.success) {
let response;
switch (network.type) {
case 'ethereum':
response = await TrezorConnect.ethereumGetAddress(params);
break;
case 'ripple':
response = await TrezorConnect.rippleGetAddress(params);
break;
default:
response = { payload: { error: `ReceiveActions.showAddress: Unknown network type: ${network.type}` } };
break;
}
if (response.success) {
dispatch({
type: RECEIVE.SHOW_ADDRESS,
});

View File

@ -6,7 +6,6 @@ import * as ACCOUNT from 'actions/constants/account';
import * as DISCOVERY from 'actions/constants/discovery';
import * as TOKEN from 'actions/constants/token';
import * as PENDING from 'actions/constants/pendingTx';
import * as SEND from 'actions/constants/send';
import * as reducerUtils from 'reducers/utils';
@ -67,6 +66,24 @@ const getAccountLoader = (state: State, selectedAccount: SelectedAccountState):
if (account) return null;
// account not found (yet). checking why...
if (discovery && discovery.fwOutdated) {
return {
type: 'info',
title: `Device ${device.instanceLabel} firmware is outdated`,
message: 'TODO: update firmware explanation',
shouldRender: false,
};
}
if (discovery && discovery.fwNotSupported) {
return {
type: 'info',
title: `Device ${device.instanceLabel} is not supported`,
message: 'TODO: model T1 not supported explanation',
shouldRender: false,
};
}
if (!discovery || (discovery.waitingForDevice || discovery.interrupted)) {
if (device.connected) {
// case 1: device is connected but discovery not started yet (probably waiting for auth)
@ -166,7 +183,6 @@ const getAccountNotification = (state: State, selectedAccount: SelectedAccountSt
const actions = [
LOCATION_CHANGE,
...Object.values(BLOCKCHAIN).filter(v => typeof v === 'string'),
SEND.TX_COMPLETE,
WALLET.SET_SELECTED_DEVICE,
WALLET.UPDATE_SELECTED_DEVICE,
...Object.values(ACCOUNT).filter(v => typeof v === 'string' && v !== ACCOUNT.UPDATE_SELECTED_ACCOUNT && v !== ACCOUNT.DISPOSE), // exported values got unwanted "__esModule: true" as first element

View File

@ -1,18 +1,7 @@
/* @flow */
import React from 'react';
import Link from 'components/Link';
import TrezorConnect from 'trezor-connect';
import BigNumber from 'bignumber.js';
import * as ACCOUNT from 'actions/constants/account';
import * as NOTIFICATION from 'actions/constants/notification';
import * as SEND from 'actions/constants/send';
import * as WEB3 from 'actions/constants/web3';
import * as ValidationActions from 'actions/SendFormValidationActions';
import { initialState } from 'reducers/SendFormReducer';
import { findToken } from 'reducers/TokensReducer';
import * as reducerUtils from 'reducers/utils';
import * as ethUtils from 'utils/ethUtils';
import type {
Dispatch,
@ -20,33 +9,27 @@ import type {
State as ReducersState,
Action,
ThunkAction,
AsyncAction,
TrezorDevice,
} from 'flowtype';
import type { State, FeeLevel } from 'reducers/SendFormReducer';
import type { Account } from 'reducers/AccountsReducer';
import * as SessionStorageActions from './SessionStorageActions';
import { prepareEthereumTx, serializeEthereumTx } from './TxActions';
import * as BlockchainActions from './BlockchainActions';
import type { State as EthereumState } from 'reducers/SendFormEthereumReducer';
import type { State as RippleState } from 'reducers/SendFormRippleReducer';
export type SendTxAction = {
type: typeof SEND.TX_COMPLETE,
account: Account,
selectedCurrency: string,
amount: string,
total: string,
tx: any,
nonce: number,
txid: string,
txData: any,
};
import * as EthereumSendFormActions from './ethereum/SendFormActions';
import * as RippleSendFormActions from './ripple/SendFormActions';
export type SendFormAction = {
type: typeof SEND.INIT | typeof SEND.VALIDATION | typeof SEND.CHANGE,
state: State,
networkType: 'ethereum',
state: EthereumState,
} | {
type: typeof SEND.INIT | typeof SEND.VALIDATION | typeof SEND.CHANGE,
networkType: 'ripple',
state: RippleState,
} | {
type: typeof SEND.TOGGLE_ADVANCED | typeof SEND.TX_SENDING | typeof SEND.TX_ERROR,
} | SendTxAction;
} | {
type: typeof SEND.TX_COMPLETE,
};
// list of all actions which has influence on "sendForm" reducer
// other actions will be ignored
@ -67,521 +50,16 @@ export const observe = (prevState: ReducersState, action: Action): ThunkAction =
// do not proceed if it's not "send" url
if (!currentState.router.location.state.send) return;
// if action type is SEND.VALIDATION which is called as result of this process
// save data to session storage
if (action.type === SEND.VALIDATION) {
dispatch(SessionStorageActions.saveDraftTransaction());
return;
}
// if send form was not initialized
if (currentState.sendForm.currency === '') {
dispatch(init());
return;
}
// handle gasPrice update from backend
// recalculate fee levels if needed
if (action.type === WEB3.GAS_PRICE_UPDATED) {
dispatch(ValidationActions.onGasPriceUpdated(action.network, action.gasPrice));
return;
}
let shouldUpdate: boolean = false;
// check if "selectedAccount" reducer changed
shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, {
account: ['balance', 'nonce', 'tokens'],
});
if (shouldUpdate && currentState.sendForm.currency !== currentState.sendForm.networkSymbol) {
// make sure that this token is added into account
const { account, tokens } = getState().selectedAccount;
if (!account) return;
const token = findToken(tokens, account.address, currentState.sendForm.currency, account.deviceState);
if (!token) {
// token not found, re-init form
dispatch(init());
return;
}
}
// check if "sendForm" reducer changed
if (!shouldUpdate) {
shouldUpdate = reducerUtils.observeChanges(prevState.sendForm, currentState.sendForm);
}
if (shouldUpdate) {
const validated = dispatch(ValidationActions.validation());
dispatch({
type: SEND.VALIDATION,
state: validated,
});
}
};
/*
* Called from "observe" action
* Initialize "sendForm" reducer data
* Get data either from session storage or "selectedAccount" reducer
*/
export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return;
const stateFromStorage = dispatch(SessionStorageActions.loadDraftTransaction());
if (stateFromStorage) {
// TODO: consider if current gasPrice should be set here as "recommendedGasPrice"
dispatch({
type: SEND.INIT,
state: stateFromStorage,
});
return;
}
const gasPrice: BigNumber = await dispatch(BlockchainActions.getGasPrice(network.shortcut, network.defaultGasPrice));
const gasLimit = network.defaultGasLimit.toString();
const feeLevels = ValidationActions.getFeeLevels(network.symbol, gasPrice, gasLimit);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel);
dispatch({
type: SEND.INIT,
state: {
...initialState,
networkName: network.shortcut,
networkSymbol: network.symbol,
currency: network.symbol,
feeLevels,
selectedFeeLevel,
recommendedGasPrice: gasPrice.toString(),
gasLimit,
gasPrice: gasPrice.toString(),
},
});
};
/*
* Called from UI from "advanced" button
*/
export const toggleAdvanced = (): Action => ({
type: SEND.TOGGLE_ADVANCED,
});
/*
* Called from UI on "address" field change
*/
export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendForm;
dispatch({
type: SEND.CHANGE,
state: {
...state,
untouched: false,
touched: { ...state.touched, address: true },
address,
},
});
};
/*
* Called from UI on "amount" field change
*/
export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendForm;
dispatch({
type: SEND.CHANGE,
state: {
...state,
untouched: false,
touched: { ...state.touched, amount: true },
setMax: false,
amount,
},
});
};
/*
* Called from UI on "currency" selection change
*/
export const onCurrencyChange = (currency: { value: string, label: string }): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return;
const state = getState().sendForm;
const isToken = currency.value !== state.networkSymbol;
const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString();
const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
state: {
...state,
currency: currency.value,
feeLevels,
selectedFeeLevel,
gasLimit,
},
});
};
/*
* Called from UI from "set max" button
*/
export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendForm;
dispatch({
type: SEND.CHANGE,
state: {
...state,
untouched: false,
touched: { ...state.touched, amount: true },
setMax: !state.setMax,
},
});
};
/*
* Called from UI on "fee" selection change
*/
export const onFeeLevelChange = (feeLevel: FeeLevel): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendForm;
const isCustom = feeLevel.value === 'Custom';
let newGasLimit = state.gasLimit;
let newGasPrice = state.gasPrice;
const advanced = isCustom ? true : state.advanced;
if (!isCustom) {
// if selected fee is not custom
// update gasLimit to default and gasPrice to selected value
const { network } = getState().selectedAccount;
if (!network) return;
const isToken = state.currency !== state.networkSymbol;
if (isToken) {
newGasLimit = network.defaultGasLimitTokens.toString();
} else {
// corner case: gas limit was changed by user OR by "estimateGasPrice" action
// leave gasLimit as it is
newGasLimit = state.touched.gasLimit ? state.gasLimit : network.defaultGasLimit.toString();
}
newGasPrice = feeLevel.gasPrice;
}
dispatch({
type: SEND.CHANGE,
state: {
...state,
advanced,
selectedFeeLevel: feeLevel,
gasLimit: newGasLimit,
gasPrice: newGasPrice,
},
});
};
/*
* Called from UI from "update recommended fees" button
*/
export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return;
const state: State = getState().sendForm;
const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, state.gasLimit, state.selectedFeeLevel);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
state: {
...state,
feeLevels,
selectedFeeLevel,
gasPrice: selectedFeeLevel.gasPrice,
gasPriceNeedsUpdate: false,
},
});
};
/*
* Called from UI on "gas price" field change
*/
export const onGasPriceChange = (gasPrice: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendForm;
// switch to custom fee level
let newSelectedFeeLevel = state.selectedFeeLevel;
if (state.selectedFeeLevel.value !== 'Custom') newSelectedFeeLevel = state.feeLevels.find(f => f.value === 'Custom');
dispatch({
type: SEND.CHANGE,
state: {
...state,
untouched: false,
touched: { ...state.touched, gasPrice: true },
gasPrice,
selectedFeeLevel: newSelectedFeeLevel,
},
});
};
/*
* Called from UI on "data" field change
* OR from "estimateGasPrice" action
*/
export const onGasLimitChange = (gasLimit: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const { network } = getState().selectedAccount;
if (!network) return;
const state: State = getState().sendForm;
// recalculate feeLevels with recommended gasPrice
const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
state: {
...state,
calculatingGasLimit: false,
untouched: false,
touched: { ...state.touched, gasLimit: true },
gasLimit,
feeLevels,
selectedFeeLevel,
},
});
};
/*
* Called from UI on "nonce" field change
*/
export const onNonceChange = (nonce: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendForm;
dispatch({
type: SEND.CHANGE,
state: {
...state,
untouched: false,
touched: { ...state.touched, nonce: true },
nonce,
},
});
};
/*
* Called from UI on "data" field change
*/
export const onDataChange = (data: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendForm;
dispatch({
type: SEND.CHANGE,
state: {
...state,
calculatingGasLimit: true,
untouched: false,
touched: { ...state.touched, data: true },
data,
},
});
dispatch(estimateGasPrice());
};
export const setDefaultGasLimit = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendForm;
const { network } = getState().selectedAccount;
const { network } = currentState.selectedAccount;
if (!network) return;
const isToken = state.currency !== state.networkSymbol;
const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString();
dispatch({
type: SEND.CHANGE,
state: {
...state,
calculatingGasLimit: false,
untouched: false,
touched: { ...state.touched, gasLimit: false },
gasLimit,
},
});
};
/*
* Internal method
* Called from "onDataChange" action
* try to asynchronously download data from backend
*/
const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const state: State = getState().sendForm;
const { network } = getState().selectedAccount;
if (!network) {
// stop "calculatingGasLimit" process
dispatch(onGasLimitChange(state.gasLimit));
return;
}
const requestedData = state.data;
if (!ethUtils.isHex(requestedData)) {
// stop "calculatingGasLimit" process
dispatch(onGasLimitChange(requestedData.length > 0 ? state.gasLimit : network.defaultGasLimit.toString()));
return;
}
if (state.data.length < 1) {
// set default
dispatch(onGasLimitChange(network.defaultGasLimit.toString()));
return;
}
const gasLimit = await dispatch(BlockchainActions.estimateGasLimit(network.shortcut, state.data, state.amount, state.gasPrice));
// double check "data" field
// possible race condition when data changed before backend respond
if (getState().sendForm.data === requestedData) {
dispatch(onGasLimitChange(gasLimit));
switch (network.type) {
case 'ethereum':
dispatch(EthereumSendFormActions.observe(prevState, action));
break;
case 'ripple':
dispatch(RippleSendFormActions.observe(prevState, action));
break;
default: break;
}
};
/*
* Called from UI from "send" button
*/
export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const {
account,
network,
pending,
} = getState().selectedAccount;
if (!account || !network) return;
const currentState: State = getState().sendForm;
const isToken: boolean = currentState.currency !== currentState.networkSymbol;
const pendingNonce: number = reducerUtils.getPendingNonce(pending);
const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce;
const txData = await dispatch(prepareEthereumTx({
network: network.shortcut,
token: isToken ? findToken(getState().tokens, account.address, currentState.currency, account.deviceState) : null,
from: account.address,
to: currentState.address,
amount: currentState.amount,
data: currentState.data,
gasLimit: currentState.gasLimit,
gasPrice: currentState.gasPrice,
nonce,
}));
const selected: ?TrezorDevice = getState().wallet.selectedDevice;
if (!selected) return;
const signedTransaction = await TrezorConnect.ethereumSignTransaction({
device: {
path: selected.path,
instance: selected.instance,
state: selected.state,
},
// useEmptyPassphrase: !selected.instance,
useEmptyPassphrase: selected.useEmptyPassphrase,
path: account.addressPath,
transaction: txData,
});
if (!signedTransaction || !signedTransaction.success) {
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'error',
title: 'Transaction error',
message: signedTransaction.payload.error,
cancelable: true,
actions: [],
},
});
return;
}
txData.r = signedTransaction.payload.r;
txData.s = signedTransaction.payload.s;
txData.v = signedTransaction.payload.v;
try {
const serializedTx: string = await dispatch(serializeEthereumTx(txData));
const push = await TrezorConnect.pushTransaction({
tx: serializedTx,
coin: network.shortcut,
});
if (!push.success) {
throw new Error(push.payload.error);
}
const { txid } = push.payload;
dispatch({
type: SEND.TX_COMPLETE,
account,
selectedCurrency: currentState.currency,
amount: currentState.amount,
total: currentState.total,
tx: txData,
nonce,
txid,
txData,
});
// clear session storage
dispatch(SessionStorageActions.clear());
// reset form
dispatch(init());
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'success',
title: 'Transaction success',
message: <Link href={`${network.explorer.tx}${txid}`} isGreen>See transaction detail</Link>,
cancelable: true,
actions: [],
},
});
} catch (error) {
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'error',
title: 'Transaction error',
message: error.message || error,
cancelable: true,
actions: [],
},
});
}
};
export default {
toggleAdvanced,
onAddressChange,
onAmountChange,
onCurrencyChange,
onSetMax,
onFeeLevelChange,
updateFeeLevels,
onGasPriceChange,
onGasLimitChange,
setDefaultGasLimit,
onNonceChange,
onDataChange,
onSend,
};

View File

@ -2,7 +2,8 @@
import * as storageUtils from 'utils/storage';
import { findToken } from 'reducers/TokensReducer';
import type { State as SendFormState } from 'reducers/SendFormReducer';
import type { State as EthereumSendFormState } from 'reducers/SendFormEthereumReducer';
import type { State as RippleSendFormState } from 'reducers/SendFormRippleReducer';
import type {
ThunkAction,
PayloadAction,
@ -20,18 +21,18 @@ const getTxDraftKey = (getState: GetState): string => {
};
export const saveDraftTransaction = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendForm;
const state = getState().sendFormEthereum;
if (state.untouched) return;
const key = getTxDraftKey(getState);
storageUtils.set(TYPE, key, JSON.stringify(state));
};
export const loadDraftTransaction = (): PayloadAction<?SendFormState> => (dispatch: Dispatch, getState: GetState): ?SendFormState => {
export const loadEthereumDraftTransaction = (): PayloadAction<?EthereumSendFormState> => (dispatch: Dispatch, getState: GetState): ?EthereumSendFormState => {
const key = getTxDraftKey(getState);
const value: ?string = storageUtils.get(TYPE, key);
if (!value) return null;
const state: ?SendFormState = JSON.parse(value);
const state: ?EthereumSendFormState = JSON.parse(value);
if (!state) return null;
// decide if draft is valid and should be returned
// ignore this draft if has any error
@ -52,6 +53,21 @@ export const loadDraftTransaction = (): PayloadAction<?SendFormState> => (dispat
return state;
};
export const loadRippleDraftTransaction = (): PayloadAction<?RippleSendFormState> => (dispatch: Dispatch, getState: GetState): ?RippleSendFormState => {
const key = getTxDraftKey(getState);
const value: ?string = storageUtils.get(TYPE, key);
if (!value) return null;
const state: ?RippleSendFormState = JSON.parse(value);
if (!state) return null;
// decide if draft is valid and should be returned
// ignore this draft if has any error
if (Object.keys(state.errors).length > 0) {
storageUtils.remove(TYPE, key);
return null;
}
return state;
};
export const clear = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const key = getTxDraftKey(getState);
storageUtils.remove(TYPE, key);

View File

@ -9,7 +9,7 @@ import type {
import type { State, Token } from 'reducers/TokensReducer';
import type { Account } from 'reducers/AccountsReducer';
import type { NetworkToken } from 'reducers/LocalStorageReducer';
import * as BlockchainActions from './BlockchainActions';
import * as BlockchainActions from 'actions/ethereum/BlockchainActions';
export type TokenAction = {
type: typeof TOKEN.FROM_STORAGE,

View File

@ -17,8 +17,7 @@ import type {
UiMessageType,
TransportMessage,
TransportMessageType,
BlockchainMessage,
BlockchainMessageType,
BlockchainEvent,
} from 'trezor-connect';
import type {
@ -114,13 +113,9 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS
});
});
TrezorConnect.on(BLOCKCHAIN_EVENT, (event: BlockchainMessage): void => {
// post event to reducers
const type: BlockchainMessageType = event.type; // eslint-disable-line prefer-destructuring
dispatch({
type,
payload: event.payload,
});
// post event to reducers
TrezorConnect.on(BLOCKCHAIN_EVENT, (event: BlockchainEvent): void => {
dispatch(event);
});
if (buildUtils.isDev()) {

View File

@ -137,25 +137,24 @@ export const resolvePendingTransactions = (network: string): PromiseAction<void>
const instance: Web3Instance = await dispatch(initWeb3(network));
const pending = getState().pending.filter(p => p.network === network);
pending.forEach(async (tx) => {
const status = await instance.web3.eth.getTransaction(tx.id);
const status = await instance.web3.eth.getTransaction(tx.hash);
if (!status) {
dispatch({
type: PENDING.TX_REJECTED,
tx,
hash: tx.hash,
});
} else {
const receipt = await instance.web3.eth.getTransactionReceipt(tx.id);
const receipt = await instance.web3.eth.getTransactionReceipt(tx.hash);
if (receipt) {
if (status.gas !== receipt.gasUsed) {
dispatch({
type: PENDING.TX_TOKEN_ERROR,
tx,
hash: tx.hash,
});
}
dispatch({
type: PENDING.TX_RESOLVED,
tx,
receipt,
hash: tx.hash,
});
}
}
@ -197,7 +196,11 @@ export const updateAccount = (account: Account, newAccount: EthereumAccount, net
const balance = await instance.web3.eth.getBalance(account.address);
const nonce = await instance.web3.eth.getTransactionCount(account.address);
dispatch(AccountsActions.update({
...account, ...newAccount, balance: EthereumjsUnits.convert(balance, 'wei', 'ether'), nonce,
...account,
...newAccount,
nonce,
balance: EthereumjsUnits.convert(balance, 'wei', 'ether'),
availableBalance: EthereumjsUnits.convert(balance, 'wei', 'ether'),
}));
// update tokens for this account

View File

@ -1,6 +1,4 @@
/* @flow */
export const READY: 'blockchain__ready' = 'blockchain__ready';
export const CONNECTING: 'blockchain__connecting' = 'blockchain__connecting';
export const CONNECTED: 'blockchain__connected' = 'blockchain__connected';
export const DISCONNECTED: 'blockchain__disconnected' = 'blockchain__disconnected';
export const UPDATE_FEE: 'blockchain__update_fee' = 'blockchain__update_fee';

View File

@ -3,6 +3,8 @@
export const START: 'discovery__start' = 'discovery__start';
export const STOP: 'discovery__stop' = 'discovery__stop';
export const FIRMWARE_NOT_SUPPORTED: 'discovery__fw_not_supported' = 'discovery__fw_not_supported';
export const FIRMWARE_OUTDATED: 'discovery__fw_outdated' = 'discovery__fw_outdated';
export const COMPLETE: 'discovery__complete' = 'discovery__complete';
export const WAITING_FOR_DEVICE: 'discovery__waiting_for_device' = 'discovery__waiting_for_device';
export const WAITING_FOR_BLOCKCHAIN: 'discovery__waiting_for_blockchain' = 'discovery__waiting_for_blockchain';

View File

@ -7,5 +7,4 @@ export const CREATE: 'web3__create' = 'web3__create';
export const READY: 'web3__ready' = 'web3__ready';
export const BLOCK_UPDATED: 'web3__block_updated' = 'web3__block_updated';
export const GAS_PRICE_UPDATED: 'web3__gas_price_updated' = 'web3__gas_price_updated';
export const PENDING_TX_RESOLVED: 'web3__pending_tx_resolved' = 'web3__pending_tx_resolved';
export const DISCONNECT: 'web3__disconnect' = 'web3__disconnect';

View File

@ -0,0 +1,171 @@
/* @flow */
import TrezorConnect from 'trezor-connect';
import BigNumber from 'bignumber.js';
import type {
TrezorDevice,
Dispatch,
GetState,
PromiseAction,
} from 'flowtype';
import type { EthereumAccount } from 'trezor-connect';
import type { Token } from 'reducers/TokensReducer';
import type { NetworkToken } from 'reducers/LocalStorageReducer';
import * as Web3Actions from 'actions/Web3Actions';
import * as AccountsActions from 'actions/AccountsActions';
export const discoverAccount = (device: TrezorDevice, address: string, network: string): PromiseAction<EthereumAccount> => async (dispatch: Dispatch): Promise<EthereumAccount> => {
// get data from connect
const txs = await TrezorConnect.ethereumGetAccountInfo({
account: {
address,
block: 0,
transactions: 0,
balance: '0',
nonce: 0,
},
coin: network,
});
if (!txs.success) {
throw new Error(txs.payload.error);
}
// blockbook web3 fallback
const web3account = await dispatch(Web3Actions.discoverAccount(address, network));
return {
address,
transactions: txs.payload.transactions,
block: txs.payload.block,
balance: web3account.balance,
nonce: web3account.nonce,
};
};
export const getTokenInfo = (input: string, network: string): PromiseAction<NetworkToken> => async (dispatch: Dispatch): Promise<NetworkToken> => dispatch(Web3Actions.getTokenInfo(input, network));
export const getTokenBalance = (token: Token): PromiseAction<string> => async (dispatch: Dispatch): Promise<string> => dispatch(Web3Actions.getTokenBalance(token));
export const getGasPrice = (network: string, defaultGasPrice: number): PromiseAction<BigNumber> => async (dispatch: Dispatch): Promise<BigNumber> => {
try {
const gasPrice = await dispatch(Web3Actions.getCurrentGasPrice(network));
return gasPrice === '0' ? new BigNumber(defaultGasPrice) : new BigNumber(gasPrice);
} catch (error) {
return new BigNumber(defaultGasPrice);
}
};
const estimateProxy: Array<Promise<string>> = [];
export const estimateGasLimit = (network: string, data: string, value: string, gasPrice: string): PromiseAction<string> => async (dispatch: Dispatch): Promise<string> => {
// Since this method could be called multiple times in short period of time
// check for pending calls in proxy and if there more than two (first is current running and the second is waiting for result of first)
// TODO: should reject second call immediately?
if (estimateProxy.length > 0) {
// wait for proxy result (but do not process it)
await estimateProxy[0];
}
const call = dispatch(Web3Actions.estimateGasLimit(network, {
to: '',
data,
value,
gasPrice,
}));
// add current call to proxy
estimateProxy.push(call);
// wait for result
const result = await call;
// remove current call from proxy
estimateProxy.splice(0, 1);
// return result
return result;
};
export const subscribe = (network: string): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const accounts: Array<string> = getState().accounts.filter(a => a.network === network).map(a => a.address); // eslint-disable-line no-unused-vars
await TrezorConnect.blockchainSubscribe({
accounts,
coin: network,
});
// init web3 instance if not exists
await dispatch(Web3Actions.initWeb3(network));
};
export const onBlockMined = (network: string): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
// try to resolve pending transactions
await dispatch(Web3Actions.resolvePendingTransactions(network));
await dispatch(Web3Actions.updateGasPrice(network));
const accounts: Array<any> = getState().accounts.filter(a => a.network === network);
if (accounts.length > 0) {
// find out which account changed
const response = await TrezorConnect.ethereumGetAccountInfo({
accounts,
coin: network,
});
if (response.success) {
response.payload.forEach((a, i) => {
if (a.transactions > 0) {
// load additional data from Web3 (balance, nonce, tokens)
dispatch(Web3Actions.updateAccount(accounts[i], a, network));
} else {
// there are no new txs, just update block
dispatch(AccountsActions.update({ ...accounts[i], block: a.block }));
// HACK: since blockbook can't work with smart contracts for now
// try to update tokens balances added to this account using Web3
dispatch(Web3Actions.updateAccountTokens(accounts[i]));
}
});
}
}
};
export const onNotification = (/*network: string*/): PromiseAction<void> => async (): Promise<void> => {
// todo: get transaction history here
// console.warn("OnBlAccount", account);
// this event can be triggered multiple times
// // 1. check if pair [txid + address] is already in reducer
// const network: string = payload.coin.shortcut.toLowerCase();
// const address: string = EthereumjsUtil.toChecksumAddress(payload.tx.address);
// const txInfo = await dispatch(Web3Actions.getPendingInfo(network, payload.tx.txid));
// // const exists = getState().pending.filter(p => p.id === payload.tx.txid && p.address === address);
// const exists = getState().pending.filter(p => p.address === address);
// if (exists.length < 1) {
// if (txInfo) {
// dispatch({
// type: PENDING.ADD,
// payload: {
// type: 'send',
// id: payload.tx.txid,
// network,
// currency: 'tETH',
// amount: txInfo.value,
// total: '0',
// tx: {},
// nonce: txInfo.nonce,
// address,
// rejected: false,
// },
// });
// } else {
// // tx info not found (yet?)
// // dispatch({
// // type: PENDING.ADD_UNKNOWN,
// // payload: {
// // network,
// // ...payload.tx,
// // }
// // });
// }
// }
};
export const onError = (network: string): PromiseAction<void> => async (dispatch: Dispatch): Promise<void> => {
dispatch(Web3Actions.disconnect(network));
};

View File

@ -0,0 +1,93 @@
/* @flow */
import TrezorConnect from 'trezor-connect';
import EthereumjsUtil from 'ethereumjs-util';
import * as DISCOVERY from 'actions/constants/discovery';
import * as BlockchainActions from 'actions/ethereum/BlockchainActions';
import type {
PromiseAction,
Dispatch,
TrezorDevice,
Network,
Account,
} from 'flowtype';
import type { Discovery } from 'reducers/DiscoveryReducer';
export type DiscoveryStartAction = {
type: typeof DISCOVERY.START,
networkType: 'ethereum',
network: Network,
device: TrezorDevice,
publicKey: string,
chainCode: string,
basePath: Array<number>,
};
// first iteration
// generate public key for this account
// start discovery process
export const begin = (device: TrezorDevice, network: Network): PromiseAction<DiscoveryStartAction> => async (): Promise<DiscoveryStartAction> => {
// get xpub from TREZOR
const response = await TrezorConnect.getPublicKey({
device: {
path: device.path,
instance: device.instance,
state: device.state,
},
path: network.bip44,
keepSession: true, // acquire and hold session
//useEmptyPassphrase: !device.instance,
useEmptyPassphrase: device.useEmptyPassphrase,
network: network.name,
});
// handle TREZOR response error
if (!response.success) {
throw new Error(response.payload.error);
}
const basePath: Array<number> = response.payload.path;
return {
type: DISCOVERY.START,
networkType: 'ethereum',
network,
device,
publicKey: response.payload.publicKey,
chainCode: response.payload.chainCode,
basePath,
};
};
export const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): PromiseAction<Account> => async (dispatch: Dispatch): Promise<Account> => {
const derivedKey = discoveryProcess.hdKey.derive(`m/${discoveryProcess.accountIndex}`);
const path = discoveryProcess.basePath.concat(discoveryProcess.accountIndex);
const publicAddress: string = EthereumjsUtil.publicToAddress(derivedKey.publicKey, true).toString('hex');
const ethAddress: string = EthereumjsUtil.toChecksumAddress(publicAddress);
const { network } = discoveryProcess;
// TODO: check if address was created before
const account = await dispatch(BlockchainActions.discoverAccount(device, ethAddress, network));
// const accountIsEmpty = account.transactions <= 0 && account.nonce <= 0 && account.balance === '0';
const empty = account.nonce <= 0 && account.balance === '0';
return {
index: discoveryProcess.accountIndex,
loaded: true,
network,
deviceID: device.features ? device.features.device_id : '0',
deviceState: device.state || '0',
addressPath: path,
address: ethAddress,
balance: account.balance,
availableBalance: account.balance,
sequence: account.nonce,
nonce: account.nonce,
block: account.block,
transactions: account.transactions,
empty,
};
};

View File

@ -0,0 +1,587 @@
/* @flow */
import React from 'react';
import Link from 'components/Link';
import TrezorConnect from 'trezor-connect';
import BigNumber from 'bignumber.js';
import * as ACCOUNT from 'actions/constants/account';
import * as NOTIFICATION from 'actions/constants/notification';
import * as SEND from 'actions/constants/send';
import * as PENDING from 'actions/constants/pendingTx';
import * as WEB3 from 'actions/constants/web3';
import { initialState } from 'reducers/SendFormEthereumReducer';
import { findToken } from 'reducers/TokensReducer';
import * as reducerUtils from 'reducers/utils';
import * as ethUtils from 'utils/ethUtils';
import type {
Dispatch,
GetState,
State as ReducersState,
Action,
ThunkAction,
AsyncAction,
TrezorDevice,
} from 'flowtype';
import type { State, FeeLevel } from 'reducers/SendFormEthereumReducer';
import * as SessionStorageActions from '../SessionStorageActions';
import { prepareEthereumTx, serializeEthereumTx } from '../TxActions';
import * as BlockchainActions from './BlockchainActions';
import * as ValidationActions from './SendFormValidationActions';
// list of all actions which has influence on "sendFormEthereum" reducer
// other actions will be ignored
const actions = [
ACCOUNT.UPDATE_SELECTED_ACCOUNT,
WEB3.GAS_PRICE_UPDATED,
...Object.values(SEND).filter(v => typeof v === 'string'),
];
/*
* Called from WalletService
*/
export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
// ignore not listed actions
if (actions.indexOf(action.type) < 0) return;
const currentState = getState();
// do not proceed if it's not "send" url
if (!currentState.router.location.state.send) return;
// if action type is SEND.VALIDATION which is called as result of this process
// save data to session storage
if (action.type === SEND.VALIDATION) {
dispatch(SessionStorageActions.saveDraftTransaction());
return;
}
// if send form was not initialized
if (currentState.sendFormEthereum.currency === '') {
dispatch(init());
return;
}
// handle gasPrice update from backend
// recalculate fee levels if needed
if (action.type === WEB3.GAS_PRICE_UPDATED) {
dispatch(ValidationActions.onGasPriceUpdated(action.network, action.gasPrice));
return;
}
let shouldUpdate: boolean = false;
// check if "selectedAccount" reducer changed
shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, {
account: ['balance', 'nonce', 'tokens'],
});
if (shouldUpdate && currentState.sendFormEthereum.currency !== currentState.sendFormEthereum.networkSymbol) {
// make sure that this token is added into account
const { account, tokens } = getState().selectedAccount;
if (!account) return;
const token = findToken(tokens, account.address, currentState.sendFormEthereum.currency, account.deviceState);
if (!token) {
// token not found, re-init form
dispatch(init());
return;
}
}
// check if "sendFormEthereum" reducer changed
if (!shouldUpdate) {
shouldUpdate = reducerUtils.observeChanges(prevState.sendFormEthereum, currentState.sendFormEthereum);
}
if (shouldUpdate) {
const validated = dispatch(ValidationActions.validation());
dispatch({
type: SEND.VALIDATION,
networkType: 'ethereum',
state: validated,
});
}
};
/*
* Called from "observe" action
* Initialize "sendFormEthereum" reducer data
* Get data either from session storage or "selectedAccount" reducer
*/
export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return;
const stateFromStorage = dispatch(SessionStorageActions.loadEthereumDraftTransaction());
if (stateFromStorage) {
// TODO: consider if current gasPrice should be set here as "recommendedGasPrice"
dispatch({
type: SEND.INIT,
networkType: 'ethereum',
state: stateFromStorage,
});
return;
}
const gasPrice: BigNumber = await dispatch(BlockchainActions.getGasPrice(network.shortcut, network.defaultGasPrice));
const gasLimit = network.defaultGasLimit.toString();
const feeLevels = ValidationActions.getFeeLevels(network.symbol, gasPrice, gasLimit);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel);
dispatch({
type: SEND.INIT,
networkType: 'ethereum',
state: {
...initialState,
networkName: network.shortcut,
networkSymbol: network.symbol,
currency: network.symbol,
feeLevels,
selectedFeeLevel,
recommendedGasPrice: gasPrice.toString(),
gasLimit,
gasPrice: gasPrice.toString(),
},
});
};
/*
* Called from UI from "advanced" button
*/
export const toggleAdvanced = (): Action => ({
type: SEND.TOGGLE_ADVANCED,
});
/*
* Called from UI on "address" field change
*/
export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendFormEthereum;
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
untouched: false,
touched: { ...state.touched, address: true },
address,
},
});
};
/*
* Called from UI on "amount" field change
*/
export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendFormEthereum;
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
untouched: false,
touched: { ...state.touched, amount: true },
setMax: false,
amount,
},
});
};
/*
* Called from UI on "currency" selection change
*/
export const onCurrencyChange = (currency: { value: string, label: string }): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return;
const state = getState().sendFormEthereum;
const isToken = currency.value !== state.networkSymbol;
const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString();
const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
currency: currency.value,
feeLevels,
selectedFeeLevel,
gasLimit,
},
});
};
/*
* Called from UI from "set max" button
*/
export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendFormEthereum;
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
untouched: false,
touched: { ...state.touched, amount: true },
setMax: !state.setMax,
},
});
};
/*
* Called from UI on "fee" selection change
*/
export const onFeeLevelChange = (feeLevel: FeeLevel): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendFormEthereum;
const isCustom = feeLevel.value === 'Custom';
let newGasLimit = state.gasLimit;
let newGasPrice = state.gasPrice;
const advanced = isCustom ? true : state.advanced;
if (!isCustom) {
// if selected fee is not custom
// update gasLimit to default and gasPrice to selected value
const { network } = getState().selectedAccount;
if (!network) return;
const isToken = state.currency !== state.networkSymbol;
if (isToken) {
newGasLimit = network.defaultGasLimitTokens.toString();
} else {
// corner case: gas limit was changed by user OR by "estimateGasPrice" action
// leave gasLimit as it is
newGasLimit = state.touched.gasLimit ? state.gasLimit : network.defaultGasLimit.toString();
}
newGasPrice = feeLevel.gasPrice;
}
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
advanced,
selectedFeeLevel: feeLevel,
gasLimit: newGasLimit,
gasPrice: newGasPrice,
},
});
};
/*
* Called from UI from "update recommended fees" button
*/
export const updateFeeLevels = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return;
const state: State = getState().sendFormEthereum;
const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, state.gasLimit, state.selectedFeeLevel);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
feeLevels,
selectedFeeLevel,
gasPrice: selectedFeeLevel.gasPrice,
gasPriceNeedsUpdate: false,
},
});
};
/*
* Called from UI on "gas price" field change
*/
export const onGasPriceChange = (gasPrice: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendFormEthereum;
// switch to custom fee level
let newSelectedFeeLevel = state.selectedFeeLevel;
if (state.selectedFeeLevel.value !== 'Custom') newSelectedFeeLevel = state.feeLevels.find(f => f.value === 'Custom');
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
untouched: false,
touched: { ...state.touched, gasPrice: true },
gasPrice,
selectedFeeLevel: newSelectedFeeLevel,
},
});
};
/*
* Called from UI on "data" field change
* OR from "estimateGasPrice" action
*/
export const onGasLimitChange = (gasLimit: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const { network } = getState().selectedAccount;
if (!network) return;
const state: State = getState().sendFormEthereum;
// recalculate feeLevels with recommended gasPrice
const feeLevels = ValidationActions.getFeeLevels(network.symbol, state.recommendedGasPrice, gasLimit, state.selectedFeeLevel);
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
calculatingGasLimit: false,
untouched: false,
touched: { ...state.touched, gasLimit: true },
gasLimit,
feeLevels,
selectedFeeLevel,
},
});
};
/*
* Called from UI on "nonce" field change
*/
export const onNonceChange = (nonce: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendFormEthereum;
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
untouched: false,
touched: { ...state.touched, nonce: true },
nonce,
},
});
};
/*
* Called from UI on "data" field change
*/
export const onDataChange = (data: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendFormEthereum;
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
calculatingGasLimit: true,
untouched: false,
touched: { ...state.touched, data: true },
data,
},
});
dispatch(estimateGasPrice());
};
export const setDefaultGasLimit = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendFormEthereum;
const { network } = getState().selectedAccount;
if (!network) return;
const isToken = state.currency !== state.networkSymbol;
const gasLimit = isToken ? network.defaultGasLimitTokens.toString() : network.defaultGasLimit.toString();
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
calculatingGasLimit: false,
untouched: false,
touched: { ...state.touched, gasLimit: false },
gasLimit,
},
});
};
/*
* Internal method
* Called from "onDataChange" action
* try to asynchronously download data from backend
*/
const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const state: State = getState().sendFormEthereum;
const { network } = getState().selectedAccount;
if (!network) {
// stop "calculatingGasLimit" process
dispatch(onGasLimitChange(state.gasLimit));
return;
}
const requestedData = state.data;
if (!ethUtils.isHex(requestedData)) {
// stop "calculatingGasLimit" process
dispatch(onGasLimitChange(requestedData.length > 0 ? state.gasLimit : network.defaultGasLimit.toString()));
return;
}
if (state.data.length < 1) {
// set default
dispatch(onGasLimitChange(network.defaultGasLimit.toString()));
return;
}
const gasLimit = await dispatch(BlockchainActions.estimateGasLimit(network.shortcut, state.data, state.amount, state.gasPrice));
// double check "data" field
// possible race condition when data changed before backend respond
if (getState().sendFormEthereum.data === requestedData) {
dispatch(onGasLimitChange(gasLimit));
}
};
/*
* Called from UI from "send" button
*/
export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const {
account,
network,
pending,
} = getState().selectedAccount;
if (!account || !network) return;
const currentState: State = getState().sendFormEthereum;
const isToken: boolean = currentState.currency !== currentState.networkSymbol;
const pendingNonce: number = reducerUtils.getPendingSequence(pending);
const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce;
const txData = await dispatch(prepareEthereumTx({
network: network.shortcut,
token: isToken ? findToken(getState().tokens, account.address, currentState.currency, account.deviceState) : null,
from: account.address,
to: currentState.address,
amount: currentState.amount,
data: currentState.data,
gasLimit: currentState.gasLimit,
gasPrice: currentState.gasPrice,
nonce,
}));
const selected: ?TrezorDevice = getState().wallet.selectedDevice;
if (!selected) return;
const signedTransaction = await TrezorConnect.ethereumSignTransaction({
device: {
path: selected.path,
instance: selected.instance,
state: selected.state,
},
// useEmptyPassphrase: !selected.instance,
useEmptyPassphrase: selected.useEmptyPassphrase,
path: account.addressPath,
transaction: txData,
});
if (!signedTransaction || !signedTransaction.success) {
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'error',
title: 'Transaction error',
message: signedTransaction.payload.error,
cancelable: true,
actions: [],
},
});
return;
}
txData.r = signedTransaction.payload.r;
txData.s = signedTransaction.payload.s;
txData.v = signedTransaction.payload.v;
try {
const serializedTx: string = await dispatch(serializeEthereumTx(txData));
const push = await TrezorConnect.pushTransaction({
tx: serializedTx,
coin: network.shortcut,
});
if (!push.success) {
throw new Error(push.payload.error);
}
const { txid } = push.payload;
dispatch({ type: SEND.TX_COMPLETE });
dispatch({
type: PENDING.ADD,
payload: {
type: 'send',
deviceState: account.deviceState,
sequence: nonce,
hash: txid,
network: account.network,
address: account.address,
currency: currentState.currency,
amount: currentState.amount,
total: currentState.total,
fee: '0', // TODO: calculate fee
},
});
// clear session storage
dispatch(SessionStorageActions.clear());
// reset form
dispatch(init());
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'success',
title: 'Transaction success',
message: <Link href={`${network.explorer.tx}${txid}`} isGreen>See transaction detail</Link>,
cancelable: true,
actions: [],
},
});
} catch (error) {
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'error',
title: 'Transaction error',
message: error.message || error,
cancelable: true,
actions: [],
},
});
}
};
export default {
toggleAdvanced,
onAddressChange,
onAmountChange,
onCurrencyChange,
onSetMax,
onFeeLevelChange,
updateFeeLevels,
onGasPriceChange,
onGasLimitChange,
setDefaultGasLimit,
onNonceChange,
onDataChange,
onSend,
};

View File

@ -13,7 +13,7 @@ import type {
GetState,
PayloadAction,
} from 'flowtype';
import type { State, FeeLevel } from 'reducers/SendFormReducer';
import type { State, FeeLevel } from 'reducers/SendFormEthereumReducer';
// general regular expressions
const NUMBER_RE: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$');
@ -38,7 +38,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct
// }
// const newPrice = getRandomInt(10, 50).toString();
const state = getState().sendForm;
const state = getState().sendFormEthereum;
if (network === state.networkSymbol) return;
// check if new price is different then currently recommended
@ -50,6 +50,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct
// and let him update manually
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
gasPriceNeedsUpdate: true,
@ -62,6 +63,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct
const selectedFeeLevel = getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
networkType: 'ethereum',
state: {
...state,
gasPriceNeedsUpdate: false,
@ -81,7 +83,7 @@ export const onGasPriceUpdated = (network: string, gasPrice: string): PayloadAct
export const validation = (): PayloadAction<State> => (dispatch: Dispatch, getState: GetState): State => {
// clone deep nested object
// to avoid overrides across state history
let state: State = JSON.parse(JSON.stringify(getState().sendForm));
let state: State = JSON.parse(JSON.stringify(getState().sendFormEthereum));
// reset errors
state.errors = {};
state.warnings = {};

View File

@ -0,0 +1,90 @@
/* @flow */
import TrezorConnect from 'trezor-connect';
import * as BLOCKCHAIN from 'actions/constants/blockchain';
import * as PENDING from 'actions/constants/pendingTx';
import * as AccountsActions from 'actions/AccountsActions';
import { toDecimalAmount } from 'utils/formatUtils';
import type { BlockchainNotification } from 'trezor-connect';
import type {
Dispatch,
GetState,
PromiseAction,
} from 'flowtype';
export const subscribe = (network: string): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const accounts: Array<string> = getState().accounts.filter(a => a.network === network).map(a => a.address);
await TrezorConnect.blockchainSubscribe({
accounts,
coin: network,
});
};
export const onBlockMined = (network: string): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const fee = await TrezorConnect.blockchainGetFee({
coin: network,
});
if (!fee.success) return;
const blockchain = getState().blockchain.find(b => b.shortcut === network);
if (!blockchain) return;
if (fee.payload !== blockchain.fee) {
dispatch({
type: BLOCKCHAIN.UPDATE_FEE,
shortcut: network,
fee: fee.payload,
});
}
};
export const onNotification = (payload: $ElementType<BlockchainNotification, 'payload'>): PromiseAction<void> => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const { notification } = payload;
const account = getState().accounts.find(a => a.address === notification.address);
if (!account) return;
if (notification.status === 'pending') {
dispatch({
type: PENDING.ADD,
payload: {
type: notification.type,
deviceState: account.deviceState,
sequence: account.sequence,
hash: notification.hash,
network: account.network,
address: account.address,
currency: account.network,
amount: notification.amount,
total: notification.amount,
fee: notification.fee,
},
});
// todo: replace "send success" notification with link to explorer
} else if (notification.status === 'confirmed') {
dispatch({
type: PENDING.TX_RESOLVED,
hash: notification.hash,
});
}
const updatedAccount = await TrezorConnect.rippleGetAccountInfo({
account: {
address: account.address,
block: account.block,
history: false,
},
});
if (!updatedAccount.success) return;
dispatch(AccountsActions.update({
...account,
balance: toDecimalAmount(updatedAccount.payload.balance, 6),
availableDevice: toDecimalAmount(updatedAccount.payload.availableBalance, 6),
block: updatedAccount.payload.block,
sequence: updatedAccount.payload.sequence,
}));
};

View File

@ -0,0 +1,77 @@
/* @flow */
import TrezorConnect from 'trezor-connect';
import * as DISCOVERY from 'actions/constants/discovery';
import { toDecimalAmount } from 'utils/formatUtils';
import type {
PromiseAction,
GetState,
Dispatch,
TrezorDevice,
Network,
Account,
} from 'flowtype';
import type { Discovery } from 'reducers/DiscoveryReducer';
export type DiscoveryStartAction = {
type: typeof DISCOVERY.START,
networkType: 'ripple',
network: Network,
device: TrezorDevice,
};
export const begin = (device: TrezorDevice, network: Network): PromiseAction<DiscoveryStartAction> => async (): Promise<DiscoveryStartAction> => ({
type: DISCOVERY.START,
networkType: 'ripple',
network,
device,
});
export const discoverAccount = (device: TrezorDevice, discoveryProcess: Discovery): PromiseAction<Account> => async (dispatch: Dispatch, getState: GetState): Promise<Account> => {
const { config } = getState().localStorage;
const network = config.networks.find(c => c.shortcut === discoveryProcess.network);
if (!network) throw new Error('Discovery network not found');
const { accountIndex } = discoveryProcess;
const path = network.bip44.slice(0).replace('a', accountIndex.toString());
const response = await TrezorConnect.rippleGetAccountInfo({
device: {
path: device.path,
instance: device.instance,
state: device.state,
},
account: {
path,
block: 0,
},
keepSession: true, // acquire and hold session
useEmptyPassphrase: device.useEmptyPassphrase,
});
// handle TREZOR response error
if (!response.success) {
throw new Error(response.payload.error);
}
const account = response.payload;
const empty = account.sequence <= 0 && account.balance === '0';
return {
index: discoveryProcess.accountIndex,
loaded: true,
network: network.shortcut,
deviceID: device.features ? device.features.device_id : '0',
deviceState: device.state || '0',
addressPath: account.path || [],
address: account.address,
balance: toDecimalAmount(account.balance, 6),
availableBalance: toDecimalAmount(account.availableBalance, 6),
sequence: account.sequence,
nonce: account.sequence,
block: account.block,
transactions: account.transactions,
empty,
};
};

View File

@ -0,0 +1,265 @@
/* @flow */
import React from 'react';
import Link from 'components/Link';
import TrezorConnect from 'trezor-connect';
import * as NOTIFICATION from 'actions/constants/notification';
import * as SEND from 'actions/constants/send';
import * as BLOCKCHAIN from 'actions/constants/blockchain';
import { initialState } from 'reducers/SendFormRippleReducer';
import * as reducerUtils from 'reducers/utils';
import { fromDecimalAmount } from 'utils/formatUtils';
import type {
Dispatch,
GetState,
State as ReducersState,
Action,
ThunkAction,
AsyncAction,
TrezorDevice,
} from 'flowtype';
import type { State } from 'reducers/SendFormRippleReducer';
import * as SessionStorageActions from '../SessionStorageActions';
import * as ValidationActions from './SendFormValidationActions';
/*
* Called from WalletService
*/
export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const currentState = getState();
// if action type is SEND.VALIDATION which is called as result of this process
// save data to session storage
if (action.type === SEND.VALIDATION) {
dispatch(SessionStorageActions.saveDraftTransaction());
return;
}
// if send form was not initialized
if (currentState.sendFormRipple.networkSymbol === '') {
dispatch(init());
return;
}
// handle gasPrice update from backend
// recalculate fee levels if needed
if (action.type === BLOCKCHAIN.UPDATE_FEE) {
dispatch(ValidationActions.onFeeUpdated(action.shortcut, action.fee));
return;
}
let shouldUpdate: boolean = false;
// check if "selectedAccount" reducer changed
shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, {
account: ['balance', 'nonce'],
});
// check if "sendForm" reducer changed
if (!shouldUpdate) {
shouldUpdate = reducerUtils.observeChanges(prevState.sendFormRipple, currentState.sendFormRipple);
}
if (shouldUpdate) {
const validated = dispatch(ValidationActions.validation());
dispatch({
type: SEND.VALIDATION,
networkType: 'ripple',
state: validated,
});
}
};
/*
* Called from "observe" action
* Initialize "sendFormRipple" reducer data
* Get data either from session storage or "selectedAccount" reducer
*/
export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return;
const stateFromStorage = dispatch(SessionStorageActions.loadRippleDraftTransaction());
if (stateFromStorage) {
dispatch({
type: SEND.INIT,
networkType: 'ripple',
state: stateFromStorage,
});
return;
}
const feeLevels = dispatch(ValidationActions.getFeeLevels(network.symbol));
const selectedFeeLevel = ValidationActions.getSelectedFeeLevel(feeLevels, initialState.selectedFeeLevel);
dispatch({
type: SEND.INIT,
networkType: 'ripple',
state: {
...initialState,
networkName: network.shortcut,
networkSymbol: network.symbol,
feeLevels,
selectedFeeLevel,
sequence: '1',
},
});
};
/*
* Called from UI on "address" field change
*/
export const onAddressChange = (address: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state: State = getState().sendFormRipple;
dispatch({
type: SEND.CHANGE,
networkType: 'ripple',
state: {
...state,
untouched: false,
touched: { ...state.touched, address: true },
address,
},
});
};
/*
* Called from UI on "amount" field change
*/
export const onAmountChange = (amount: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendFormRipple;
dispatch({
type: SEND.CHANGE,
networkType: 'ripple',
state: {
...state,
untouched: false,
touched: { ...state.touched, amount: true },
setMax: false,
amount,
},
});
};
/*
* Called from UI from "set max" button
*/
export const onSetMax = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendFormRipple;
dispatch({
type: SEND.CHANGE,
networkType: 'ripple',
state: {
...state,
untouched: false,
touched: { ...state.touched, amount: true },
setMax: !state.setMax,
},
});
};
/*
* Called from UI from "send" button
*/
export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const {
account,
network,
} = getState().selectedAccount;
const selected: ?TrezorDevice = getState().wallet.selectedDevice;
if (!selected) return;
if (!account || !network) return;
const blockchain = getState().blockchain.find(b => b.shortcut === account.network);
if (!blockchain) return;
const currentState: State = getState().sendFormRipple;
const amount = fromDecimalAmount(currentState.amount, 6);
const signedTransaction = await TrezorConnect.rippleSignTransaction({
device: {
path: selected.path,
instance: selected.instance,
state: selected.state,
},
useEmptyPassphrase: selected.useEmptyPassphrase,
path: account.addressPath,
transaction: {
fee: blockchain.fee, // Fee must be in the range of 10 to 10,000 drops
flags: 0x80000000,
sequence: account.sequence,
payment: {
amount,
destination: currentState.address,
},
},
});
if (!signedTransaction || !signedTransaction.success) {
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'error',
title: 'Transaction error',
message: signedTransaction.payload.error,
cancelable: true,
actions: [],
},
});
return;
}
const push = await TrezorConnect.pushTransaction({
tx: signedTransaction.payload.serializedTx,
coin: network.shortcut,
});
if (!push.success) {
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'error',
title: 'Transaction error',
message: push.payload.error,
cancelable: true,
actions: [],
},
});
return;
}
const { txid } = push.payload;
dispatch({ type: SEND.TX_COMPLETE });
// clear session storage
dispatch(SessionStorageActions.clear());
// reset form
dispatch(init());
dispatch({
type: NOTIFICATION.ADD,
payload: {
type: 'success',
title: 'Transaction success',
message: <Link href={`${network.explorer.tx}${txid}`} isGreen>See transaction detail</Link>,
cancelable: true,
actions: [],
},
});
};
export default {
onAddressChange,
onAmountChange,
onSetMax,
onSend,
};

View File

@ -0,0 +1,254 @@
/* @flow */
import BigNumber from 'bignumber.js';
import * as SEND from 'actions/constants/send';
import { findDevice, getPendingAmount } from 'reducers/utils';
import { toDecimalAmount } from 'utils/formatUtils';
import type {
Dispatch,
GetState,
PayloadAction,
} from 'flowtype';
import type { State, FeeLevel } from 'reducers/SendFormRippleReducer';
// general regular expressions
const XRP_ADDRESS_RE = new RegExp('^r[1-9A-HJ-NP-Za-km-z]{25,34}$');
const NUMBER_RE: RegExp = new RegExp('^(0|0\\.([0-9]+)?|[1-9][0-9]*\\.?([0-9]+)?|\\.[0-9]+)$');
const XRP_6_RE = new RegExp('^(0|0\\.([0-9]{0,6})?|[1-9][0-9]*\\.?([0-9]{0,6})?|\\.[0-9]{0,6})$');
/*
* Called from SendFormActions.observe
* Reaction for BLOCKCHAIN.FEE_UPDATED action
*/
export const onFeeUpdated = (network: string, fee: string): PayloadAction<void> => (dispatch: Dispatch, getState: GetState): void => {
const state = getState().sendFormRipple;
if (network === state.networkSymbol) return;
if (!state.untouched) {
// if there is a transaction draft let the user know
// and let him update manually
dispatch({
type: SEND.CHANGE,
networkType: 'ripple',
state: {
...state,
feeNeedsUpdate: true,
recommendedFee: fee,
},
});
return;
}
// automatically update feeLevels and gasPrice
const feeLevels = dispatch(getFeeLevels(state.networkSymbol));
const selectedFeeLevel = getSelectedFeeLevel(feeLevels, state.selectedFeeLevel);
dispatch({
type: SEND.CHANGE,
networkType: 'ripple',
state: {
...state,
feeNeedsUpdate: false,
recommendedFee: fee,
gasPrice: selectedFeeLevel.gasPrice,
feeLevels,
selectedFeeLevel,
},
});
};
/*
* Recalculate amount, total and fees
*/
export const validation = (): PayloadAction<State> => (dispatch: Dispatch, getState: GetState): State => {
// clone deep nested object
// to avoid overrides across state history
let state: State = JSON.parse(JSON.stringify(getState().sendFormRipple));
// reset errors
state.errors = {};
state.warnings = {};
state.infos = {};
state = dispatch(recalculateTotalAmount(state));
state = dispatch(addressValidation(state));
state = dispatch(addressLabel(state));
state = dispatch(amountValidation(state));
return state;
};
const recalculateTotalAmount = ($state: State): PayloadAction<State> => (dispatch: Dispatch, getState: GetState): State => {
const {
account,
pending,
} = getState().selectedAccount;
if (!account) return $state;
const blockchain = getState().blockchain.find(b => b.shortcut === account.network);
if (!blockchain) return $state;
const fee = toDecimalAmount(blockchain.fee, 6);
const state = { ...$state };
if (state.setMax) {
const pendingAmount = getPendingAmount(pending, state.networkSymbol, false);
const b = new BigNumber(account.balance).minus(pendingAmount);
state.amount = calculateMaxAmount(b, fee);
}
state.total = calculateTotal(state.amount, fee);
return state;
};
/*
* Address value validation
*/
const addressValidation = ($state: State): PayloadAction<State> => (dispatch: Dispatch, getState: GetState): State => {
const state = { ...$state };
if (!state.touched.address) return state;
const { account, network } = getState().selectedAccount;
if (!account || !network) return state;
const { address } = state;
if (address.length < 1) {
state.errors.address = 'Address is not set';
} else if (!address.match(XRP_ADDRESS_RE)) {
state.errors.address = 'Address is not valid';
} else if (address.toLowerCase() === account.address.toLowerCase()) {
state.errors.address = 'Cannot send to myself';
}
return state;
};
/*
* Address label assignation
*/
const addressLabel = ($state: State): PayloadAction<State> => (dispatch: Dispatch, getState: GetState): State => {
const state = { ...$state };
if (!state.touched.address || state.errors.address) return state;
const {
account,
network,
} = getState().selectedAccount;
if (!account || !network) return state;
const { address } = state;
const savedAccounts = getState().accounts.filter(a => a.address.toLowerCase() === address.toLowerCase());
if (savedAccounts.length > 0) {
// check if found account belongs to this network
const currentNetworkAccount = savedAccounts.find(a => a.network === network.shortcut);
if (currentNetworkAccount) {
const device = findDevice(getState().devices, currentNetworkAccount.deviceID, currentNetworkAccount.deviceState);
if (device) {
state.infos.address = `${device.instanceLabel} Account #${(currentNetworkAccount.index + 1)}`;
}
} else {
// corner-case: the same derivation path is used on different networks
const otherNetworkAccount = savedAccounts[0];
const device = findDevice(getState().devices, otherNetworkAccount.deviceID, otherNetworkAccount.deviceState);
const { networks } = getState().localStorage.config;
const otherNetwork = networks.find(c => c.shortcut === otherNetworkAccount.network);
if (device && otherNetwork) {
state.warnings.address = `Looks like it's ${device.instanceLabel} Account #${(otherNetworkAccount.index + 1)} address of ${otherNetwork.name} network`;
}
}
}
return state;
};
/*
* Amount value validation
*/
const amountValidation = ($state: State): PayloadAction<State> => (dispatch: Dispatch, getState: GetState): State => {
const state = { ...$state };
if (!state.touched.amount) return state;
const {
account,
pending,
} = getState().selectedAccount;
if (!account) return state;
const { amount } = state;
if (amount.length < 1) {
state.errors.amount = 'Amount is not set';
} else if (amount.length > 0 && !amount.match(NUMBER_RE)) {
state.errors.amount = 'Amount is not a number';
} else {
const pendingAmount: BigNumber = getPendingAmount(pending, state.networkSymbol);
if (!state.amount.match(XRP_6_RE)) {
state.errors.amount = 'Maximum 6 decimals allowed';
} else if (new BigNumber(state.total).greaterThan(new BigNumber(account.balance).minus(pendingAmount))) {
state.errors.amount = 'Not enough funds';
}
}
return state;
};
/*
* UTILITIES
*/
const calculateTotal = (amount: string, fee: string): string => {
try {
return new BigNumber(amount).plus(fee).toString(10);
} catch (error) {
return '0';
}
};
const calculateMaxAmount = (balance: BigNumber, fee: string): string => {
try {
// TODO - minus pendings
const max = balance.minus(fee);
if (max.lessThan(0)) return '0';
return max.toString(10);
} catch (error) {
return '0';
}
};
export const getFeeLevels = (symbol: string): PayloadAction<Array<FeeLevel>> => (dispatch: Dispatch, getState: GetState): Array<FeeLevel> => {
const blockchain = getState().blockchain.find(b => b.shortcut === symbol.toLowerCase());
if (!blockchain) {
// return default fee levels (TODO: get them from config)
return [{
value: 'Normal',
gasPrice: '0.000012',
label: `0.000012 ${symbol}`,
}];
}
const xrpDrops = toDecimalAmount(blockchain.fee, 6);
// TODO: calc fee levels
return [{
value: 'Normal',
gasPrice: xrpDrops,
label: `${xrpDrops} ${symbol}`,
}];
};
// export const getFeeLevels = (shortcut: string): Array<FeeLevel> => ([
// {
// value: 'Normal',
// gasPrice: '1',
// label: `1 ${shortcut}`,
// },
// ]);
export const getSelectedFeeLevel = (feeLevels: Array<FeeLevel>, selected: FeeLevel): FeeLevel => {
const { value } = selected;
let selectedFeeLevel: ?FeeLevel;
selectedFeeLevel = feeLevels.find(f => f.value === value);
if (!selectedFeeLevel) {
// fallback to default
selectedFeeLevel = feeLevels.find(f => f.value === 'Normal');
}
return selectedFeeLevel || selected;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -11,7 +11,7 @@ import type { State, Dispatch } from 'flowtype';
import Modal from './index';
type OwnProps = { }
type OwnProps = {};
type StateProps = {
modal: $ElementType<State, 'modal'>,
@ -19,16 +19,17 @@ type StateProps = {
devices: $ElementType<State, 'devices'>,
connect: $ElementType<State, 'connect'>,
selectedAccount: $ElementType<State, 'selectedAccount'>,
sendForm: $ElementType<State, 'sendForm'>,
sendFormEthereum: $ElementType<State, 'sendFormEthereum'>,
sendFormRipple: $ElementType<State, 'sendFormRipple'>,
receive: $ElementType<State, 'receive'>,
localStorage: $ElementType<State, 'localStorage'>,
wallet: $ElementType<State, 'wallet'>,
}
};
type DispatchProps = {
modalActions: typeof ModalActions,
receiveActions: typeof ReceiveActions,
}
};
export type Props = StateProps & DispatchProps;
@ -38,7 +39,8 @@ const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: St
devices: state.devices,
connect: state.connect,
selectedAccount: state.selectedAccount,
sendForm: state.sendForm,
sendFormEthereum: state.sendFormEthereum,
sendFormRipple: state.sendFormRipple,
receive: state.receive,
localStorage: state.localStorage,
wallet: state.wallet,

View File

@ -12,12 +12,11 @@ import P from 'components/Paragraph';
import Icon from 'components/Icon';
import { H3 } from 'components/Heading';
import type { TrezorDevice } from 'flowtype';
import type { Props as BaseProps } from '../../Container';
import type { TrezorDevice, State } from 'flowtype';
type Props = {
device: TrezorDevice;
sendForm: $ElementType<BaseProps, 'sendForm'>;
sendForm: $ElementType<State, 'sendFormEthereum'> | $ElementType<State, 'sendFormRipple'>;
}
const Wrapper = styled.div`
@ -51,10 +50,11 @@ const ConfirmSignTx = (props: Props) => {
const {
amount,
address,
currency,
selectedFeeLevel,
} = props.sendForm;
const currency: string = typeof props.sendForm.currency === 'string' ? props.sendForm.currency : props.sendForm.networkSymbol;
return (
<Wrapper>
<Header>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1,70 @@
/* @flow */
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import colors from 'config/colors';
import icons from 'config/icons';
import Icon from 'components/Icon';
import Link from 'components/Link';
import Button from 'components/Button';
import { H2 } from 'components/Heading';
import P from 'components/Paragraph';
import coins from 'constants/coins';
import CardanoImage from './images/cardano.png';
import type { Props as BaseProps } from '../../Container';
type Props = {
onCancel: $ElementType<$ElementType<BaseProps, 'modalActions'>, 'onCancel'>;
}
const Wrapper = styled.div`
width: 100%;
max-width: 620px;
padding: 30px 48px;
`;
const StyledButton = styled(Button)`
margin: 10px 0 10px 0;
width: 100%;
`;
const StyledLink = styled(Link)`
position: absolute;
right: 15px;
top: 10px;
`;
const Img = styled.img`
display: block;
max-width: 100px;
margin: 0 auto;
height: auto;
padding-bottom: 20px;
`;
const CardanoWallet = (props: Props) => (
<Wrapper>
<StyledLink onClick={props.onCancel}>
<Icon
size={20}
color={colors.TEXT_SECONDARY}
icon={icons.CLOSE}
/>
</StyledLink>
<Img src={CardanoImage} />
<H2>Cardano wallet</H2>
<P isSmaller>You will be redirected to external wallet</P>
<Link href={coins.find(i => i.id === 'ada').url}>
<StyledButton>Go to external wallet</StyledButton>
</Link>
</Wrapper>
);
CardanoWallet.propTypes = {
onCancel: PropTypes.func.isRequired,
};
export default CardanoWallet;

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -0,0 +1,70 @@
/* @flow */
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import colors from 'config/colors';
import icons from 'config/icons';
import Icon from 'components/Icon';
import Link from 'components/Link';
import Button from 'components/Button';
import { H2 } from 'components/Heading';
import P from 'components/Paragraph';
import coins from 'constants/coins';
import StellarImage from './images/xlm.png';
import type { Props as BaseProps } from '../../Container';
type Props = {
onCancel: $ElementType<$ElementType<BaseProps, 'modalActions'>, 'onCancel'>;
}
const Wrapper = styled.div`
width: 100%;
max-width: 620px;
padding: 30px 48px;
`;
const StyledButton = styled(Button)`
margin: 10px 0 10px 0;
width: 100%;
`;
const StyledLink = styled(Link)`
position: absolute;
right: 15px;
top: 10px;
`;
const Img = styled.img`
display: block;
max-width: 100px;
margin: 0 auto;
height: auto;
padding-bottom: 20px;
`;
const StellarWallet = (props: Props) => (
<Wrapper>
<StyledLink onClick={props.onCancel}>
<Icon
size={20}
color={colors.TEXT_SECONDARY}
icon={icons.CLOSE}
/>
</StyledLink>
<Img src={StellarImage} />
<H2>Stellar wallet</H2>
<P isSmaller>You will be redirected to external wallet</P>
<Link href={coins.find(i => i.id === 'xlm').url}>
<StyledButton>Go to external wallet</StyledButton>
</Link>
</Wrapper>
);
StellarWallet.propTypes = {
onCancel: PropTypes.func.isRequired,
};
export default StellarWallet;

View File

@ -1,10 +1,10 @@
/* @flow */
import * as React from 'react';
import { CSSTransition } from 'react-transition-group';
import styled from 'styled-components';
import colors from 'config/colors';
import { FADE_IN } from 'config/animations';
import { UI } from 'trezor-connect';
import * as MODAL from 'actions/constants/modal';
@ -25,19 +25,12 @@ import DuplicateDevice from 'components/modals/device/Duplicate';
import WalletType from 'components/modals/device/WalletType';
// external context
import NemWallet from 'components/modals/external/NemWallet';
import Nem from 'components/modals/external/Nem';
import Cardano from 'components/modals/external/Cardano';
import Stellar from 'components/modals/external/Stellar';
import type { Props } from './Container';
const Fade = (props: { children: React.Node}) => (
<CSSTransition
{...props}
timeout={1000}
classNames="fade"
>{ props.children }
</CSSTransition>
);
const ModalContainer = styled.div`
position: fixed;
z-index: 10000;
@ -51,6 +44,7 @@ const ModalContainer = styled.div`
align-items: center;
overflow: auto;
padding: 20px;
animation: ${FADE_IN} 0.3s;
`;
const ModalWindow = styled.div`
@ -88,13 +82,22 @@ const getDeviceContextModal = (props: Props) => {
case 'ButtonRequest_PassphraseType':
return <PassphraseType device={modal.device} />;
case 'ButtonRequest_SignTx':
return <ConfirmSignTx device={modal.device} sendForm={props.sendForm} />;
case 'ButtonRequest_SignTx': {
if (!props.selectedAccount.network) return null;
switch (props.selectedAccount.network.type) {
case 'ethereum':
return <ConfirmSignTx device={modal.device} sendForm={props.sendFormEthereum} />;
case 'ripple':
return <ConfirmSignTx device={modal.device} sendForm={props.sendFormRipple} />;
default: return null;
}
}
case 'ButtonRequest_ProtectCall':
return <ConfirmAction />;
case 'ButtonRequest_Other':
case 'ButtonRequest_ConfirmOutput':
return <ConfirmAction />;
case RECEIVE.REQUEST_UNVERIFIED:
@ -153,7 +156,11 @@ const getExternalContextModal = (props: Props) => {
switch (modal.windowType) {
case 'xem':
return (<NemWallet onCancel={modalActions.onCancel} />);
return (<Nem onCancel={modalActions.onCancel} />);
case 'xlm':
return (<Stellar onCancel={modalActions.onCancel} />);
case 'ada':
return (<Cardano onCancel={modalActions.onCancel} />);
default:
return null;
}
@ -177,13 +184,11 @@ const Modal = (props: Props) => {
}
return (
<Fade key="modal-fade">
<ModalContainer>
<ModalWindow>
{ component }
</ModalWindow>
</ModalContainer>
</Fade>
<ModalContainer>
<ModalWindow>
{ component }
</ModalWindow>
</ModalContainer>
);
};

View File

@ -56,4 +56,13 @@ export const PULSATE = keyframes`
50% {
opacity: 1.0;
}
`;
export const FADE_IN = keyframes`
0% {
opacity: 0;
}
100% {
opacity: 1;
}
`;

View File

@ -55,4 +55,16 @@ export default [
url: 'https://nem.io/downloads/',
external: true,
},
{
id: 'xlm',
coinName: 'Stellar',
url: 'https://trezor.io/stellar',
external: true,
},
{
id: 'ada',
coinName: 'Cardano',
url: 'https://adalite.io/app',
external: true,
},
];

View File

@ -42,8 +42,8 @@ import type {
DeviceMode,
DeviceMessageType,
TransportMessageType,
BlockchainMessageType,
UiMessageType,
BlockchainEvent,
} from 'trezor-connect';
import type { RouterAction, LocationState } from 'react-router-redux';
@ -111,11 +111,6 @@ type UiEventAction = {
// },
}
type BlockchainEventAction = {
type: BlockchainMessageType,
payload: any,
}
// TODO: join this message with uiMessage
type IFrameHandshake = {
type: 'iframe_handshake',
@ -128,7 +123,7 @@ export type Action =
| TransportEventAction
| DeviceEventAction
| UiEventAction
| BlockchainEventAction
| BlockchainEvent
| SelectedAccountAction
| AccountAction

View File

@ -20,9 +20,12 @@ export type Account = {
+addressPath: Array<number>;
+address: string;
balance: string;
availableBalance: string;
sequence: number;
nonce: number;
block: number;
transactions: number;
empty: boolean;
}
export type State = Array<Account>;

View File

@ -1,59 +1,94 @@
/* @flow */
import { BLOCKCHAIN } from 'trezor-connect';
import { BLOCKCHAIN as BLOCKCHAIN_EVENT } from 'trezor-connect';
import * as BLOCKCHAIN_ACTION from 'actions/constants/blockchain';
import type { Action } from 'flowtype';
import type { BlockchainConnect, BlockchainError, BlockchainBlock } from 'trezor-connect';
export type BlockchainNetwork = {
+shortcut: string;
connected: boolean;
}
+shortcut: string,
connected: boolean,
fee: string,
block: number,
};
export type State = Array<BlockchainNetwork>;
export const initialState: State = [];
const find = (state: State, shortcut: string): number => state.findIndex(b => b.shortcut === shortcut);
const connect = (state: State, action: any): State => {
const onConnect = (state: State, action: BlockchainConnect): State => {
const shortcut = action.payload.coin.shortcut.toLowerCase();
const network: BlockchainNetwork = {
const network = state.find(b => b.shortcut === shortcut);
const { info } = action.payload;
if (network) {
const others = state.filter(b => b !== network);
return others.concat([{
...network,
connected: true,
fee: info.fee,
block: info.block,
}]);
}
return state.concat([{
shortcut,
connected: true,
};
const newState: State = [...state];
const index: number = find(newState, shortcut);
if (index >= 0) {
newState[index] = network;
} else {
newState.push(network);
}
return newState;
fee: info.fee,
block: info.block,
}]);
};
const disconnect = (state: State, action: any): State => {
const onError = (state: State, action: BlockchainError): State => {
const shortcut = action.payload.coin.shortcut.toLowerCase();
const network: BlockchainNetwork = {
shortcut,
connected: false,
};
const newState: State = [...state];
const index: number = find(newState, shortcut);
if (index >= 0) {
newState[index] = network;
} else {
newState.push(network);
const network = state.find(b => b.shortcut === shortcut);
if (network) {
const others = state.filter(b => b !== network);
return others.concat([{
...network,
connected: false,
}]);
}
return newState;
return state;
};
const onBlock = (state: State, action: BlockchainBlock): State => {
const shortcut = action.payload.coin.shortcut.toLowerCase();
const network = state.find(b => b.shortcut === shortcut);
if (network) {
const others = state.filter(b => b !== network);
return others.concat([{
...network,
block: action.payload.block,
}]);
}
return state;
};
const updateFee = (state: State, shortcut: string, fee: string): State => {
const network = state.find(b => b.shortcut === shortcut);
if (!network) return state;
const others = state.filter(b => b !== network);
return others.concat([{
...network,
fee,
}]);
};
export default (state: State = initialState, action: Action): State => {
switch (action.type) {
case BLOCKCHAIN.CONNECT:
return connect(state, action);
case BLOCKCHAIN.ERROR:
return disconnect(state, action);
case BLOCKCHAIN_EVENT.CONNECT:
return onConnect(state, action);
case BLOCKCHAIN_EVENT.ERROR:
return onError(state, action);
case BLOCKCHAIN_EVENT.BLOCK:
return onBlock(state, action);
case BLOCKCHAIN_ACTION.UPDATE_FEE:
return updateFee(state, action.shortcut, action.fee);
default:
return state;

View File

@ -19,9 +19,6 @@ import type { Account } from './AccountsReducer';
export type Discovery = {
network: string;
publicKey: string;
chainCode: string;
hdKey: HDKey;
basePath: Array<number>;
deviceState: string;
accountIndex: number;
@ -29,34 +26,57 @@ export type Discovery = {
completed: boolean;
waitingForDevice: boolean;
waitingForBlockchain: boolean;
}
fwNotSupported: boolean;
fwOutdated: boolean;
publicKey: string; // used in ethereum only
chainCode: string; // used in ethereum only
hdKey: HDKey; // used in ethereum only
};
export type State = Array<Discovery>;
const initialState: State = [];
const defaultDiscovery: Discovery = {
network: '',
deviceState: '',
basePath: [],
accountIndex: 0,
interrupted: false,
completed: false,
waitingForDevice: false,
waitingForBlockchain: false,
fwNotSupported: false,
fwOutdated: false,
publicKey: '',
chainCode: '',
hdKey: null,
};
const findIndex = (state: State, network: string, deviceState: string): number => state.findIndex(d => d.network === network && d.deviceState === deviceState);
const start = (state: State, action: DiscoveryStartAction): State => {
const deviceState: string = action.device.state || '0';
const hdKey: HDKey = new HDKey();
hdKey.publicKey = Buffer.from(action.publicKey, 'hex');
hdKey.chainCode = Buffer.from(action.chainCode, 'hex');
const instance: Discovery = {
network: action.network,
publicKey: action.publicKey,
chainCode: action.chainCode,
hdKey,
basePath: action.basePath,
...defaultDiscovery,
network: action.network.shortcut,
deviceState,
accountIndex: 0,
interrupted: false,
completed: false,
waitingForDevice: false,
waitingForBlockchain: false,
};
if (action.networkType === 'ethereum') {
const hdKey = new HDKey();
hdKey.publicKey = Buffer.from(action.publicKey, 'hex');
hdKey.chainCode = Buffer.from(action.chainCode, 'hex');
instance.hdKey = hdKey;
instance.publicKey = action.publicKey;
instance.chainCode = action.chainCode;
instance.basePath = action.basePath;
}
const newState: State = [...state];
const index: number = findIndex(state, action.network, deviceState);
const index: number = findIndex(state, action.network.shortcut, deviceState);
if (index >= 0) {
newState[index] = instance;
} else {
@ -105,17 +125,10 @@ const stop = (state: State, device: TrezorDevice): State => {
const waitingForDevice = (state: State, action: DiscoveryWaitingAction): State => {
const deviceState: string = action.device.state || '0';
const instance: Discovery = {
...defaultDiscovery,
network: action.network,
deviceState,
publicKey: '',
chainCode: '',
hdKey: null,
basePath: [],
accountIndex: 0,
interrupted: false,
completed: false,
waitingForDevice: true,
waitingForBlockchain: false,
};
const index: number = findIndex(state, action.network, deviceState);
@ -132,16 +145,9 @@ const waitingForDevice = (state: State, action: DiscoveryWaitingAction): State =
const waitingForBlockchain = (state: State, action: DiscoveryWaitingAction): State => {
const deviceState: string = action.device.state || '0';
const instance: Discovery = {
...defaultDiscovery,
network: action.network,
deviceState,
publicKey: '',
chainCode: '',
hdKey: null,
basePath: [],
accountIndex: 0,
interrupted: false,
completed: false,
waitingForDevice: false,
waitingForBlockchain: true,
};
@ -156,6 +162,19 @@ const waitingForBlockchain = (state: State, action: DiscoveryWaitingAction): Sta
return newState;
};
const notSupported = (state: State, action: DiscoveryWaitingAction): State => {
const affectedProcesses = state.filter(d => d.deviceState === action.device.state && d.network === action.network);
const otherProcesses = state.filter(d => affectedProcesses.indexOf(d) === -1);
const changedProcesses = affectedProcesses.map(d => ({
...d,
fwOutdated: action.type === DISCOVERY.FIRMWARE_OUTDATED,
fwNotSupported: action.type === DISCOVERY.FIRMWARE_NOT_SUPPORTED,
}));
return otherProcesses.concat(changedProcesses);
};
export default function discovery(state: State = initialState, action: Action): State {
switch (action.type) {
case DISCOVERY.START:
@ -170,6 +189,10 @@ export default function discovery(state: State = initialState, action: Action):
return waitingForDevice(state, action);
case DISCOVERY.WAITING_FOR_BLOCKCHAIN:
return waitingForBlockchain(state, action);
case DISCOVERY.FIRMWARE_NOT_SUPPORTED:
return notSupported(state, action);
case DISCOVERY.FIRMWARE_OUTDATED:
return notSupported(state, action);
case DISCOVERY.FROM_STORAGE:
return action.payload.map((d) => {
const hdKey: HDKey = new HDKey();

View File

@ -6,7 +6,9 @@ import * as STORAGE from 'actions/constants/localStorage';
import type { Action } from 'flowtype';
export type Network = {
type: string;
name: string;
testnet?: boolean;
shortcut: string;
symbol: string;
bip44: string;

View File

@ -1,62 +1,39 @@
/* @flow */
import * as CONNECT from 'actions/constants/TrezorConnect';
import * as PENDING from 'actions/constants/pendingTx';
import * as SEND from 'actions/constants/send';
import type { TrezorDevice, Action } from 'flowtype';
import type { SendTxAction } from 'actions/SendFormActions';
import type { Action } from 'flowtype';
export type PendingTx = {
+type: 'send' | 'receive';
+id: string;
+network: string;
+address: string;
+deviceState: string;
+currency: string;
+amount: string;
+total: string;
+tx: any;
+nonce: number;
rejected: boolean;
}
+type: 'send' | 'recv',
+deviceState: string,
+sequence: number,
+hash: string,
+network: string,
+address: string,
+currency: string,
+amount: string,
+total: string,
+fee: string,
rejected?: boolean,
};
export type State = Array<PendingTx>;
const initialState: State = [];
const add = (state: State, action: SendTxAction): State => {
const newState = [...state];
newState.push({
type: 'send',
id: action.txid,
network: action.account.network,
address: action.account.address,
deviceState: action.account.deviceState,
currency: action.selectedCurrency,
amount: action.amount,
total: action.total,
tx: action.tx,
nonce: action.nonce,
rejected: false,
});
return newState;
};
/*
const addFromBloockbokNotifiaction = (state: State, payload: any): State => {
const add = (state: State, payload: PendingTx): State => {
const newState = [...state];
newState.push(payload);
return newState;
};
*/
const clear = (state: State, device: TrezorDevice): State => state.filter(tx => tx.deviceState !== device.state);
const removeByDeviceState = (state: State, deviceState: ?string): State => state.filter(tx => tx.deviceState !== deviceState);
const remove = (state: State, id: string): State => state.filter(tx => tx.id !== id);
const removeByHash = (state: State, hash: string): State => state.filter(tx => tx.hash !== hash);
const reject = (state: State, id: string): State => state.map((tx) => {
if (tx.id === id && !tx.rejected) {
const reject = (state: State, hash: string): State => state.map((tx) => {
if (tx.hash === hash && !tx.rejected) {
return { ...tx, rejected: true };
}
return tx;
@ -64,21 +41,18 @@ const reject = (state: State, id: string): State => state.map((tx) => {
export default function pending(state: State = initialState, action: Action): State {
switch (action.type) {
case SEND.TX_COMPLETE:
return add(state, action);
case CONNECT.FORGET:
case CONNECT.FORGET_SINGLE:
case CONNECT.FORGET_SILENT:
case CONNECT.RECEIVE_WALLET_TYPE:
return clear(state, action.device);
return removeByDeviceState(state, action.device.state);
// case PENDING.ADD:
// return add(state, action.payload);
case PENDING.ADD:
return add(state, action.payload);
case PENDING.TX_RESOLVED:
return remove(state, action.tx.id);
return removeByHash(state, action.hash);
case PENDING.TX_REJECTED:
return reject(state, action.tx.id);
return reject(state, action.hash);
case PENDING.FROM_STORAGE:
return action.payload;

View File

@ -75,16 +75,15 @@ export const initialState: State = {
};
export default (state: State = initialState, action: Action): State => {
if (action.type === ACCOUNT.DISPOSE) return initialState;
if (!action.networkType || action.networkType !== 'ethereum') return state;
switch (action.type) {
case SEND.INIT:
case SEND.CHANGE:
case SEND.VALIDATION:
return action.state;
case ACCOUNT.DISPOSE:
return initialState;
case SEND.TOGGLE_ADVANCED:
return {
...state,

View File

@ -0,0 +1,99 @@
/* @flow */
import * as SEND from 'actions/constants/send';
import * as ACCOUNT from 'actions/constants/account';
import type { Action } from 'flowtype';
export type FeeLevel = {
label: string;
gasPrice: string;
value: string;
}
export type State = {
+networkName: string;
+networkSymbol: string;
// form fields
advanced: boolean;
untouched: boolean; // set to true when user made any changes in form
touched: {[k: string]: boolean};
address: string;
amount: string;
setMax: boolean;
feeLevels: Array<FeeLevel>;
selectedFeeLevel: FeeLevel;
recommendedFee: string;
feeNeedsUpdate: boolean;
sequence: string;
total: string;
errors: {[k: string]: string};
warnings: {[k: string]: string};
infos: {[k: string]: string};
sending: boolean;
}
export const initialState: State = {
networkName: '',
networkSymbol: '',
advanced: false,
untouched: true,
touched: {},
address: '',
amount: '',
setMax: false,
feeLevels: [],
selectedFeeLevel: {
label: 'Normal',
gasPrice: '0',
value: 'Normal',
},
recommendedFee: '0',
feeNeedsUpdate: false,
sequence: '0',
total: '0',
errors: {},
warnings: {},
infos: {},
sending: false,
};
export default (state: State = initialState, action: Action): State => {
if (action.type === ACCOUNT.DISPOSE) return initialState;
if (!action.networkType || action.networkType !== 'ripple') return state;
switch (action.type) {
case SEND.INIT:
case SEND.CHANGE:
case SEND.VALIDATION:
return action.state;
case SEND.TOGGLE_ADVANCED:
return {
...state,
advanced: !state.advanced,
};
case SEND.TX_SENDING:
return {
...state,
sending: true,
};
case SEND.TX_ERROR:
return {
...state,
sending: false,
};
default:
return state;
}
};

View File

@ -10,7 +10,8 @@ import modal from 'reducers/ModalReducer';
import web3 from 'reducers/Web3Reducer';
import accounts from 'reducers/AccountsReducer';
import selectedAccount from 'reducers/SelectedAccountReducer';
import sendForm from 'reducers/SendFormReducer';
import sendFormEthereum from 'reducers/SendFormEthereumReducer';
import sendFormRipple from 'reducers/SendFormRippleReducer';
import receive from 'reducers/ReceiveReducer';
import summary from 'reducers/SummaryReducer';
import tokens from 'reducers/TokensReducer';
@ -32,7 +33,8 @@ const reducers = {
web3,
accounts,
selectedAccount,
sendForm,
sendFormEthereum,
sendFormRipple,
receive,
summary,
tokens,

View File

@ -89,9 +89,9 @@ export const getAccountPendingTx = (pending: Array<PendingTx>, account: ?Account
return pending.filter(p => p.network === a.network && p.address === a.address);
};
export const getPendingNonce = (pending: Array<PendingTx>): number => pending.reduce((value: number, tx: PendingTx) => {
export const getPendingSequence = (pending: Array<PendingTx>): number => pending.reduce((value: number, tx: PendingTx) => {
if (tx.rejected) return value;
return Math.max(value, tx.nonce + 1);
return Math.max(value, tx.sequence + 1);
}, 0);
export const getPendingAmount = (pending: Array<PendingTx>, currency: string, token: boolean = false): BigNumber => pending.reduce((value: BigNumber, tx: PendingTx) => {

View File

@ -6,7 +6,6 @@ import * as CONNECT from 'actions/constants/TrezorConnect';
import * as TOKEN from 'actions/constants/token';
import * as ACCOUNT from 'actions/constants/account';
import * as DISCOVERY from 'actions/constants/discovery';
import * as SEND from 'actions/constants/send';
import * as PENDING from 'actions/constants/pendingTx';
import * as WALLET from 'actions/constants/wallet';
@ -59,7 +58,7 @@ const LocalStorageService: Middleware = (api: MiddlewareAPI) => (next: Middlewar
api.dispatch(LocalStorageActions.save());
break;
case SEND.TX_COMPLETE:
case PENDING.ADD:
case PENDING.TX_RESOLVED:
case PENDING.TX_REJECTED:
api.dispatch(LocalStorageActions.save());

View File

@ -58,11 +58,11 @@ const TrezorConnectService: Middleware = (api: MiddlewareAPI) => (next: Middlewa
} else if (action.type === CONNECT.DUPLICATE) {
api.dispatch(RouterActions.selectDevice(action.device));
} else if (action.type === BLOCKCHAIN.BLOCK) {
api.dispatch(BlockchainActions.onBlockMined(action.payload.coin));
api.dispatch(BlockchainActions.onBlockMined(action.payload));
} else if (action.type === BLOCKCHAIN.NOTIFICATION) {
// api.dispatch(BlockchainActions.onNotification(action.payload));
api.dispatch(BlockchainActions.onNotification(action.payload));
} else if (action.type === BLOCKCHAIN.ERROR) {
api.dispatch(BlockchainActions.error(action.payload));
api.dispatch(BlockchainActions.onError(action.payload));
}
return action;

View File

@ -9,7 +9,7 @@ import * as NotificationActions from 'actions/NotificationActions';
import * as LocalStorageActions from 'actions/LocalStorageActions';
import * as TrezorConnectActions from 'actions/TrezorConnectActions';
import * as SelectedAccountActions from 'actions/SelectedAccountActions';
import * as SendFormActionActions from 'actions/SendFormActions';
import * as SendFormActions from 'actions/SendFormActions';
import * as DiscoveryActions from 'actions/DiscoveryActions';
import * as RouterActions from 'actions/RouterActions';
@ -102,7 +102,7 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa
// if "selectedDevice" didn't change observe common values in SelectedAccountReducer
if (!await api.dispatch(SelectedAccountActions.observe(prevState, action))) {
// if "selectedAccount" didn't change observe send form props changes
api.dispatch(SendFormActionActions.observe(prevState, action));
api.dispatch(SendFormActions.observe(prevState, action));
}
} else {
// no changes in common values

View File

@ -1,6 +1,7 @@
/* @flow */
// TODO: chagne currency units
import BigNumber from 'bignumber.js';
const currencyUnitsConstant: string = 'mbtc2';
export const formatAmount = (n: number, coinInfo: any, currencyUnits: string = currencyUnitsConstant): string => {
@ -52,3 +53,7 @@ export const hexToString = (hex: string): string => {
}
return str;
};
export const toDecimalAmount = (amount: string, decimals: number): string => new BigNumber(amount).div(10 ** decimals).toString(10);
export const fromDecimalAmount = (amount: string, decimals: number): string => new BigNumber(amount).times(10 ** decimals).toString(10);

View File

@ -229,6 +229,10 @@ const AccountMenu = (props: Props) => {
);
}
if (discovery && (discovery.fwNotSupported || discovery.fwOutdated)) {
discoveryStatus = null;
}
return (
<Wrapper>
<NavLink to={baseUrl}>

View File

@ -0,0 +1,183 @@
/* @flow */
import React from 'react';
import { QRCode } from 'react-qr-svg';
import styled from 'styled-components';
import Title from 'views/Wallet/components/Title';
import Button from 'components/Button';
import Icon from 'components/Icon';
import Tooltip from 'components/Tooltip';
import Input from 'components/inputs/Input';
import ICONS from 'config/icons';
import colors from 'config/colors';
import { CONTEXT_DEVICE } from 'actions/constants/modal';
import Content from 'views/Wallet/components/Content';
import VerifyAddressTooltip from '../components/VerifyAddressTooltip';
import type { Props } from './Container';
const Label = styled.div`
padding-bottom: 10px;
color: ${colors.TEXT_SECONDARY};
`;
const AddressWrapper = styled.div`
display: flex;
flex-wrap: wrap;
flex-direction: row;
`;
const StyledQRCode = styled(QRCode)`
padding: 15px;
margin-top: 0 25px;
border: 1px solid ${colors.BODY};
`;
const ShowAddressButton = styled(Button)`
min-width: 195px;
padding: 0;
white-space: nowrap;
display: flex;
height: 40px;
align-items: center;
align-self: flex-end;
justify-content: center;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
@media screen and (max-width: 795px) {
margin-top: 10px;
align-self: auto;
border-radius: 3px;
}
`;
const ShowAddressIcon = styled(Icon)`
margin-right: 7px;
position: relative;
top: 2px;
`;
const EyeButton = styled(Button)`
z-index: 10001;
padding: 0;
width: 30px;
background: transparent;
top: 5px;
position: absolute;
right: 10px;
&:hover {
background: transparent;
}
`;
const Row = styled.div`
display: flex;
width: 100%;
padding-bottom: 28px;
@media screen and (max-width: 795px) {
flex-direction: column;
}
`;
const QrWrapper = styled.div`
display: flex;
flex-direction: column;
`;
const AccountReceive = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
discovery,
shouldRender,
loader,
} = props.selectedAccount;
const { type, title, message } = loader;
if (!device || !account || !discovery || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const {
addressVerified,
addressUnverified,
} = props.receive;
const isAddressVerifying = props.modal.context === CONTEXT_DEVICE && props.modal.windowType === 'ButtonRequest_Address';
const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified;
let address = `${account.address.substring(0, 20)}...`;
if (addressVerified || addressUnverified || isAddressVerifying) {
({ address } = account);
}
return (
<Content>
<React.Fragment>
<Title>Receive Ethereum or tokens</Title>
<AddressWrapper isShowingQrCode={addressVerified || addressUnverified}>
<Row>
<Input
type="text"
readOnly
autoSelect
topLabel="Address"
value={address}
isPartiallyHidden={isAddressHidden}
trezorAction={isAddressVerifying ? (
<React.Fragment>
<Icon
icon={ICONS.T1}
color={colors.WHITE}
/>
Check address on your Trezor
</React.Fragment>
) : null}
icon={((addressVerified || addressUnverified) && !isAddressVerifying) && (
<Tooltip
placement="left"
content={(
<VerifyAddressTooltip
isConnected={device.connected}
isAvailable={device.available}
addressUnverified={addressUnverified}
/>
)}
>
<EyeButton onClick={() => props.showAddress(account.addressPath)}>
<Icon
icon={addressUnverified ? ICONS.EYE_CROSSED : ICONS.EYE}
color={addressUnverified ? colors.ERROR_PRIMARY : colors.TEXT_PRIMARY}
/>
</EyeButton>
</Tooltip>
)}
/>
{!(addressVerified || addressUnverified) && (
<ShowAddressButton onClick={() => props.showAddress(account.addressPath)} isDisabled={device.connected && !discovery.completed}>
<ShowAddressIcon icon={ICONS.EYE} color={colors.WHITE} />Show full address
</ShowAddressButton>
)}
</Row>
{(addressVerified || addressUnverified) && !isAddressVerifying && (
<QrWrapper>
<Label>QR code</Label>
<StyledQRCode
bgColor="#FFFFFF"
fgColor="#000000"
level="Q"
style={{ width: 150 }}
value={account.address}
/>
</QrWrapper>
)}
</AddressWrapper>
</React.Fragment>
</Content>
);
};
export default AccountReceive;

View File

@ -1,183 +1,28 @@
/* @flow */
import React from 'react';
import { QRCode } from 'react-qr-svg';
import styled from 'styled-components';
import { connect } from 'react-redux';
import Title from 'views/Wallet/components/Title';
import Button from 'components/Button';
import Icon from 'components/Icon';
import Tooltip from 'components/Tooltip';
import Input from 'components/inputs/Input';
import type { State } from 'flowtype';
import EthereumTypeReceiveForm from './ethereum/Container';
import RippleTypeReceiveForm from './ripple/Container';
import ICONS from 'config/icons';
import colors from 'config/colors';
import { CONTEXT_DEVICE } from 'actions/constants/modal';
export type BaseProps = {
selectedAccount: $ElementType<State, 'selectedAccount'>,
}
import Content from 'views/Wallet/components/Content';
import VerifyAddressTooltip from './components/VerifyAddressTooltip';
// return container for requested network type
export default connect((state: State): BaseProps => ({
selectedAccount: state.selectedAccount,
}), null)((props) => {
const { network } = props.selectedAccount;
if (!network) return null;
import type { Props } from './Container';
const Label = styled.div`
padding-bottom: 10px;
color: ${colors.TEXT_SECONDARY};
`;
const AddressWrapper = styled.div`
display: flex;
flex-wrap: wrap;
flex-direction: row;
`;
const StyledQRCode = styled(QRCode)`
padding: 15px;
margin-top: 0 25px;
border: 1px solid ${colors.BODY};
`;
const ShowAddressButton = styled(Button)`
min-width: 195px;
padding: 0;
white-space: nowrap;
display: flex;
height: 40px;
align-items: center;
align-self: flex-end;
justify-content: center;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
@media screen and (max-width: 795px) {
margin-top: 10px;
align-self: auto;
border-radius: 3px;
switch (network.type) {
case 'ethereum':
return <EthereumTypeReceiveForm />;
case 'ripple':
return <RippleTypeReceiveForm />;
default:
return null;
}
`;
const ShowAddressIcon = styled(Icon)`
margin-right: 7px;
position: relative;
top: 2px;
`;
const EyeButton = styled(Button)`
z-index: 10001;
padding: 0;
width: 30px;
background: transparent;
top: 5px;
position: absolute;
right: 10px;
&:hover {
background: transparent;
}
`;
const Row = styled.div`
display: flex;
width: 100%;
padding-bottom: 28px;
@media screen and (max-width: 795px) {
flex-direction: column;
}
`;
const QrWrapper = styled.div`
display: flex;
flex-direction: column;
`;
const AccountReceive = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
discovery,
shouldRender,
loader,
} = props.selectedAccount;
const { type, title, message } = loader;
if (!device || !account || !discovery || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const {
addressVerified,
addressUnverified,
} = props.receive;
const isAddressVerifying = props.modal.context === CONTEXT_DEVICE && props.modal.windowType === 'ButtonRequest_Address';
const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified;
let address = `${account.address.substring(0, 20)}...`;
if (addressVerified || addressUnverified || isAddressVerifying) {
({ address } = account);
}
return (
<Content>
<React.Fragment>
<Title>Receive Ethereum or tokens</Title>
<AddressWrapper isShowingQrCode={addressVerified || addressUnverified}>
<Row>
<Input
type="text"
readOnly
autoSelect
topLabel="Address"
value={address}
isPartiallyHidden={isAddressHidden}
trezorAction={isAddressVerifying ? (
<React.Fragment>
<Icon
icon={ICONS.T1}
color={colors.WHITE}
/>
Check address on your Trezor
</React.Fragment>
) : null}
icon={((addressVerified || addressUnverified) && !isAddressVerifying) && (
<Tooltip
placement="left"
content={(
<VerifyAddressTooltip
isConnected={device.connected}
isAvailable={device.available}
addressUnverified={addressUnverified}
/>
)}
>
<EyeButton onClick={() => props.showAddress(account.addressPath)}>
<Icon
icon={addressUnverified ? ICONS.EYE_CROSSED : ICONS.EYE}
color={addressUnverified ? colors.ERROR_PRIMARY : colors.TEXT_PRIMARY}
/>
</EyeButton>
</Tooltip>
)}
/>
{!(addressVerified || addressUnverified) && (
<ShowAddressButton onClick={() => props.showAddress(account.addressPath)} isDisabled={device.connected && !discovery.completed}>
<ShowAddressIcon icon={ICONS.EYE} color={colors.WHITE} />Show full address
</ShowAddressButton>
)}
</Row>
{(addressVerified || addressUnverified) && !isAddressVerifying && (
<QrWrapper>
<Label>QR code</Label>
<StyledQRCode
bgColor="#FFFFFF"
fgColor="#000000"
level="Q"
style={{ width: 150 }}
value={account.address}
/>
</QrWrapper>
)}
</AddressWrapper>
</React.Fragment>
</Content>
);
};
export default AccountReceive;
});

View File

@ -0,0 +1,36 @@
/* @flow */
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { showAddress } from 'actions/ReceiveActions';
import type { MapStateToProps, MapDispatchToProps } from 'react-redux';
import type { State, Dispatch } from 'flowtype';
import Receive from './index';
type OwnProps = { }
type StateProps = {
selectedAccount: $ElementType<State, 'selectedAccount'>,
receive: $ElementType<State, 'receive'>,
modal: $ElementType<State, 'modal'>,
wallet: $ElementType<State, 'wallet'>,
}
type DispatchProps = {
showAddress: typeof showAddress
};
export type Props = StateProps & DispatchProps;
const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: State): StateProps => ({
selectedAccount: state.selectedAccount,
receive: state.receive,
modal: state.modal,
wallet: state.wallet,
});
const mapDispatchToProps: MapDispatchToProps<Dispatch, OwnProps, DispatchProps> = (dispatch: Dispatch): DispatchProps => ({
showAddress: bindActionCreators(showAddress, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Receive);

View File

@ -0,0 +1,183 @@
/* @flow */
import React from 'react';
import { QRCode } from 'react-qr-svg';
import styled from 'styled-components';
import Title from 'views/Wallet/components/Title';
import Button from 'components/Button';
import Icon from 'components/Icon';
import Tooltip from 'components/Tooltip';
import Input from 'components/inputs/Input';
import ICONS from 'config/icons';
import colors from 'config/colors';
import { CONTEXT_DEVICE } from 'actions/constants/modal';
import Content from 'views/Wallet/components/Content';
import VerifyAddressTooltip from '../components/VerifyAddressTooltip';
import type { Props } from './Container';
const Label = styled.div`
padding-bottom: 10px;
color: ${colors.TEXT_SECONDARY};
`;
const AddressWrapper = styled.div`
display: flex;
flex-wrap: wrap;
flex-direction: row;
`;
const StyledQRCode = styled(QRCode)`
padding: 15px;
margin-top: 0 25px;
border: 1px solid ${colors.BODY};
`;
const ShowAddressButton = styled(Button)`
min-width: 195px;
padding: 0;
white-space: nowrap;
display: flex;
height: 40px;
align-items: center;
align-self: flex-end;
justify-content: center;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
@media screen and (max-width: 795px) {
margin-top: 10px;
align-self: auto;
border-radius: 3px;
}
`;
const ShowAddressIcon = styled(Icon)`
margin-right: 7px;
position: relative;
top: 2px;
`;
const EyeButton = styled(Button)`
z-index: 10001;
padding: 0;
width: 30px;
background: transparent;
top: 5px;
position: absolute;
right: 10px;
&:hover {
background: transparent;
}
`;
const Row = styled.div`
display: flex;
width: 100%;
padding-bottom: 28px;
@media screen and (max-width: 795px) {
flex-direction: column;
}
`;
const QrWrapper = styled.div`
display: flex;
flex-direction: column;
`;
const AccountReceive = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
discovery,
shouldRender,
loader,
} = props.selectedAccount;
const { type, title, message } = loader;
if (!device || !account || !discovery || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const {
addressVerified,
addressUnverified,
} = props.receive;
const isAddressVerifying = props.modal.context === CONTEXT_DEVICE && props.modal.windowType === 'ButtonRequest_Address';
const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified;
let address = `${account.address.substring(0, 20)}...`;
if (addressVerified || addressUnverified || isAddressVerifying) {
({ address } = account);
}
return (
<Content>
<React.Fragment>
<Title>Receive Ripple</Title>
<AddressWrapper isShowingQrCode={addressVerified || addressUnverified}>
<Row>
<Input
type="text"
readOnly
autoSelect
topLabel="Address"
value={address}
isPartiallyHidden={isAddressHidden}
trezorAction={isAddressVerifying ? (
<React.Fragment>
<Icon
icon={ICONS.T1}
color={colors.WHITE}
/>
Check address on your Trezor
</React.Fragment>
) : null}
icon={((addressVerified || addressUnverified) && !isAddressVerifying) && (
<Tooltip
placement="left"
content={(
<VerifyAddressTooltip
isConnected={device.connected}
isAvailable={device.available}
addressUnverified={addressUnverified}
/>
)}
>
<EyeButton onClick={() => props.showAddress(account.addressPath)}>
<Icon
icon={addressUnverified ? ICONS.EYE_CROSSED : ICONS.EYE}
color={addressUnverified ? colors.ERROR_PRIMARY : colors.TEXT_PRIMARY}
/>
</EyeButton>
</Tooltip>
)}
/>
{!(addressVerified || addressUnverified) && (
<ShowAddressButton onClick={() => props.showAddress(account.addressPath)} isDisabled={device.connected && !discovery.completed}>
<ShowAddressIcon icon={ICONS.EYE} color={colors.WHITE} />Show full address
</ShowAddressButton>
)}
</Row>
{(addressVerified || addressUnverified) && !isAddressVerifying && (
<QrWrapper>
<Label>QR code</Label>
<StyledQRCode
bgColor="#FFFFFF"
fgColor="#000000"
level="Q"
style={{ width: 150 }}
value={account.address}
/>
</QrWrapper>
)}
</AddressWrapper>
</React.Fragment>
</Content>
);
};
export default AccountReceive;

View File

@ -11,7 +11,7 @@ import Icon from 'components/Icon';
import ICONS from 'config/icons';
import { FONT_SIZE } from 'config/variables';
import type { Props as BaseProps } from '../../Container';
import type { Props as BaseProps } from '../../ethereum/Container';
type Props = BaseProps & {
children: React.Node,

View File

@ -9,7 +9,7 @@ import ScaleText from 'react-scale-text';
import type { Network } from 'reducers/LocalStorageReducer';
import type { Token } from 'reducers/TokensReducer';
import type { Props as BaseProps } from '../../Container';
import type { BaseProps } from '../../index';
type Props = {
pending: $PropertyType<$ElementType<BaseProps, 'selectedAccount'>, 'pending'>,
@ -140,7 +140,7 @@ class PendingTransactions extends PureComponent<Props> {
<H2>Pending transactions</H2>
{this.getPendingTransactions().map(tx => (
<TransactionWrapper
key={tx.id}
key={tx.hash}
>
<TransactionIcon
textColor={() => this.getTransactionIconColors(tx).textColor}
@ -154,7 +154,7 @@ class PendingTransactions extends PureComponent<Props> {
<TransactionName>
<StyledLink
href={`${this.props.network.explorer.tx}${tx.id}`}
href={`${this.props.network.explorer.tx}${tx.hash}`}
isGray
>
{this.getTransactionName(tx)}

View File

@ -0,0 +1,39 @@
/* @flow */
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import SendFormActions from 'actions/ethereum/SendFormActions';
import type { MapStateToProps, MapDispatchToProps } from 'react-redux';
import type { State, Dispatch } from 'flowtype';
import AccountSend from './index';
type OwnProps = {}
export type StateProps = {
selectedAccount: $ElementType<State, 'selectedAccount'>,
sendForm: $ElementType<State, 'sendFormEthereum'>,
wallet: $ElementType<State, 'wallet'>,
fiat: $ElementType<State, 'fiat'>,
localStorage: $ElementType<State, 'localStorage'>,
}
export type DispatchProps = {
sendFormActions: typeof SendFormActions,
}
export type Props = StateProps & DispatchProps;
const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: State): StateProps => ({
selectedAccount: state.selectedAccount,
sendForm: state.sendFormEthereum,
wallet: state.wallet,
fiat: state.fiat,
localStorage: state.localStorage,
});
const mapDispatchToProps: MapDispatchToProps<Dispatch, OwnProps, DispatchProps> = (dispatch: Dispatch): DispatchProps => ({
sendFormActions: bindActionCreators(SendFormActions, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountSend);

View File

@ -0,0 +1,403 @@
/* @flow */
import React from 'react';
import styled, { css } from 'styled-components';
import { Select } from 'components/Select';
import Button from 'components/Button';
import Input from 'components/inputs/Input';
import Icon from 'components/Icon';
import Link from 'components/Link';
import ICONS from 'config/icons';
import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables';
import colors from 'config/colors';
import Title from 'views/Wallet/components/Title';
import P from 'components/Paragraph';
import Content from 'views/Wallet/components/Content';
import type { Token } from 'flowtype';
import AdvancedForm from '../components/AdvancedForm';
import PendingTransactions from '../components/PendingTransactions';
import type { Props } from './Container';
// TODO: Decide on a small screen width for the whole app
// and put it inside config/variables.js
const SmallScreenWidth = '850px';
const AmountInputLabelWrapper = styled.div`
display: flex;
justify-content: space-between;
`;
const AmountInputLabel = styled.span`
text-align: right;
color: ${colors.TEXT_SECONDARY};
`;
const InputRow = styled.div`
padding-bottom: 28px;
`;
const SetMaxAmountButton = styled(Button)`
height: 40px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: ${FONT_SIZE.SMALLER};
font-weight: ${FONT_WEIGHT.SMALLEST};
color: ${colors.TEXT_SECONDARY};
border-radius: 0;
border: 1px solid ${colors.DIVIDER};
border-right: 0;
border-left: 0;
background: transparent;
transition: ${TRANSITION.HOVER};
&:hover {
background: ${colors.GRAY_LIGHT};
}
${props => props.isActive && css`
color: ${colors.WHITE};
background: ${colors.GREEN_PRIMARY};
border-color: ${colors.GREEN_PRIMARY};
&:hover {
background: ${colors.GREEN_SECONDARY};
}
&:active {
background: ${colors.GREEN_TERTIARY};
}
`}
`;
const CurrencySelect = styled(Select)`
min-width: 77px;
height: 40px;
flex: 0.2;
`;
const FeeOptionWrapper = styled.div`
display: flex;
justify-content: space-between;
`;
const FeeLabelWrapper = styled.div`
display: flex;
align-items: center;
padding-bottom: 10px;
`;
const FeeLabel = styled.span`
color: ${colors.TEXT_SECONDARY};
`;
const UpdateFeeWrapper = styled.span`
margin-left: 8px;
display: flex;
align-items: center;
font-size: ${FONT_SIZE.SMALLER};
color: ${colors.WARNING_PRIMARY};
`;
const StyledLink = styled(Link)`
margin-left: 4px;
white-space: nowrap;
`;
const ToggleAdvancedSettingsWrapper = styled.div`
min-height: 40px;
margin-bottom: 20px;
display: flex;
flex-direction: row;
justify-content: space-between;
@media screen and (max-width: ${SmallScreenWidth}) {
${props => (props.isAdvancedSettingsHidden && css`
flex-direction: column;
`)}
}
`;
const ToggleAdvancedSettingsButton = styled(Button)`
min-height: 40px;
padding: 0;
display: flex;
align-items: center;
font-weight: ${FONT_WEIGHT.BIGGER};
`;
const SendButton = styled(Button)`
min-width: ${props => (props.isAdvancedSettingsHidden ? '50%' : '100%')};
font-size: 13px;
@media screen and (max-width: ${SmallScreenWidth}) {
margin-top: ${props => (props.isAdvancedSettingsHidden ? '10px' : 0)};
}
`;
const AdvancedSettingsIcon = styled(Icon)`
margin-left: 10px;
`;
// render helpers
const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => {
let state = '';
if (address && !addressErrors) {
state = 'success';
}
if (addressWarnings && !addressErrors) {
state = 'warning';
}
if (addressErrors) {
state = 'error';
}
return state;
};
const getAmountInputState = (amountErrors: string, amountWarnings: string): string => {
let state = '';
if (amountWarnings && !amountErrors) {
state = 'warning';
}
if (amountErrors) {
state = 'error';
}
return state;
};
const getTokensSelectData = (tokens: Array<Token>, accountNetwork: any): Array<{ value: string, label: string }> => {
const tokensSelectData: Array<{ value: string, label: string }> = tokens.map(t => ({ value: t.symbol, label: t.symbol }));
tokensSelectData.unshift({ value: accountNetwork.symbol, label: accountNetwork.symbol });
return tokensSelectData;
};
// stateless component
const AccountSend = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
network,
discovery,
tokens,
shouldRender,
loader,
} = props.selectedAccount;
const {
address,
amount,
setMax,
networkSymbol,
currency,
feeLevels,
selectedFeeLevel,
gasPriceNeedsUpdate,
total,
errors,
warnings,
infos,
sending,
advanced,
} = props.sendForm;
const {
toggleAdvanced,
onAddressChange,
onAmountChange,
onSetMax,
onCurrencyChange,
onFeeLevelChange,
updateFeeLevels,
onSend,
} = props.sendFormActions;
const { type, title, message } = loader;
if (!device || !account || !discovery || !network || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const isCurrentCurrencyToken = networkSymbol !== currency;
let selectedTokenBalance = 0;
const selectedToken = tokens.find(t => t.symbol === currency);
if (selectedToken) {
selectedTokenBalance = selectedToken.balance;
}
let isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending;
let sendButtonText: string = 'Send';
if (networkSymbol !== currency && amount.length > 0 && !errors.amount) {
sendButtonText += ` ${amount} ${currency.toUpperCase()}`;
} else if (networkSymbol === currency && total !== '0') {
sendButtonText += ` ${total} ${network.symbol}`;
}
if (!device.connected) {
sendButtonText = 'Device is not connected';
isSendButtonDisabled = true;
} else if (!device.available) {
sendButtonText = 'Device is unavailable';
isSendButtonDisabled = true;
} else if (!discovery.completed) {
sendButtonText = 'Loading accounts';
isSendButtonDisabled = true;
}
const tokensSelectData = getTokensSelectData(tokens, network);
const tokensSelectValue = tokensSelectData.find(t => t.value === currency);
const isAdvancedSettingsHidden = !advanced;
return (
<Content>
<Title>Send Ethereum or tokens</Title>
<InputRow>
<Input
state={getAddressInputState(address, errors.address, warnings.address)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
topLabel="Address"
bottomText={errors.address || warnings.address || infos.address}
value={address}
onChange={event => onAddressChange(event.target.value)}
/>
</InputRow>
<InputRow>
<Input
state={getAmountInputState(errors.amount, warnings.amount)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
topLabel={(
<AmountInputLabelWrapper>
<AmountInputLabel>Amount</AmountInputLabel>
{(isCurrentCurrencyToken && selectedToken) && (
<AmountInputLabel>You have: {selectedTokenBalance} {selectedToken.symbol}</AmountInputLabel>
)}
</AmountInputLabelWrapper>
)}
value={amount}
onChange={event => onAmountChange(event.target.value)}
bottomText={errors.amount || warnings.amount || infos.amount}
sideAddons={[
(
<SetMaxAmountButton
key="icon"
onClick={() => onSetMax()}
isActive={setMax}
>
{!setMax && (
<Icon
icon={ICONS.TOP}
size={25}
color={colors.TEXT_SECONDARY}
/>
)}
{setMax && (
<Icon
icon={ICONS.CHECKED}
size={25}
color={colors.WHITE}
/>
)}
Set max
</SetMaxAmountButton>
),
(
<CurrencySelect
key="currency"
isSearchable={false}
isClearable={false}
value={tokensSelectValue}
isDisabled={tokensSelectData.length < 2}
onChange={onCurrencyChange}
options={tokensSelectData}
/>
),
]}
/>
</InputRow>
<InputRow>
<FeeLabelWrapper>
<FeeLabel>Fee</FeeLabel>
{gasPriceNeedsUpdate && (
<UpdateFeeWrapper>
<Icon
icon={ICONS.WARNING}
color={colors.WARNING_PRIMARY}
size={20}
/>
Recommended fees updated. <StyledLink onClick={updateFeeLevels} isGreen>Click here to use them</StyledLink>
</UpdateFeeWrapper>
)}
</FeeLabelWrapper>
<Select
isSearchable={false}
isClearable={false}
value={selectedFeeLevel}
onChange={onFeeLevelChange}
options={feeLevels}
formatOptionLabel={option => (
<FeeOptionWrapper>
<P>{option.value}</P>
<P>{option.label}</P>
</FeeOptionWrapper>
)}
/>
</InputRow>
<ToggleAdvancedSettingsWrapper
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
>
<ToggleAdvancedSettingsButton
isTransparent
onClick={toggleAdvanced}
>
Advanced settings
<AdvancedSettingsIcon
icon={ICONS.ARROW_DOWN}
color={colors.TEXT_SECONDARY}
size={24}
isActive={advanced}
canAnimate
/>
</ToggleAdvancedSettingsButton>
{isAdvancedSettingsHidden && (
<SendButton
isDisabled={isSendButtonDisabled}
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
)}
</ToggleAdvancedSettingsWrapper>
{advanced && (
<AdvancedForm {...props}>
<SendButton
isDisabled={isSendButtonDisabled}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
</AdvancedForm>
)}
{props.selectedAccount.pending.length > 0 && (
<PendingTransactions
pending={props.selectedAccount.pending}
tokens={props.selectedAccount.tokens}
network={network}
/>
)}
</Content>
);
};
export default AccountSend;

View File

@ -1,402 +1,28 @@
/* @flow */
import React from 'react';
import styled, { css } from 'styled-components';
import { Select } from 'components/Select';
import Button from 'components/Button';
import Input from 'components/inputs/Input';
import Icon from 'components/Icon';
import Link from 'components/Link';
import ICONS from 'config/icons';
import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables';
import colors from 'config/colors';
import Title from 'views/Wallet/components/Title';
import P from 'components/Paragraph';
import Content from 'views/Wallet/components/Content';
import type { Token } from 'flowtype';
import AdvancedForm from './components/AdvancedForm';
import PendingTransactions from './components/PendingTransactions';
import { connect } from 'react-redux';
import type { Props } from './Container';
import type { State } from 'flowtype';
import EthereumTypeSendForm from './ethereum/Container';
import RippleTypeSendForm from './ripple/Container';
// TODO: Decide on a small screen width for the whole app
// and put it inside config/variables.js
const SmallScreenWidth = '850px';
export type BaseProps = {
selectedAccount: $ElementType<State, 'selectedAccount'>,
}
const AmountInputLabelWrapper = styled.div`
display: flex;
justify-content: space-between;
`;
// return container for requested network type
export default connect((state: State): BaseProps => ({
selectedAccount: state.selectedAccount,
}), null)((props) => {
const { network } = props.selectedAccount;
if (!network) return null;
const AmountInputLabel = styled.span`
text-align: right;
color: ${colors.TEXT_SECONDARY};
`;
const InputRow = styled.div`
padding-bottom: 28px;
`;
const SetMaxAmountButton = styled(Button)`
height: 40px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: ${FONT_SIZE.SMALLER};
font-weight: ${FONT_WEIGHT.LIGHT};
color: ${colors.TEXT_SECONDARY};
border-radius: 0;
border: 1px solid ${colors.DIVIDER};
border-right: 0;
border-left: 0;
background: transparent;
transition: ${TRANSITION.HOVER};
&:hover {
background: ${colors.GRAY_LIGHT};
switch (network.type) {
case 'ethereum':
return <EthereumTypeSendForm />;
case 'ripple':
return <RippleTypeSendForm />;
default:
return null;
}
${props => props.isActive && css`
color: ${colors.WHITE};
background: ${colors.GREEN_PRIMARY};
border-color: ${colors.GREEN_PRIMARY};
&:hover {
background: ${colors.GREEN_SECONDARY};
}
&:active {
background: ${colors.GREEN_TERTIARY};
}
`}
`;
const CurrencySelect = styled(Select)`
min-width: 77px;
height: 40px;
flex: 0.2;
`;
const FeeOptionWrapper = styled.div`
display: flex;
justify-content: space-between;
`;
const FeeLabelWrapper = styled.div`
display: flex;
align-items: center;
padding-bottom: 10px;
`;
const FeeLabel = styled.span`
color: ${colors.TEXT_SECONDARY};
`;
const UpdateFeeWrapper = styled.span`
margin-left: 8px;
display: flex;
align-items: center;
font-size: ${FONT_SIZE.SMALLER};
color: ${colors.WARNING_PRIMARY};
`;
const StyledLink = styled(Link)`
margin-left: 4px;
white-space: nowrap;
`;
const ToggleAdvancedSettingsWrapper = styled.div`
min-height: 40px;
margin-bottom: 20px;
display: flex;
flex-direction: row;
justify-content: space-between;
@media screen and (max-width: ${SmallScreenWidth}) {
${props => (props.isAdvancedSettingsHidden && css`
flex-direction: column;
`)}
}
`;
const ToggleAdvancedSettingsButton = styled(Button)`
min-height: 40px;
padding: 0;
display: flex;
align-items: center;
font-weight: ${FONT_WEIGHT.SEMIBOLD};
`;
const SendButton = styled(Button)`
min-width: ${props => (props.isAdvancedSettingsHidden ? '50%' : '100%')};
@media screen and (max-width: ${SmallScreenWidth}) {
margin-top: ${props => (props.isAdvancedSettingsHidden ? '10px' : 0)};
}
`;
const AdvancedSettingsIcon = styled(Icon)`
margin-left: 10px;
`;
// render helpers
const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => {
let state = '';
if (address && !addressErrors) {
state = 'success';
}
if (addressWarnings && !addressErrors) {
state = 'warning';
}
if (addressErrors) {
state = 'error';
}
return state;
};
const getAmountInputState = (amountErrors: string, amountWarnings: string): string => {
let state = '';
if (amountWarnings && !amountErrors) {
state = 'warning';
}
if (amountErrors) {
state = 'error';
}
return state;
};
const getTokensSelectData = (tokens: Array<Token>, accountNetwork: any): Array<{ value: string, label: string }> => {
const tokensSelectData: Array<{ value: string, label: string }> = tokens.map(t => ({ value: t.symbol, label: t.symbol }));
tokensSelectData.unshift({ value: accountNetwork.symbol, label: accountNetwork.symbol });
return tokensSelectData;
};
// stateless component
const AccountSend = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
network,
discovery,
tokens,
shouldRender,
loader,
} = props.selectedAccount;
const {
address,
amount,
setMax,
networkSymbol,
currency,
feeLevels,
selectedFeeLevel,
gasPriceNeedsUpdate,
total,
errors,
warnings,
infos,
sending,
advanced,
} = props.sendForm;
const {
toggleAdvanced,
onAddressChange,
onAmountChange,
onSetMax,
onCurrencyChange,
onFeeLevelChange,
updateFeeLevels,
onSend,
} = props.sendFormActions;
const { type, title, message } = loader;
if (!device || !account || !discovery || !network || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const isCurrentCurrencyToken = networkSymbol !== currency;
let selectedTokenBalance = 0;
const selectedToken = tokens.find(t => t.symbol === currency);
if (selectedToken) {
selectedTokenBalance = selectedToken.balance;
}
let isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending;
let sendButtonText: string = 'Send';
if (networkSymbol !== currency && amount.length > 0 && !errors.amount) {
sendButtonText += ` ${amount} ${currency.toUpperCase()}`;
} else if (networkSymbol === currency && total !== '0') {
sendButtonText += ` ${total} ${network.symbol}`;
}
if (!device.connected) {
sendButtonText = 'Device is not connected';
isSendButtonDisabled = true;
} else if (!device.available) {
sendButtonText = 'Device is unavailable';
isSendButtonDisabled = true;
} else if (!discovery.completed) {
sendButtonText = 'Loading accounts';
isSendButtonDisabled = true;
}
const tokensSelectData = getTokensSelectData(tokens, network);
const tokensSelectValue = tokensSelectData.find(t => t.value === currency);
const isAdvancedSettingsHidden = !advanced;
return (
<Content>
<Title>Send Ethereum or tokens</Title>
<InputRow>
<Input
state={getAddressInputState(address, errors.address, warnings.address)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
topLabel="Address"
bottomText={errors.address || warnings.address || infos.address}
value={address}
onChange={event => onAddressChange(event.target.value)}
/>
</InputRow>
<InputRow>
<Input
state={getAmountInputState(errors.amount, warnings.amount)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
topLabel={(
<AmountInputLabelWrapper>
<AmountInputLabel>Amount</AmountInputLabel>
{(isCurrentCurrencyToken && selectedToken) && (
<AmountInputLabel>You have: {selectedTokenBalance} {selectedToken.symbol}</AmountInputLabel>
)}
</AmountInputLabelWrapper>
)}
value={amount}
onChange={event => onAmountChange(event.target.value)}
bottomText={errors.amount || warnings.amount || infos.amount}
sideAddons={[
(
<SetMaxAmountButton
key="icon"
onClick={() => onSetMax()}
isActive={setMax}
>
{!setMax && (
<Icon
icon={ICONS.TOP}
size={25}
color={colors.TEXT_SECONDARY}
/>
)}
{setMax && (
<Icon
icon={ICONS.CHECKED}
size={25}
color={colors.WHITE}
/>
)}
Set max
</SetMaxAmountButton>
),
(
<CurrencySelect
key="currency"
isSearchable={false}
isClearable={false}
value={tokensSelectValue}
isDisabled={tokensSelectData.length < 2}
onChange={onCurrencyChange}
options={tokensSelectData}
/>
),
]}
/>
</InputRow>
<InputRow>
<FeeLabelWrapper>
<FeeLabel>Fee</FeeLabel>
{gasPriceNeedsUpdate && (
<UpdateFeeWrapper>
<Icon
icon={ICONS.WARNING}
color={colors.WARNING_PRIMARY}
size={20}
/>
Recommended fees updated. <StyledLink onClick={updateFeeLevels} isGreen>Click here to use them</StyledLink>
</UpdateFeeWrapper>
)}
</FeeLabelWrapper>
<Select
isSearchable={false}
isClearable={false}
value={selectedFeeLevel}
onChange={onFeeLevelChange}
options={feeLevels}
formatOptionLabel={option => (
<FeeOptionWrapper>
<P>{option.value}</P>
<P>{option.label}</P>
</FeeOptionWrapper>
)}
/>
</InputRow>
<ToggleAdvancedSettingsWrapper
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
>
<ToggleAdvancedSettingsButton
isTransparent
onClick={toggleAdvanced}
>
Advanced settings
<AdvancedSettingsIcon
icon={ICONS.ARROW_DOWN}
color={colors.TEXT_SECONDARY}
size={24}
isActive={advanced}
canAnimate
/>
</ToggleAdvancedSettingsButton>
{isAdvancedSettingsHidden && (
<SendButton
isDisabled={isSendButtonDisabled}
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
)}
</ToggleAdvancedSettingsWrapper>
{advanced && (
<AdvancedForm {...props}>
<SendButton
isDisabled={isSendButtonDisabled}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
</AdvancedForm>
)}
{props.selectedAccount.pending.length > 0 && (
<PendingTransactions
pending={props.selectedAccount.pending}
tokens={props.selectedAccount.tokens}
network={network}
/>
)}
</Content>
);
};
export default AccountSend;
});

View File

@ -3,7 +3,7 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import SendFormActions from 'actions/SendFormActions';
import SendFormActions from 'actions/ripple/SendFormActions';
import type { MapStateToProps, MapDispatchToProps } from 'react-redux';
import type { State, Dispatch } from 'flowtype';
import AccountSend from './index';
@ -12,7 +12,7 @@ type OwnProps = {}
export type StateProps = {
selectedAccount: $ElementType<State, 'selectedAccount'>,
sendForm: $ElementType<State, 'sendForm'>,
sendForm: $ElementType<State, 'sendFormRipple'>,
wallet: $ElementType<State, 'wallet'>,
fiat: $ElementType<State, 'fiat'>,
localStorage: $ElementType<State, 'localStorage'>,
@ -26,7 +26,7 @@ export type Props = StateProps & DispatchProps;
const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: State): StateProps => ({
selectedAccount: state.selectedAccount,
sendForm: state.sendForm,
sendForm: state.sendFormRipple,
wallet: state.wallet,
fiat: state.fiat,
localStorage: state.localStorage,

View File

@ -0,0 +1,255 @@
/* @flow */
import React from 'react';
import styled, { css } from 'styled-components';
import { Select } from 'components/Select';
import Button from 'components/Button';
import Input from 'components/inputs/Input';
import Icon from 'components/Icon';
import ICONS from 'config/icons';
import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables';
import colors from 'config/colors';
import Title from 'views/Wallet/components/Title';
import Content from 'views/Wallet/components/Content';
import PendingTransactions from '../components/PendingTransactions';
import type { Props } from './Container';
// TODO: Decide on a small screen width for the whole app
// and put it inside config/variables.js
const SmallScreenWidth = '850px';
const InputRow = styled.div`
padding-bottom: 28px;
`;
const SetMaxAmountButton = styled(Button)`
height: 40px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: ${FONT_SIZE.SMALLER};
font-weight: ${FONT_WEIGHT.SMALLEST};
color: ${colors.TEXT_SECONDARY};
border-radius: 0;
border: 1px solid ${colors.DIVIDER};
border-right: 0;
border-left: 0;
background: transparent;
transition: ${TRANSITION.HOVER};
&:hover {
background: ${colors.GRAY_LIGHT};
}
${props => props.isActive && css`
color: ${colors.WHITE};
background: ${colors.GREEN_PRIMARY};
border-color: ${colors.GREEN_PRIMARY};
&:hover {
background: ${colors.GREEN_SECONDARY};
}
&:active {
background: ${colors.GREEN_TERTIARY};
}
`}
`;
const CurrencySelect = styled(Select)`
min-width: 77px;
height: 40px;
flex: 0.2;
`;
const ToggleAdvancedSettingsWrapper = styled.div`
min-height: 40px;
margin-bottom: 20px;
display: flex;
flex-direction: row;
justify-content: space-between;
@media screen and (max-width: ${SmallScreenWidth}) {
${props => (props.isAdvancedSettingsHidden && css`
flex-direction: column;
`)}
}
`;
const SendButton = styled(Button)`
min-width: ${props => (props.isAdvancedSettingsHidden ? '50%' : '100%')};
font-size: 13px;
@media screen and (max-width: ${SmallScreenWidth}) {
margin-top: ${props => (props.isAdvancedSettingsHidden ? '10px' : 0)};
}
`;
// render helpers
const getAddressInputState = (address: string, addressErrors: string, addressWarnings: string): string => {
let state = '';
if (address && !addressErrors) {
state = 'success';
}
if (addressWarnings && !addressErrors) {
state = 'warning';
}
if (addressErrors) {
state = 'error';
}
return state;
};
const getAmountInputState = (amountErrors: string, amountWarnings: string): string => {
let state = '';
if (amountWarnings && !amountErrors) {
state = 'warning';
}
if (amountErrors) {
state = 'error';
}
return state;
};
// stateless component
const AccountSend = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
network,
discovery,
shouldRender,
loader,
} = props.selectedAccount;
const {
address,
amount,
setMax,
total,
errors,
warnings,
infos,
sending,
advanced,
} = props.sendForm;
const {
onAddressChange,
onAmountChange,
onSetMax,
onSend,
} = props.sendFormActions;
const { type, title, message } = loader;
if (!device || !account || !discovery || !network || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
let isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending;
let sendButtonText: string = ` ${total} ${network.symbol}`;
if (!device.connected) {
sendButtonText = 'Device is not connected';
isSendButtonDisabled = true;
} else if (!device.available) {
sendButtonText = 'Device is unavailable';
isSendButtonDisabled = true;
} else if (!discovery.completed) {
sendButtonText = 'Loading accounts';
isSendButtonDisabled = true;
}
const tokensSelectData: Array<{ value: string, label: string }> = [{ value: network.symbol, label: network.symbol }];
const tokensSelectValue = tokensSelectData[0];
const isAdvancedSettingsHidden = !advanced;
return (
<Content>
<Title>Send Ripple</Title>
<InputRow>
<Input
state={getAddressInputState(address, errors.address, warnings.address)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
topLabel="Address"
bottomText={errors.address || warnings.address || infos.address}
value={address}
onChange={event => onAddressChange(event.target.value)}
/>
</InputRow>
<InputRow>
<Input
state={getAmountInputState(errors.amount, warnings.amount)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={amount}
onChange={event => onAmountChange(event.target.value)}
bottomText={errors.amount || warnings.amount || infos.amount}
sideAddons={[
(
<SetMaxAmountButton
key="icon"
onClick={() => onSetMax()}
isActive={setMax}
>
{!setMax && (
<Icon
icon={ICONS.TOP}
size={25}
color={colors.TEXT_SECONDARY}
/>
)}
{setMax && (
<Icon
icon={ICONS.CHECKED}
size={25}
color={colors.WHITE}
/>
)}
Set max
</SetMaxAmountButton>
),
(
<CurrencySelect
key="currency"
isSearchable={false}
isClearable={false}
value={tokensSelectValue}
isDisabled
options={tokensSelectData}
/>
),
]}
/>
</InputRow>
<ToggleAdvancedSettingsWrapper
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
>
<SendButton
isDisabled={isSendButtonDisabled}
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
</ToggleAdvancedSettingsWrapper>
{props.selectedAccount.pending.length > 0 && (
<PendingTransactions
pending={props.selectedAccount.pending}
tokens={props.selectedAccount.tokens}
network={network}
/>
)}
</Content>
);
};
export default AccountSend;

View File

@ -75,7 +75,7 @@ class SignVerify extends Component <Props> {
const verifyAddressError = this.getError('verifyAddress');
return (
<Content>
<Title>Sign & Verify</Title>
<Title>Sign &amp; Verify</Title>
<Wrapper>
<Sign>
<Row>

View File

@ -7,13 +7,12 @@ import colors from 'config/colors';
import ICONS from 'config/icons';
import { FONT_SIZE, FONT_WEIGHT } from 'config/variables';
import type { Network } from 'flowtype';
import type { Props as BaseProps } from '../../Container';
import type { Network, State as ReducersState } from 'flowtype';
type Props = {
network: Network,
balance: string,
fiat: $ElementType<BaseProps, 'fiat'>,
fiat: $ElementType<ReducersState, 'fiat'>,
}
type State = {

View File

@ -0,0 +1,165 @@
/* @flow */
import styled from 'styled-components';
import React from 'react';
import { H2 } from 'components/Heading';
import BigNumber from 'bignumber.js';
import Icon from 'components/Icon';
import { AsyncSelect } from 'components/Select';
import ICONS from 'config/icons';
import colors from 'config/colors';
import Tooltip from 'components/Tooltip';
import Content from 'views/Wallet/components/Content';
import CoinLogo from 'components/images/CoinLogo';
import * as stateUtils from 'reducers/utils';
import Link from 'components/Link';
import { FONT_WEIGHT, FONT_SIZE } from 'config/variables';
import AccountBalance from '../components/Balance';
import AddedToken from '../components/Token';
import AddTokenMessage from '../components/AddTokenMessage';
import type { Props } from './Container';
const AccountHeading = styled.div`
padding-bottom: 35px;
display: flex;
justify-content: space-between;
align-items: center;
`;
const H2Wrapper = styled.div`
display: flex;
align-items: center;
padding: 20px 0;
`;
const StyledTooltip = styled(Tooltip)`
position: relative;
top: 2px;
`;
const AccountName = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const AccountTitle = styled.div`
font-size: ${FONT_SIZE.WALLET_TITLE};
font-weight: ${FONT_WEIGHT.BASE};
color: ${colors.WALLET_TITLE};
`;
const StyledCoinLogo = styled(CoinLogo)`
margin-right: 10px;
`;
const StyledIcon = styled(Icon)`
position: relative;
top: -7px;
&:hover {
cursor: pointer;
}
`;
const AsyncSelectWrapper = styled.div`
padding-bottom: 32px;
`;
const AddedTokensWrapper = styled.div``;
const AccountSummary = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
network,
tokens,
pending,
loader,
shouldRender,
} = props.selectedAccount;
const { type, title, message } = loader;
if (!device || !account || !network || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const explorerLink: string = `${network.explorer.address}${account.address}`;
const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol);
const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10);
return (
<Content>
<React.Fragment>
<AccountHeading>
<AccountName>
<StyledCoinLogo network={account.network} />
<AccountTitle>Account #{parseInt(account.index, 10) + 1}</AccountTitle>
</AccountName>
<Link href={explorerLink} isGray>See full transaction history</Link>
</AccountHeading>
<AccountBalance
network={network}
balance={balance}
fiat={props.fiat}
/>
<H2Wrapper>
<H2>Tokens</H2>
<StyledTooltip
maxWidth={200}
placement="top"
content="Insert token name, symbol or address to be able to send it."
>
<StyledIcon
icon={ICONS.HELP}
color={colors.TEXT_SECONDARY}
size={24}
/>
</StyledTooltip>
</H2Wrapper>
<AsyncSelectWrapper>
<AsyncSelect
isSearchable
defaultOptions
value={null}
isMulti={false}
placeholder="Search for the token"
loadingMessage={() => 'Loading...'}
noOptionsMessage={() => 'Token not found'}
onChange={(token) => {
if (token.name) {
const isAdded = tokens.find(t => t.symbol === token.symbol);
if (!isAdded) {
props.addToken(token, account);
}
}
}}
loadOptions={input => props.loadTokens(input, account.network)}
formatOptionLabel={(option) => {
const isAdded = tokens.find(t => t.symbol === option.symbol);
if (isAdded) {
return `${option.name} (Already added)`;
}
return option.name;
}}
getOptionLabel={option => option.name}
getOptionValue={option => option.symbol}
/>
</AsyncSelectWrapper>
<AddedTokensWrapper>
{ tokens.length < 1 && (<AddTokenMessage />) }
{tokens.map(token => (
<AddedToken
key={token.symbol}
token={token}
pending={pending}
removeToken={props.removeToken}
/>
))}
</AddedTokensWrapper>
</React.Fragment>
</Content>
);
};
export default AccountSummary;

View File

@ -1,165 +1,28 @@
/* @flow */
import styled from 'styled-components';
import React from 'react';
import { H2 } from 'components/Heading';
import BigNumber from 'bignumber.js';
import Icon from 'components/Icon';
import { AsyncSelect } from 'components/Select';
import ICONS from 'config/icons';
import colors from 'config/colors';
import Tooltip from 'components/Tooltip';
import Content from 'views/Wallet/components/Content';
import { connect } from 'react-redux';
import CoinLogo from 'components/images/CoinLogo';
import * as stateUtils from 'reducers/utils';
import Link from 'components/Link';
import { FONT_WEIGHT, FONT_SIZE } from 'config/variables';
import AccountBalance from './components/Balance';
import AddedToken from './components/Token';
import AddTokenMessage from './components/AddTokenMessage';
import type { State } from 'flowtype';
import EthereumTypeSummary from './ethereum/Container';
import RippleTypeSummary from './ripple/Container';
import type { Props } from './Container';
type WrapperProps = {
selectedAccount: $ElementType<State, 'selectedAccount'>,
}
const AccountHeading = styled.div`
padding-bottom: 35px;
display: flex;
justify-content: space-between;
align-items: center;
`;
// return container for requested network type
export default connect((state: State): WrapperProps => ({
selectedAccount: state.selectedAccount,
}), null)((props) => {
const { network } = props.selectedAccount;
if (!network) return null;
const H2Wrapper = styled.div`
display: flex;
align-items: center;
padding: 20px 0;
`;
const StyledTooltip = styled(Tooltip)`
position: relative;
top: 2px;
`;
const AccountName = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const AccountTitle = styled.div`
font-size: ${FONT_SIZE.WALLET_TITLE};
font-weight: ${FONT_WEIGHT.MEDIUM};
color: ${colors.WALLET_TITLE};
`;
const StyledCoinLogo = styled(CoinLogo)`
margin-right: 10px;
`;
const StyledIcon = styled(Icon)`
position: relative;
top: -7px;
&:hover {
cursor: pointer;
switch (network.type) {
case 'ethereum':
return <EthereumTypeSummary />;
case 'ripple':
return <RippleTypeSummary />;
default:
return null;
}
`;
const AsyncSelectWrapper = styled.div`
padding-bottom: 32px;
`;
const AddedTokensWrapper = styled.div``;
const AccountSummary = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
network,
tokens,
pending,
loader,
shouldRender,
} = props.selectedAccount;
const { type, title, message } = loader;
if (!device || !account || !network || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const explorerLink: string = `${network.explorer.address}${account.address}`;
const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol);
const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10);
return (
<Content>
<React.Fragment>
<AccountHeading>
<AccountName>
<StyledCoinLogo network={account.network} />
<AccountTitle>Account #{parseInt(account.index, 10) + 1}</AccountTitle>
</AccountName>
<Link href={explorerLink} isGray>See full transaction history</Link>
</AccountHeading>
<AccountBalance
network={network}
balance={balance}
fiat={props.fiat}
/>
<H2Wrapper>
<H2>Tokens</H2>
<StyledTooltip
maxWidth={200}
placement="top"
content="Insert token name, symbol or address to be able to send it."
>
<StyledIcon
icon={ICONS.HELP}
color={colors.TEXT_SECONDARY}
size={24}
/>
</StyledTooltip>
</H2Wrapper>
<AsyncSelectWrapper>
<AsyncSelect
isSearchable
defaultOptions
value={null}
isMulti={false}
placeholder="Search for the token"
loadingMessage={() => 'Loading...'}
noOptionsMessage={() => 'Token not found'}
onChange={(token) => {
if (token.name) {
const isAdded = tokens.find(t => t.symbol === token.symbol);
if (!isAdded) {
props.addToken(token, account);
}
}
}}
loadOptions={input => props.loadTokens(input, account.network)}
formatOptionLabel={(option) => {
const isAdded = tokens.find(t => t.symbol === option.symbol);
if (isAdded) {
return `${option.name} (Already added)`;
}
return option.name;
}}
getOptionLabel={option => option.name}
getOptionValue={option => option.symbol}
/>
</AsyncSelectWrapper>
<AddedTokensWrapper>
{ tokens.length < 1 && (<AddTokenMessage />) }
{tokens.map(token => (
<AddedToken
key={token.symbol}
token={token}
pending={pending}
removeToken={props.removeToken}
/>
))}
</AddedTokensWrapper>
</React.Fragment>
</Content>
);
};
export default AccountSummary;
});

View File

@ -0,0 +1,45 @@
/* @flow */
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import type { MapStateToProps, MapDispatchToProps } from 'react-redux';
import * as TokenActions from 'actions/TokenActions';
import type { State, Dispatch } from 'flowtype';
import Summary from './index';
type OwnProps = { }
type StateProps = {
selectedAccount: $ElementType<State, 'selectedAccount'>,
summary: $ElementType<State, 'summary'>,
wallet: $ElementType<State, 'wallet'>,
tokens: $ElementType<State, 'tokens'>,
fiat: $ElementType<State, 'fiat'>,
localStorage: $ElementType<State, 'localStorage'>,
};
type DispatchProps = {
addToken: typeof TokenActions.add,
loadTokens: typeof TokenActions.load,
removeToken: typeof TokenActions.remove,
}
export type Props = StateProps & DispatchProps;
const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: State): StateProps => ({
selectedAccount: state.selectedAccount,
summary: state.summary,
wallet: state.wallet,
tokens: state.tokens,
fiat: state.fiat,
localStorage: state.localStorage,
});
const mapDispatchToProps: MapDispatchToProps<Dispatch, OwnProps, DispatchProps> = (dispatch: Dispatch): DispatchProps => ({
addToken: bindActionCreators(TokenActions.add, dispatch),
loadTokens: bindActionCreators(TokenActions.load, dispatch),
removeToken: bindActionCreators(TokenActions.remove, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Summary);

View File

@ -0,0 +1,115 @@
/* @flow */
import styled from 'styled-components';
import React from 'react';
import { H2 } from 'components/Heading';
import BigNumber from 'bignumber.js';
import Icon from 'components/Icon';
import ICONS from 'config/icons';
import colors from 'config/colors';
import Tooltip from 'components/Tooltip';
import Content from 'views/Wallet/components/Content';
import CoinLogo from 'components/images/CoinLogo';
import * as stateUtils from 'reducers/utils';
import Link from 'components/Link';
import { FONT_WEIGHT, FONT_SIZE } from 'config/variables';
import AccountBalance from '../components/Balance';
import type { Props } from './Container';
const AccountHeading = styled.div`
padding-bottom: 35px;
display: flex;
justify-content: space-between;
align-items: center;
`;
const H2Wrapper = styled.div`
display: flex;
align-items: center;
padding: 20px 0;
`;
const StyledTooltip = styled(Tooltip)`
position: relative;
top: 2px;
`;
const AccountName = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const AccountTitle = styled.div`
font-size: ${FONT_SIZE.WALLET_TITLE};
font-weight: ${FONT_WEIGHT.BASE};
color: ${colors.WALLET_TITLE};
`;
const StyledCoinLogo = styled(CoinLogo)`
margin-right: 10px;
`;
const StyledIcon = styled(Icon)`
position: relative;
top: -7px;
&:hover {
cursor: pointer;
}
`;
const AccountSummary = (props: Props) => {
const device = props.wallet.selectedDevice;
const {
account,
network,
pending,
loader,
shouldRender,
} = props.selectedAccount;
const { type, title, message } = loader;
if (!device || !account || !network || !shouldRender) return <Content type={type} title={title} message={message} isLoading />;
const explorerLink: string = `${network.explorer.address}${account.address}`;
const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol);
const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10);
return (
<Content>
<React.Fragment>
<AccountHeading>
<AccountName>
<StyledCoinLogo network={account.network} />
<AccountTitle>Account #{parseInt(account.index, 10) + 1}</AccountTitle>
</AccountName>
<Link href={explorerLink} isGray>See full transaction history</Link>
</AccountHeading>
<AccountBalance
network={network}
balance={balance}
fiat={props.fiat}
/>
<H2Wrapper>
<H2>History</H2>
<StyledTooltip
maxWidth={200}
placement="top"
content="Insert token name, symbol or address to be able to send it."
>
<StyledIcon
icon={ICONS.HELP}
color={colors.TEXT_SECONDARY}
size={24}
/>
</StyledTooltip>
</H2Wrapper>
</React.Fragment>
</Content>
);
};
export default AccountSummary;

View File

@ -16,9 +16,9 @@ import ImportView from 'views/Landing/views/Import/Container';
// wallet views
import WalletContainer from 'views/Wallet';
import AccountSummary from 'views/Wallet/views/Account/Summary/Container';
import AccountSend from 'views/Wallet/views/Account/Send/Container';
import AccountReceive from 'views/Wallet/views/Account/Receive/Container';
import AccountSummary from 'views/Wallet/views/Account/Summary';
import AccountSend from 'views/Wallet/views/Account/Send';
import AccountReceive from 'views/Wallet/views/Account/Receive';
import AccountSignVerify from 'views/Wallet/views/Account/SignVerify/Container';
import WalletDashboard from 'views/Wallet/views/Dashboard';

View File

@ -50,7 +50,7 @@ module.exports = {
rules: [
{
test: /\.js?$/,
exclude: /node_modules/,
exclude: [/node_modules/, /trezor-blockchain-link\/build\/workers/],
use: ['babel-loader'],
},
{

View File

@ -11896,10 +11896,9 @@ tr46@^1.0.1:
dependencies:
punycode "^2.1.0"
trezor-connect@6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/trezor-connect/-/trezor-connect-6.0.2.tgz#a4ca892cc4a167b34b97644e1404a56f6a110379"
integrity sha512-oSYTPnQD9ZT3vO2hBRdgealHG7t8Wu3oTZXX/U4eRxJZ0WqdEcXG7Nvqe1BL6Rl3VTuj7ALT9DL1Uq3QFYAc3g==
trezor-connect@6.0.3-beta.4:
version "6.0.3-beta.4"
resolved "https://registry.yarnpkg.com/trezor-connect/-/trezor-connect-6.0.3-beta.4.tgz#c0b3dfe809756b455e5b5b34fb2a104846da4f72"
dependencies:
babel-runtime "^6.26.0"
events "^1.1.1"