From d45596db31a8e9e7d0cd63349e0d135126170524 Mon Sep 17 00:00:00 2001 From: Maxwell Lasky Date: Tue, 24 Aug 2021 13:19:14 -0600 Subject: [PATCH] feature: adds neo legacy test net support / migration support (#2177) * Sets up foundation for migration with working legacy testnet support * lint * Fix activity history * lint * Update snaps * Additional testnet support * lint * TestNet and explorer linking enhancements * A working migration implementation on testnet * Mint CGAS if below threshold * Adds error handling and UX goodies * Update snaps * Working on mainnet * lint * Ready for test build * updates copy for migration workflow * adds migration tracker remark * Bump version and update snapshots * lint * update node list * Fixes remaining defects * Update snaps Co-authored-by: lllwvlvwlll --- .eslintrc | 3 +- .../__snapshots__/ConfirmModal.test.js.snap | 16 +- .../__snapshots__/Settings.test.js.snap | 2 +- .../__snapshots__/Sidebar.test.js.snap | 34 ++ app/actions/balancesActions.js | 52 ++- app/actions/blockHeightActions.js | 2 + app/actions/newMigrationWalletActions.js | 10 + app/actions/nodeNetworkActions.js | 1 + app/actions/nodeStorageActions.js | 6 + app/actions/settingsActions.js | 3 +- app/actions/transactionHistoryActions.js | 21 +- app/assets/icons/clock.svg | 9 + .../images/release-assets/migration-dark.svg | 9 + .../images/release-assets/migration-light.svg | 9 + app/assets/navigation/migration.svg | 7 + .../Transaction/MigrationTransaction.jsx | 108 ++++++ .../Blockchain/Transaction/Transaction.scss | 98 +++++ .../Transaction/TransactionList.jsx | 8 +- .../PortfolioPanel/PortfolioPanel.scss | 1 + .../TokenBalancesPanel/TokenBalancesPanel.jsx | 5 +- .../Inputs/SelectInput/SelectInput.jsx | 9 +- app/components/Inputs/TextInput/TextInput.js | 2 + .../CreateMigrationWallet.jsx | 327 +++++++++++++++++ .../CreateMigrationWallet.scss | 78 ++++ .../Migration/CreateMigrationWallet/index.js | 28 ++ .../Migration/Explanation/Explanation.jsx | 67 ++++ .../Migration/Explanation/Explanation.scss | 77 ++++ app/components/Migration/Explanation/index.js | 3 + app/components/Migration/History/History.jsx | 116 ++++++ app/components/Migration/History/History.scss | 54 +++ app/components/Migration/History/index.js | 24 ++ .../Migration/TokenSwap/TokenSwap.jsx | 37 ++ .../Migration/TokenSwap/TokenSwap.scss | 63 ++++ app/components/Migration/TokenSwap/index.js | 26 ++ .../Modals/ConfirmModal/ConfirmModal.jsx | 60 ++-- .../MigrationDetails/MigrationDetails.jsx | 155 ++++++++ .../MigrationDetails/MigrationDetails.scss | 141 ++++++++ .../Modals/MigrationDetails/index.js | 3 + .../ReleaseNotesModal/ReleaseNotesModal.jsx | 34 +- .../ReleaseNotesModal/ReleaseNotesModal.scss | 2 +- app/components/Root/Routes.jsx | 2 + app/components/Send/SendPanel/SendPanel.scss | 22 +- .../SendRecipientList/SendRecipientList.scss | 30 ++ .../SendRecipientListItem/index.jsx | 25 +- .../SendPanel/SendRecipientList/index.jsx | 12 +- app/components/Send/SendPanel/index.jsx | 52 ++- .../Settings/EncryptForm/EncryptForm.jsx | 2 +- .../Settings/EncryptPanel/EncryptPanel.jsx | 20 +- app/components/Settings/EncryptPanel/index.js | 2 + app/containers/App/App.jsx | 1 + app/containers/App/Sidebar/Sidebar.jsx | 19 + app/containers/EditWallet/index.js | 4 +- app/containers/Encrypt/Encrypt.jsx | 25 +- app/containers/Encrypt/index.js | 3 +- app/containers/Home/Home.jsx | 4 +- app/containers/Home/HomeLayout.jsx | 2 + .../LoginLedgerNanoS/LoginLedgerNanoS.jsx | 71 ++-- app/containers/LoginLedgerNanoS/index.js | 2 + .../LoginLocalStorage/LoginLocalStorage.jsx | 47 +++ app/containers/LoginLocalStorage/index.js | 24 ++ app/containers/Migration/Migration.jsx | 334 ++++++++++++++++++ app/containers/Migration/Migration.scss | 146 ++++++++ app/containers/Migration/index.js | 48 +++ .../ModalRenderer/ModalRenderer.jsx | 3 + app/containers/Send/Send.jsx | 163 ++++++++- app/containers/Send/Send.scss | 30 ++ app/containers/Send/index.js | 3 + app/core/constants.js | 2 + app/core/explorer.js | 15 +- app/core/nodes-main-net.json | 18 - app/modules/generateWallet.js | 13 +- app/modules/migration.js | 252 +++++++++++++ app/modules/notifications.js | 3 +- app/modules/transactions.js | 83 ++++- package.json | 4 +- yarn.lock | 103 +++++- 76 files changed, 3120 insertions(+), 179 deletions(-) create mode 100644 app/actions/newMigrationWalletActions.js create mode 100644 app/assets/icons/clock.svg create mode 100644 app/assets/images/release-assets/migration-dark.svg create mode 100644 app/assets/images/release-assets/migration-light.svg create mode 100644 app/assets/navigation/migration.svg create mode 100644 app/components/Blockchain/Transaction/MigrationTransaction.jsx create mode 100644 app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.jsx create mode 100644 app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.scss create mode 100644 app/components/Migration/CreateMigrationWallet/index.js create mode 100644 app/components/Migration/Explanation/Explanation.jsx create mode 100644 app/components/Migration/Explanation/Explanation.scss create mode 100644 app/components/Migration/Explanation/index.js create mode 100644 app/components/Migration/History/History.jsx create mode 100644 app/components/Migration/History/History.scss create mode 100644 app/components/Migration/History/index.js create mode 100644 app/components/Migration/TokenSwap/TokenSwap.jsx create mode 100644 app/components/Migration/TokenSwap/TokenSwap.scss create mode 100644 app/components/Migration/TokenSwap/index.js create mode 100644 app/components/Modals/MigrationDetails/MigrationDetails.jsx create mode 100644 app/components/Modals/MigrationDetails/MigrationDetails.scss create mode 100644 app/components/Modals/MigrationDetails/index.js create mode 100644 app/containers/Migration/Migration.jsx create mode 100644 app/containers/Migration/Migration.scss create mode 100644 app/containers/Migration/index.js create mode 100644 app/modules/migration.js diff --git a/.eslintrc b/.eslintrc index 96fb4ecb6..ca269c07a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -74,7 +74,8 @@ "flowtype/generic-spacing": 0, "no-param-reassign": ["warn"], "no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"], - "no-await-in-loop": 0 + "no-await-in-loop": 0, + "jsx-a11y/alt-text": 0 }, "settings": { "import/resolver": { diff --git a/__tests__/components/Modals/__snapshots__/ConfirmModal.test.js.snap b/__tests__/components/Modals/__snapshots__/ConfirmModal.test.js.snap index 7ebdd365a..0bbe96aeb 100644 --- a/__tests__/components/Modals/__snapshots__/ConfirmModal.test.js.snap +++ b/__tests__/components/Modals/__snapshots__/ConfirmModal.test.js.snap @@ -22,12 +22,12 @@ exports[`ConfirmModal should render without crashing 1`] = ` width="500px" >
- v - 2.7.4 + 2.8.0
+ + + + + + + +
+ Migration +
+
+ +
+
console.error(e)) - const assets = get(assetBalances, 'balance.assets', {}) + const testnetBalances = await axios.get( + `https://dora.coz.io/api/v1/neo2/testnet/get_balance/${address}`, + ) + const parsedTestNetBalances = {} + + testnetBalances.data.balance.forEach(token => { + parsedTestNetBalances[token.asset_symbol || token.symbol] = { + balance: token.amount, + hash: token.asset_hash, + } + }) + + const assets = + net === 'MainNet' + ? get(assetBalances, 'balance.assets', {}) + : parsedTestNetBalances + // The API doesn't always return NEO or GAS keys if, for example, the address only has one asset - const neoBalance = assets.NEO ? assets.NEO.balance.toString() : '0' + // eslint-disable-next-line + const neoBalance = assets.NEO + ? net === 'MainNet' + ? assets.NEO.balance.toString() + : assets.NEO.balance + : '0' + // eslint-disable-next-line const gasBalance = assets.GAS - ? assets.GAS.balance.round(COIN_DECIMAL_LENGTH).toString() + ? net === 'MainNet' + ? assets.GAS.balance.round(COIN_DECIMAL_LENGTH).toString() + : assets.GAS.balance : '0' + const parsedAssets = [ { [ASSETS.NEO]: neoBalance }, { [ASSETS.GAS]: gasBalance }, ] + + if (net === 'TestNet') { + Object.keys(parsedTestNetBalances).map(sym => { + const balance = { + [parsedTestNetBalances[sym].hash]: { + balance: toBigNumber(parsedTestNetBalances[sym].balance), + symbol: sym, + networkId: '2', + scriptHash: parsedTestNetBalances[sym].hash, + }, + } + if (sym !== 'GAS' && sym !== 'NEO') { + return parsedAssets.push(balance) + } + + return null + }) + } + determineIfBalanceUpdated( { [ASSETS.NEO]: neoBalance }, soundEnabled, diff --git a/app/actions/blockHeightActions.js b/app/actions/blockHeightActions.js index 4cd36a1a8..245af1eac 100644 --- a/app/actions/blockHeightActions.js +++ b/app/actions/blockHeightActions.js @@ -14,9 +14,11 @@ export const ID = 'blockHeight' export const getBlockHeight = async (networkId: string) => { let url = await getNode(networkId) + if (isEmpty(url)) { url = await getRPCEndpoint(networkId) } + const client = new rpc.RPCClient(url) const count = await client.getBlockCount() return count diff --git a/app/actions/newMigrationWalletActions.js b/app/actions/newMigrationWalletActions.js new file mode 100644 index 000000000..6cb3390bc --- /dev/null +++ b/app/actions/newMigrationWalletActions.js @@ -0,0 +1,10 @@ +// @flow +import { createActions } from 'spunky' + +export const ID = 'migration' + +type Props = { + name: string, +} + +export default createActions(ID, ({ name }: Props = {}) => () => name) diff --git a/app/actions/nodeNetworkActions.js b/app/actions/nodeNetworkActions.js index e16a42ef2..68669b710 100644 --- a/app/actions/nodeNetworkActions.js +++ b/app/actions/nodeNetworkActions.js @@ -43,6 +43,7 @@ export default createActions(ID, ({ networkId }) => async () => { data => !NODE_EXLUSION_CRITERIA.some(criteria => data.url.includes(criteria)), ) + if (chain === 'neo2') { switch (networkId) { case MAIN_NETWORK_ID: diff --git a/app/actions/nodeStorageActions.js b/app/actions/nodeStorageActions.js index c13068abe..b4c369575 100644 --- a/app/actions/nodeStorageActions.js +++ b/app/actions/nodeStorageActions.js @@ -32,6 +32,11 @@ type Props = { net: Net, } +export const resetCachedNode = () => { + delete cachedRPCUrl.MainNet + delete cachedRPCUrl.TestNet +} + export const determineIfCacheIsExpired = ( timestamp: number, expiration: number = CACHE_EXPIRATION, @@ -159,6 +164,7 @@ export const getNode = async ( if (!nodeInStorage || !expiration || determineIfCacheIsExpired(expiration)) { return '' } + return nodeInStorage } diff --git a/app/actions/settingsActions.js b/app/actions/settingsActions.js index 43ddb2e28..a194a2f95 100644 --- a/app/actions/settingsActions.js +++ b/app/actions/settingsActions.js @@ -65,13 +65,12 @@ export const updateSettingsActions = createActions( // $FlowFixMe (values: Settings = {}) => async (): Promise => { const settings = await getSettings() - const { chain } = settings + const { chain } = values const newSettings = { ...settings, ...values, } const parsedForLocalStorage = cloneDeep(newSettings) - if (chain === 'neo2') { const tokensForStorage = [ ...newSettings.tokens.filter(token => token.isUserGenerated), diff --git a/app/actions/transactionHistoryActions.js b/app/actions/transactionHistoryActions.js index 4f2eb2602..fc1dfb26c 100644 --- a/app/actions/transactionHistoryActions.js +++ b/app/actions/transactionHistoryActions.js @@ -6,6 +6,7 @@ import { createActions } from 'spunky' import { TX_TYPES } from '../core/constants' import { findAndReturnTokenInfo } from '../util/findAndReturnTokenInfo' import { getSettings } from './settingsActions' +import { toBigNumber } from '../core/math' type Props = { net: string, @@ -30,6 +31,7 @@ export async function parseAbstractData( const parsedTo = abstract => { if (abstract.address_to === 'fees') return 'NETWORK FEES' + if (abstract.address_to === 'fee') return 'NETWORK FEES' if (abstract.address_to === 'mint') return 'MINT TOKENS' return abstract.address_to } @@ -50,7 +52,7 @@ export async function parseAbstractData( from: parsedFrom(abstract), txid: abstract.txid, time: abstract.time, - amount: abstract.amount, + amount: toBigNumber(abstract.amount).toString(), asset, symbol: asset.symbol, image: asset.image, @@ -96,6 +98,23 @@ export default createActions( }/get_address_abstracts/${address}/${page}`, ) + // eslint-disable-next-line + data = results.data + } else if (net === 'TestNet' && chain === 'neo2') { + const results = await axios.get( + `https://dora.coz.io/api/v1/neo2/testnet/get_address_abstracts/${address}/${page}`, + ) + results.data.entries = results.data.entries.map(entry => { + const parsedEntry = { ...entry } + if ( + entry.asset === + '602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7' + ) { + parsedEntry.amount = entry.amount * 100000000 + } + + return parsedEntry + }) // eslint-disable-next-line data = results.data } else { diff --git a/app/assets/icons/clock.svg b/app/assets/icons/clock.svg new file mode 100644 index 000000000..274f080a9 --- /dev/null +++ b/app/assets/icons/clock.svg @@ -0,0 +1,9 @@ + + + Path + + + + + + \ No newline at end of file diff --git a/app/assets/images/release-assets/migration-dark.svg b/app/assets/images/release-assets/migration-dark.svg new file mode 100644 index 000000000..29c386bde --- /dev/null +++ b/app/assets/images/release-assets/migration-dark.svg @@ -0,0 +1,9 @@ + + + Bitmap + + + + + + \ No newline at end of file diff --git a/app/assets/images/release-assets/migration-light.svg b/app/assets/images/release-assets/migration-light.svg new file mode 100644 index 000000000..714dd649d --- /dev/null +++ b/app/assets/images/release-assets/migration-light.svg @@ -0,0 +1,9 @@ + + + Bitmap + + + + + + \ No newline at end of file diff --git a/app/assets/navigation/migration.svg b/app/assets/navigation/migration.svg new file mode 100644 index 000000000..c1c082cc7 --- /dev/null +++ b/app/assets/navigation/migration.svg @@ -0,0 +1,7 @@ + + + Icon/Migrate-Light + + + + \ No newline at end of file diff --git a/app/components/Blockchain/Transaction/MigrationTransaction.jsx b/app/components/Blockchain/Transaction/MigrationTransaction.jsx new file mode 100644 index 000000000..4ee5c8e72 --- /dev/null +++ b/app/components/Blockchain/Transaction/MigrationTransaction.jsx @@ -0,0 +1,108 @@ +// @flow +import React from 'react' +import moment from 'moment' +import classNames from 'classnames' + +import CheckMarkIcon from '../../../assets/icons/confirm.svg' +import ClockIcon from '../../../assets/icons/clock.svg' +import styles from './Transaction.scss' +import { imageMap } from '../../../assets/nep5/png' +import { toBigNumber } from '../../../core/math' + +const electron = require('electron').remote + +type Props = { + tx: { + time: number, + tokenname: string, + assetHash: string, + destTransactionStatus: Number, + srcTransactionStatus: number, + amount: string, + }, +} +const TOKEN_MAP = { + f46719e2d16bf50cddcef9d4bbfece901f73cbb6: 'nNEO', + '17da3881ab2d050fea414c80b3fa8324d756f60e': 'nNEO', + '74f2dc36a68fdc4682034178eb2220729231db76': 'CGAS', + c56f33fc6ecfcd0c225c4ab356fee59390af8560be0e930faebe74a6daff7c9b: 'NEO', + '602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7': 'GAS', +} +export default class MigrationTransaction extends React.Component { + renderTxDate = (time: ?number) => { + if (!time) { + return null + } + + return ( +
+ {moment.unix(time).format('MM/DD/YYYY | HH:mm:ss')} +
+ ) + } + + render = () => { + const { tx } = this.props + + return ( + +
+
+
+ {imageMap[TOKEN_MAP[tx.assetHash]] && ( + + )} + {TOKEN_MAP[tx.assetHash]} +
+
+ {toBigNumber(tx.amount).toString()} +
+ + {this.renderTxDate(tx.time)} + +
+ {tx.destTransactionStatus === 0 && + tx.srcTransactionStatus === 0 ? ( +
+ Completed +
+ ) : ( +
+ Pending{' '} +
+ )} +
+
+
+
+ + ) + } + + renderTxDate = (time: ?number) => { + if (!time) { + return null + } + + return ( +
+ {moment.unix(time).format('MM/DD/YYYY | HH:mm:ss')} +
+ ) + } +} diff --git a/app/components/Blockchain/Transaction/Transaction.scss b/app/components/Blockchain/Transaction/Transaction.scss index 4456801af..dd19f3097 100644 --- a/app/components/Blockchain/Transaction/Transaction.scss +++ b/app/components/Blockchain/Transaction/Transaction.scss @@ -146,3 +146,101 @@ } } } + +.migrationTransaction { + width: 100%; + display: flex; + height: 60px; + // background: #37414b; + // border-bottom: solid 2px#4cffb3; + cursor: pointer; + margin-bottom: 2px; + // margin: -2x -24px 2px -24px; + + .migrationTxAddressContainer { + // width: 320px; + text-align: center; + align-items: center; + display: flex; + font-size: 10px; + justify-content: space-between; + + span { + text-overflow: ellipsis; + overflow: hidden; + } + + button { + align-self: flex-end; + margin-top: 5px; + + svg { + path { + fill: var(--tx-list-copy-icon); + } + } + } + + .migrationAssetHash { + margin-right: 4px; + } + } + + .statusContainer { + display: flex; + align-items: center; + font-size: 12px; + } + + .statusCompleteContainer { + display: flex; + align-items: center; + color: var(--tx-icon-color); + // width: 80px; + svg { + margin-right: 3px; + + path { + fill: var(--tx-icon-color); + } + } + } + + .statusPendingContainer { + display: flex; + align-items: center; + margin-right: 6px; + min-height: 18px; + min-width: 80px; + + svg { + margin-right: 3px; + min-height: 18px; + min-width: 24px; + + path { + fill: var(--base-text); + } + } + } +} + +.errorTransaction { + opacity: 0.4; +} + +.statusProgessBar { + height: 2px; + width: 60%; + background-color: #4cffb3; +} + +.migrationTxWrapper { + width: 100%; + display: flex; + flex-direction: column; +} + +.isComplete { + width: 100%; +} diff --git a/app/components/Blockchain/Transaction/TransactionList.jsx b/app/components/Blockchain/Transaction/TransactionList.jsx index 1cac1e491..39d8ea410 100644 --- a/app/components/Blockchain/Transaction/TransactionList.jsx +++ b/app/components/Blockchain/Transaction/TransactionList.jsx @@ -7,17 +7,21 @@ type Props = { className?: string, alternateRows?: boolean, children: Array, + rowClassName?: string, } export default class TransactionList extends React.Component { renderChildren = () => { - const { children, alternateRows } = this.props + const { children, alternateRows, rowClassName } = this.props return React.Children.map(children, (child, i) => { const oddRowClass = alternateRows && { [styles.oddNumberedRow]: i % 2 === 0, } return ( -
  • +
  • {child}
  • ) diff --git a/app/components/Dashboard/PortfolioPanel/PortfolioPanel.scss b/app/components/Dashboard/PortfolioPanel/PortfolioPanel.scss index 5404f8097..c0b6a935a 100644 --- a/app/components/Dashboard/PortfolioPanel/PortfolioPanel.scss +++ b/app/components/Dashboard/PortfolioPanel/PortfolioPanel.scss @@ -8,6 +8,7 @@ align-items: stretch; height: 100%; padding: 12px 24px 12px 0; + overflow: hidden; .chart { flex: 0 0 auto; diff --git a/app/components/Dashboard/TokenBalancesPanel/TokenBalancesPanel.jsx b/app/components/Dashboard/TokenBalancesPanel/TokenBalancesPanel.jsx index bac4021c7..617250aa4 100644 --- a/app/components/Dashboard/TokenBalancesPanel/TokenBalancesPanel.jsx +++ b/app/components/Dashboard/TokenBalancesPanel/TokenBalancesPanel.jsx @@ -11,6 +11,7 @@ import { toFixedDecimals } from '../../../core/formatters' import { toBigNumber } from '../../../core/math' import Nothing from '../../../assets/icons/nothing.svg' import { CURRENCIES, PRICE_UNAVAILABLE } from '../../../core/constants' +import { imageMap } from '../../../assets/nep5/svg' type Props = { className: ?string, @@ -130,11 +131,11 @@ export default class TokenBalancesPanel extends React.Component { {balances.sort(this.sortByValueInPortfolio).map(token => ( - {!!token.image && ( + {(!!token.image || imageMap[token.symbol]) && (
    diff --git a/app/components/Inputs/SelectInput/SelectInput.jsx b/app/components/Inputs/SelectInput/SelectInput.jsx index 9b115aa15..d980e27ed 100644 --- a/app/components/Inputs/SelectInput/SelectInput.jsx +++ b/app/components/Inputs/SelectInput/SelectInput.jsx @@ -38,7 +38,8 @@ type RenderItemProps = { onSelect: Function, } -const defaultRenderAfter = (props: Props) => +const defaultRenderAfter = (props: Props) => + !!props.items.length && const defaultItemValue = (item: string) => item @@ -107,7 +108,11 @@ export default class SelectInput extends React.Component { ) } - renderAfter = () => this.props.renderAfter({ onToggle: this.handleToggle }) + renderAfter = () => + this.props.renderAfter({ + onToggle: this.handleToggle, + items: this.props.items, + }) renderDropdown = ({ className }: { className: string }) => { const items = this.getItems() diff --git a/app/components/Inputs/TextInput/TextInput.js b/app/components/Inputs/TextInput/TextInput.js index 5188ce9db..09e8dcdd0 100644 --- a/app/components/Inputs/TextInput/TextInput.js +++ b/app/components/Inputs/TextInput/TextInput.js @@ -21,6 +21,7 @@ type Props = { onBlur?: Function, label: string, shouldRenderErrorIcon?: boolean, + disabled?: boolean, } type State = { @@ -65,6 +66,7 @@ export default class TextInput extends React.Component { className={classNames(styles.input, textInputClassName)} onFocus={this.handleFocus} onBlur={this.handleBlur} + disabled={this.props.disabled} /> {error && this.props.shouldRenderErrorIcon && ( diff --git a/app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.jsx b/app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.jsx new file mode 100644 index 000000000..c193a8ef6 --- /dev/null +++ b/app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.jsx @@ -0,0 +1,327 @@ +// @flow +import classNames from 'classnames' +import React from 'react' +import { intlShape } from 'react-intl' +import { wallet as n3Wallet } from '@cityofzion/neon-js-next' + +import n3Logo from '../../../assets/images/n3_logo.png' +import Address from '../../Blockchain/Address/Address' +import Button from '../../Button' +import PasswordInput from '../../Inputs/PasswordInput' +import TextInput from '../../Inputs/TextInput' +// import CreateImportWalletForm from '../../CreateImportWalletForm' +import styles from './CreateMigrationWallet.scss' +import { EXPLORERS } from '../../../core/constants' + +const PASS_MIN_LENGTH = 4 + +type Props = { + generateNewWalletAccount: Function, + history: Object, + walletCreationDetected: boolean, + authenticated: boolean, + intl: intlShape, + address: string, + + wif: string, + handleWalletCreatedComplete: (name?: string, showModal?: boolean) => void, + networkId: string, + createdWalletName: string, +} + +type State = { + passphrase: string, + passphrase2: string, + passphraseValid: boolean, + passphrase2Valid: boolean, + passphraseError: string, + passphrase2Error: string, + key: string, + walletName: string, + submitButtonDisabled: boolean, + createNewWallet: boolean, +} + +export default class CreateMigrationWallet extends React.Component< + Props, + State, +> { + state = { + passphrase: '', + passphrase2: '', + passphraseValid: false, + passphrase2Valid: false, + passphraseError: '', + passphrase2Error: '', + key: '', + walletName: '', + submitButtonDisabled: false, + createNewWallet: false, + } + + handleChangePassphrase = (e: SyntheticInputEvent) => { + this.setState({ passphrase: e.target.value }, this.validatePassphrase) + } + + handleChangePassphrase2 = (e: SyntheticInputEvent) => { + this.setState({ passphrase2: e.target.value }, this.validatePassphrase2) + } + + validatePassphrase = () => { + const { passphrase: p } = this.state + const { intl } = this.props + // validate min char count + const errorMessage = + p && p.length < PASS_MIN_LENGTH + ? intl.formatMessage( + { + id: 'errors.password.length', + }, + { PASS_MIN_LENGTH }, + ) + : '' + this.setState( + { + passphraseError: errorMessage, + passphraseValid: !!(p && !errorMessage), + }, + this.validatePassphrase2, + ) + } + + validatePassphrase2 = () => { + const { passphrase: p1, passphrase2: p2, passphraseValid } = this.state + const { intl } = this.props + // validate phrases match + const errorMessage = + p1 && p2 && p1 !== p2 && passphraseValid + ? intl.formatMessage({ id: 'errors.password.match' }) + : '' + this.setState({ + passphrase2Error: errorMessage, + passphrase2Valid: !!(p2 && !errorMessage), + }) + } + + getTranslations = () => { + const { intl } = this.props + const walletCreationWalletNamePlaceholder = intl.formatMessage({ + id: 'walletCreationWalletNamePlaceholder', + }) + const walletCreationWalletNameLabel = intl.formatMessage({ + id: 'walletCreationWalletNameLabel', + }) + + const walletCreationWalletPasswordLabel = intl.formatMessage({ + id: 'walletCreationWalletPasswordLabel', + }) + + const walletCreationWalletPasswordPlaceholder = intl.formatMessage({ + id: 'walletCreationWalletPasswordPlaceholder', + }) + + const walletCreationWalletPasswordConfirmLabel = intl.formatMessage({ + id: 'walletCreationWalletPasswordConfirmLabel', + }) + + const walletCreationWalletPasswordConfirmPlaceholder = intl.formatMessage({ + id: 'walletCreationWalletPasswordConfirmPlaceholder', + }) + + return { + walletCreationWalletNamePlaceholder, + walletCreationWalletNameLabel, + walletCreationWalletPasswordLabel, + walletCreationWalletPasswordPlaceholder, + walletCreationWalletPasswordConfirmLabel, + walletCreationWalletPasswordConfirmPlaceholder, + } + } + + isDisabled = () => { + const { + passphraseValid, + passphrase2Valid, + key, + walletName, + submitButtonDisabled, + } = this.state + const validPassphrase = passphraseValid && passphrase2Valid + if (submitButtonDisabled) return true + + const isDisabled = !(validPassphrase && !!walletName && !!key) + + return isDisabled + } + + createWalletAccount = (e: SyntheticMouseEvent<*>) => { + this.setState({ submitButtonDisabled: true }) + e.preventDefault() + const { history, address } = this.props + const { passphrase, passphrase2, key, walletName } = this.state + const { + generateNewWalletAccount, + authenticated, + handleWalletCreatedComplete, + } = this.props + + generateNewWalletAccount( + passphrase, + passphrase2, + key, + null, + 'WIF', + history, + walletName, + authenticated, + () => this.setState({ submitButtonDisabled: false }), + 'neo3', + { + legacyAddress: address, + walletCreatedCallback: () => + handleWalletCreatedComplete(walletName, true), + }, + ) + } + + componentDidMount() { + this.setState({ + key: this.props.wif, + }) + } + + render() { + const { + walletCreationWalletNamePlaceholder, + walletCreationWalletNameLabel, + walletCreationWalletPasswordLabel, + walletCreationWalletPasswordPlaceholder, + walletCreationWalletPasswordConfirmLabel, + walletCreationWalletPasswordConfirmPlaceholder, + } = this.getTranslations() + + const { passphraseError, passphrase2Error, key, walletName } = this.state + + this.isDisabled() + + const TO_ACCOUNT = new n3Wallet.Account(this.props.wif) + + return ( +
    +
    + +
    +

    Create your N3 wallet

    +

    + Let's get started by creating your new N3 wallet. Your private key + will remain the same, but the derived address will be new! All you + have to do is give your N3 wallet a name and password. We'll take + care of the rest! +

    +
    +
    + {this.props.walletCreationDetected && !this.state.createNewWallet ? ( + +
    + {' '} + It looks like you have already created a wallet for your + corresponding address on N3... +
    +
    +
    + Your N3 address is:{' '} +
    + {/* $FlowFixMe */} +
    {TO_ACCOUNT.address}
    +
    +
    +
    +
    + Your N3 wallet name is:
    + {this.props.createdWalletName} +
    +
    + + {/*
    +
    +
    */} +
    + + +
    + {/*
    +
    +
    */} +
    + ) : ( +
    +
    +
    + this.setState({ walletName: e.target.value })} + placeholder={walletCreationWalletNamePlaceholder} + autoFocus + /> + + +
    +
    + +
    +
    +
    + )} +
    + ) + } +} diff --git a/app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.scss b/app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.scss new file mode 100644 index 000000000..8be6b4316 --- /dev/null +++ b/app/components/Migration/CreateMigrationWallet/CreateMigrationWallet.scss @@ -0,0 +1,78 @@ +.container { + display: flex; + padding: 24px; + flex-direction: column; + // height: 100%; + // margin-bottom: 48px; + width: 100%; + + h3 { + margin: 0; + } +} + +.explanation { + display: flex; + align-items: center; + margin-bottom: 12px; + + p { + font-size: 14px; + margin-top: 4px; + line-height: 17px; + } + + img { + max-width: 68px; + max-height: 68px; + margin-right: 24px; + } +} + +.formContainer { + // width: 300px; + width: 100%; + margin: auto; + justify-content: center; + display: flex; + height: 100%; + flex: 1; + flex-direction: column; + + .inputContainer { + max-width: 380px; + margin: auto; + height: 100%; + } + + form { + width: 100%; + height: 100%; + } +} + +.buttonContainer { + margin-top: -74px; + display: flex; + justify-content: flex-end; + + button { + max-width: 212px; + margin-left: 24px; + } +} + +.walletFound { + margin-top: 24px; + font-size: 14px; + height: 100%; +} + +.noMargin { + margin: 0 !important; +} + +.addressLink { + color: var(--base-link-color); + cursor: pointer; +} diff --git a/app/components/Migration/CreateMigrationWallet/index.js b/app/components/Migration/CreateMigrationWallet/index.js new file mode 100644 index 000000000..200f7eb79 --- /dev/null +++ b/app/components/Migration/CreateMigrationWallet/index.js @@ -0,0 +1,28 @@ +// @flow +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { bindActionCreators } from 'redux' +import { injectIntl } from 'react-intl' + +import CreateMigrationWallet from './CreateMigrationWallet' +import { generateNewWalletAccount } from '../../../modules/generateWallet' +import withChainData from '../../../hocs/withChainData' +import withAuthData from '../../../hocs/withAuthData' +import withNetworkData from '../../../hocs/withNetworkData' + +const actionCreators = { + generateNewWalletAccount, +} + +const mapDispatchToProps = dispatch => + bindActionCreators(actionCreators, dispatch) + +export default compose( + connect( + null, + mapDispatchToProps, + ), + withAuthData(), + withChainData(), + withNetworkData(), +)(injectIntl(CreateMigrationWallet)) diff --git a/app/components/Migration/Explanation/Explanation.jsx b/app/components/Migration/Explanation/Explanation.jsx new file mode 100644 index 000000000..07cffba02 --- /dev/null +++ b/app/components/Migration/Explanation/Explanation.jsx @@ -0,0 +1,67 @@ +// @flow +import classNames from 'classnames' +import React from 'react' +import { FormattedHTMLMessage, FormattedMessage } from 'react-intl' + +import InfoIcon from '../../../assets/icons/info.svg' + +import styles from './Explanation.scss' + +// const CREATE_WALLET_STEP = 'CREATE_WALLET_STEP' +// const SELECT_TOKEN_STEP = 'SELECT_TOKEN_STEP' +// const MIGRATION_HISTORY_STEP = 'MIGRATION_HISTORY_STEP' + +const STEPS = { + CREATE_WALLET_STEP: { + explanation: 'Create N3 Wallet', + }, + SELECT_TOKEN_STEP: { + explanation: 'Select tokens to migrate', + }, + MIGRATION_HISTORY_STEP: { + explanation: 'View migration history', + }, +} + +export default function Explanation({ + currentStep, + handleStepChange, +}: { + currentStep: string, + handleStepChange: string => void, +}) { + return ( +
    +
    + +
    How does token migration work?
    +
    +
    + {/* $FlowFixMe */} + {Object.keys(STEPS).map((key, i) => { + const { explanation } = STEPS[key] + return ( +
    handleStepChange(key)} + > +
    {i + 1}
    + {explanation} +
    + ) + })} +
    + +

    + Neon Wallet will format and relay your migration transaction to a + utility which will mint N3 NEO and GAS to your new N3 address. You can + return to the migration tab at any time to view the migration status for + your address or to relay new migration transactions. +

    +
    + ) +} diff --git a/app/components/Migration/Explanation/Explanation.scss b/app/components/Migration/Explanation/Explanation.scss new file mode 100644 index 000000000..75d246fac --- /dev/null +++ b/app/components/Migration/Explanation/Explanation.scss @@ -0,0 +1,77 @@ +.receiveExplanation { + width: 305px; + min-width: 305px; + max-width: 305px; + padding: 24px; + background: var(--panel-receive-explanation); + text-align: center; + font-family: var(--font-gotham-medium); + + .header { + display: flex; + align-items: center; + padding-bottom: 24px; + border-bottom: 1px solid grey; + margin-bottom: 12px; + + .icon { + margin-right: 16px; + } + + .title { + font-size: 16px; + font-weight: 600; + } + } + + .message { + font-size: 16px; + text-align: left; + + p { + margin: 12px 0; + } + } + + p { + font-family: var(--font-gotham-light); + text-align: left; + font-size: 14px; + margin-top: 24px; + } +} + +.stepContainer { + margin: 12px -24px; + font-family: var(--font-gotham-light); + display: flex; + font-size: 14px; + padding: 6px 24px; + cursor: pointer; + display: flex; + align-items: center; +} + +.stepImage { + background: var(--button-max-amount-background); + border-radius: 100%; + height: 38px; + width: 38px; + min-width: 38px; + min-height: 38px; + margin-right: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-family: var(--font-gotham-bold); +} + +.activeStep { + background: var(--sidebar-active-background); + border-left: solid 4px var(--sidebar-active-border); + + .stepImage { + margin-left: -4px; + } +} diff --git a/app/components/Migration/Explanation/index.js b/app/components/Migration/Explanation/index.js new file mode 100644 index 000000000..653965bcf --- /dev/null +++ b/app/components/Migration/Explanation/index.js @@ -0,0 +1,3 @@ +import Explanation from './Explanation' + +export default Explanation diff --git a/app/components/Migration/History/History.jsx b/app/components/Migration/History/History.jsx new file mode 100644 index 000000000..72faa9991 --- /dev/null +++ b/app/components/Migration/History/History.jsx @@ -0,0 +1,116 @@ +// @flow +import React from 'react' +import TransactionList from '../../Blockchain/Transaction/TransactionList' +import Transaction from '../../Blockchain/Transaction' + +import Nothing from '../../../assets/icons/nothing.svg' +import styles from './History.scss' +import MigrationTransaction from '../../Blockchain/Transaction/MigrationTransaction' +import LogoWithStrikethrough from '../../LogoWithStrikethrough' +// import Details from '../../Modals/MigrationDetails' + +const REFRESH_INTERVAL_MS = 30000 + +type Props = { + data: { + transactions: [], + pageCount: number, + }, + fetchAdditonalData: (isDemo?: boolean) => void, + showTxHistoryModal: (tx: Object) => void, + handleRefreshHistory: () => Promise<*>, + net: string, + showSuccessNotification: ({ message: string }) => void, +} + +type State = { + // selectedTransaction: Object | null, +} + +export default class History extends React.Component { + // state = { + // selectedTransaction: null, + // } + + historyDataInterval: IntervalID + + componentDidMount() { + this.addPolling() + } + + componentWillUnmount() { + this.removePolling() + } + + handleScroll = (e: SyntheticInputEvent) => { + const bottom = + e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight + if (bottom) { + this.props.fetchAdditonalData() + } + } + + addPolling = () => { + const { showSuccessNotification } = this.props + this.historyDataInterval = setInterval(async () => { + this.props.handleRefreshHistory().then(() => + showSuccessNotification({ + message: 'Recevied latest migration information.', + }), + ) + }, REFRESH_INTERVAL_MS) + } + + removePolling = () => { + if (this.historyDataInterval) { + clearInterval(this.historyDataInterval) + } + } + + // handleShowMigrationStatus = tx => { + // this.setState({ selectedTransaction: tx }) + // } + + render() { + const { data, net } = this.props + + return ( + +
    +
    +

    Migration Summary

    + {/* {net === 'TestNet' && ( + this.props.fetchAdditonalData(true)}> + DEMO + + )} */} +
    + {data.transactions.length ? ( + + {data.transactions.map((tx, i) => ( +
    this.props.showTxHistoryModal(tx)} + className={styles.txWrapper} + > + +
    + ))} +
    + ) : ( +
    + +
    + )} +
    +
    + ) + } +} diff --git a/app/components/Migration/History/History.scss b/app/components/Migration/History/History.scss new file mode 100644 index 000000000..0a436d8fb --- /dev/null +++ b/app/components/Migration/History/History.scss @@ -0,0 +1,54 @@ +.container { + // padding: 24px; + width: 100%; + overflow-y: auto; + max-height: calc(100vh - 120px) !important; + + h3 { + padding: 0 24px; + } + + #transactionList { + width: 100%; + // margin: -10px; + } +} + +.migrationTransactionListContainer { + // margin: 12px -34px 0 -34px; +} + +.rowClass { + display: flex; + list-style-type: none; + font-weight: 400; + // margin-top: 8px; + + // height: 50px; + + .txid { + flex: 0 0 auto; + align-items: center; + display: flex; + } +} + +.emptyHistory { + height: 500px; +} + +.header { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + + code { + margin-right: 48px; + cursor: pointer; + } +} + +.txWrapper { + width: 100%; +} diff --git a/app/components/Migration/History/index.js b/app/components/Migration/History/index.js new file mode 100644 index 000000000..14f96bca0 --- /dev/null +++ b/app/components/Migration/History/index.js @@ -0,0 +1,24 @@ +// @flow + +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { bindActionCreators } from 'redux' +import withNetworkData from '../../../hocs/withNetworkData' +import { showSuccessNotification } from '../../../modules/notifications' + +import History from './History' + +const actionCreators = { + showSuccessNotification, +} + +const mapDispatchToProps = dispatch => + bindActionCreators(actionCreators, dispatch) + +export default compose( + connect( + null, + mapDispatchToProps, + ), + withNetworkData(), +)(History) diff --git a/app/components/Migration/TokenSwap/TokenSwap.jsx b/app/components/Migration/TokenSwap/TokenSwap.jsx new file mode 100644 index 000000000..88f40a569 --- /dev/null +++ b/app/components/Migration/TokenSwap/TokenSwap.jsx @@ -0,0 +1,37 @@ +// @flow +import React from 'react' +import Send from '../../../containers/Send' + +import styles from './TokenSwap.scss' + +type Props = { + handleSwapComplete: () => void, +} + +type State = {} + +export default class TokenSwap extends React.Component { + render() { + return ( +
    +
    +
    +

    Select a token to migrate

    +

    + You can migrate using multiple transactions in Neon Wallet so + don't feel obligated to migrate all of your assets in a single + event if you aren't comfortable doing so. +

    +
    +
    + +
    + +
    +
    + ) + } +} diff --git a/app/components/Migration/TokenSwap/TokenSwap.scss b/app/components/Migration/TokenSwap/TokenSwap.scss new file mode 100644 index 000000000..15dcf4028 --- /dev/null +++ b/app/components/Migration/TokenSwap/TokenSwap.scss @@ -0,0 +1,63 @@ +.container { + display: flex; + // padding: 24px 24px 0 24px; + flex-direction: column; + height: 100%; + // margin-bottom: 48px; + width: 100%; + + h3 { + margin: 0; + } +} + +.explanation { + display: flex; + align-items: center; + margin-bottom: 12px; + // padding: 24px; + + p { + font-size: 14px; + margin-top: 4px; + line-height: 17px; + } + + img { + max-width: 68px; + max-height: 68px; + margin-right: 24px; + } +} + +.formContainer { + // width: 300px; + width: 100%; + margin: auto; + justify-content: center; + display: flex; + height: 100%; + flex: 1; + flex-direction: column; + padding: 12px 0 0 0; + + .inputContainer { + max-width: 380px; + margin: auto; + } + + form { + width: 100%; + height: 100%; + } +} + +.buttonContainer { + margin-top: 75px; + display: flex; + justify-content: flex-end; + + button { + max-width: 212px; + } +} diff --git a/app/components/Migration/TokenSwap/index.js b/app/components/Migration/TokenSwap/index.js new file mode 100644 index 000000000..e7e59f8f3 --- /dev/null +++ b/app/components/Migration/TokenSwap/index.js @@ -0,0 +1,26 @@ +// @flow +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { bindActionCreators } from 'redux' +import { injectIntl } from 'react-intl' + +import TokenSwap from './TokenSwap' +import { generateNewWalletAccount } from '../../../modules/generateWallet' +import withChainData from '../../../hocs/withChainData' +import withAuthData from '../../../hocs/withAuthData' + +const actionCreators = { + generateNewWalletAccount, +} + +const mapDispatchToProps = dispatch => + bindActionCreators(actionCreators, dispatch) + +export default compose( + connect( + null, + mapDispatchToProps, + ), + withAuthData(), + withChainData(), +)(injectIntl(TokenSwap)) diff --git a/app/components/Modals/ConfirmModal/ConfirmModal.jsx b/app/components/Modals/ConfirmModal/ConfirmModal.jsx index 6a49e7c72..5c44f7f99 100644 --- a/app/components/Modals/ConfirmModal/ConfirmModal.jsx +++ b/app/components/Modals/ConfirmModal/ConfirmModal.jsx @@ -15,6 +15,9 @@ type Props = { hideModal: Function, width: string, renderBody: Function, + shouldRenderHeader: boolean, + height: string, + shouldRenderFooter: boolean, } const ConfirmModal = ({ @@ -25,15 +28,18 @@ const ConfirmModal = ({ text, renderBody, width, + shouldRenderHeader = false, + height, + shouldRenderFooter = true, }: Props) => ( hideModal() && onCancel && onCancel()} + shouldRenderHeader={shouldRenderHeader} style={{ content: { width, - height: '200px', + height: height || '200px', }, }} > @@ -42,29 +48,31 @@ const ConfirmModal = ({ {text && {text}} {renderBody && renderBody()}
    -
    - - -
    + {shouldRenderFooter && ( +
    + + +
    + )}
    ) diff --git a/app/components/Modals/MigrationDetails/MigrationDetails.jsx b/app/components/Modals/MigrationDetails/MigrationDetails.jsx new file mode 100644 index 000000000..7f1137041 --- /dev/null +++ b/app/components/Modals/MigrationDetails/MigrationDetails.jsx @@ -0,0 +1,155 @@ +// @flow + +import React from 'react' +import moment from 'moment' +import classNames from 'classnames' + +import FullHeightPanel from '../../Panel/FullHeightPanel' +import SendIcon from '../../../assets/navigation/migration.svg' +import CloseButton from '../../CloseButton' +import CheckMarkIcon from '../../../assets/icons/confirm.svg' +import ClockIcon from '../../../assets/icons/clock.svg' +import BlockExplorerIcon from '../../../assets/icons/info.svg' +import styles from './MigrationDetails.scss' +import BaseModal from '../BaseModal' + +const electron = require('electron').remote + +type Props = { + hideModal: () => void, + tx: { + time: number, + tokenname: string, + assetHash: string, + destTransactionStatus: Number, + srcTransactionStatus: number, + amount: string, + destAddress: string, + srcTransactionHash: string, + destTransactionHash: string, + }, +} + +const TokenSaleSuccess = ({ tx, hideModal }: Props) => ( + + } + containerClassName={styles.innerFullHeightContentClass} + className={styles.detailsContainer} + renderCloseButton={() => ( +
    hideModal()}> + +
    + )} + > +
    + + +
    +
    + +
    {tx.destAddress}
    +
    +
    +
    + +
    {moment.unix(tx.time).format('MM/DD/YYYY | HH:mm:ss')}
    +
    +
    + +
    + {tx.destTransactionStatus === 0 && + tx.srcTransactionStatus === 0 ? ( +
    + Completed +
    + ) : ( +
    + Pending{' '} +
    + )} +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    { + electron.shell.openExternal( + `https://dora.coz.io/transaction/neo2/mainnet/${ + tx.srcTransactionHash + }`, + ) + }} + > +

    Processed on Neo Legacy

    + {tx.srcTransactionHash} + + {' '} + View on dora + +
    +
    +
    +
    + {tx.destTransactionHash ? ( + +
    +
    { + electron.shell.openExternal( + `https://dora.coz.io/transaction/neo3/mainnet/${ + tx.destTransactionHash + }`, + ) + }} + > +

    Processed on Neo N3

    + {tx.destTransactionHash} + + {' '} + View on dora + +
    + + ) : ( + +
    +
    +

    Processing on Neo N3

    +
    + + )} +
    +
    +
    + + +) + +export default TokenSaleSuccess diff --git a/app/components/Modals/MigrationDetails/MigrationDetails.scss b/app/components/Modals/MigrationDetails/MigrationDetails.scss new file mode 100644 index 000000000..0f522625c --- /dev/null +++ b/app/components/Modals/MigrationDetails/MigrationDetails.scss @@ -0,0 +1,141 @@ +.detailsContainer { + margin: 0 !important; + width: 100%; + display: flex; + flex-direction: column; + z-index: 1000000; + + svg { + path { + fill: var(--base-text); + } + } +} + +.stepStatusContainer { + border-left: dashed 1px #979797; + height: 70px; + margin: -38px 0px -32px 18px; + opacity: 0.5; +} + +.stepStatusContainerPending { + border-left: dashed 1px #979797; + height: 70px; + margin: -38px 0px -14px 18px; + opacity: 0.5; +} + +.innerContainer { + padding: 24px 24px 24px 24px; + z-index: 1000000; + flex-direction: column; + height: 100%; + justify-content: start; + + small { + margin-top: -2px; + cursor: pointer; + display: flex; + align-items: center; + } +} + +.innerFullHeightContentClass { + margin: 0; + height: 100%; +} + +.migrationIcon { + fill: var(--view-layout-header-icon-color); + stroke: var(--view-layout-header-icon-color); +} + +.detailsContainer { + background: var(--input-background); + width: 384px; + border-radius: 4px; +} + +.detailContainer { + padding: 12px; + + font-size: 14px; +} + +.statusDetailContainer { + padding: 12px; + font-size: 14px; + + p { + margin: 0; + margin-bottom: 2px; + } + + code { + font-size: 11px; + color: var(--base-link-color); + white-space: nowrap; + text-overflow: ellipsis; + width: 300px; + display: block; + overflow: hidden; + cursor: pointer; + } +} + +.stepContainer { + display: flex; + align-items: center; + padding: 0 6px 6px 12px; + + svg { + max-width: 16px; + margin-right: 2px; + } + + .completedStep { + height: 12px; + width: 12px; + background-color: #4cffb3; + border-radius: 50%; + display: inline-block; + // margin-left: 6px; + } + + .incompleteStep { + background-color: transparent; + border: 1px solid #d355e7; + height: 12px; + border-radius: 50%; + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + width: 12px; + } +} + +.row { + display: flex; +} + +.statusContainer { + display: flex; + align-items: center; + svg { + margin-right: 4px; + max-width: 16px; + } +} + +.statusContainerPending { + display: flex; + align-items: center; + svg { + margin-right: 4px; + max-width: 10px; + } +} + +.detailsContainerLabel { + height: 24px; +} diff --git a/app/components/Modals/MigrationDetails/index.js b/app/components/Modals/MigrationDetails/index.js new file mode 100644 index 000000000..43b2b2ac6 --- /dev/null +++ b/app/components/Modals/MigrationDetails/index.js @@ -0,0 +1,3 @@ +import Details from './MigrationDetails' + +export default Details diff --git a/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.jsx b/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.jsx index 1bff52ba0..6e2f7386a 100644 --- a/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.jsx +++ b/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.jsx @@ -15,6 +15,9 @@ import PatchDark from '../../../assets/images/release-assets/patch-dark.svg' import N3SupportLight from '../../../assets/images/release-assets/n3_support_light.svg' import N3SupportDark from '../../../assets/images/release-assets/n3_support_dark.svg' +import MigrationLight from '../../../assets/images/release-assets/migration-light.svg' +import MigrationDark from '../../../assets/images/release-assets/migration-dark.svg' + const electron = require('electron').remote type Props = { @@ -45,6 +48,35 @@ const ReleaseNotesModal = ({ hideModal, theme }: Props) => ( )} >
    +
    +
    + Aug 24th 2021 +

    Release v2.8.0

    + +

    + In this update you will find the following updates: +
    +
    + {/* eslint-disable-next-line */} +

  • Support for asset migration to N3
  • +
  • Support for Neo Legacy TestNet
  • +
    + View full details of this release on GitHub +
    +

    + + + electron.shell.openExternal( + 'https://github.com/CityOfZion/neon-wallet/releases/tag/v2.8.0', + ) + } + /> +
    +
    + {theme === 'Light' ? : } +
    +
    Aug 5th 2021 @@ -55,7 +87,7 @@ const ReleaseNotesModal = ({ hideModal, theme }: Props) => (

    {/* eslint-disable-next-line */} -
  • Support for Neo (N3) MainNet and TestNet 🎉🎉
  • +
  • Support for Neo (N3) MainNet and TestNet 🎉
  • Under the hood dependency updates
  • Performance enhancements

  • diff --git a/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.scss b/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.scss index 93bdff754..bfff17acc 100644 --- a/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.scss +++ b/app/components/Modals/ReleaseNotesModal/ReleaseNotesModal.scss @@ -44,7 +44,7 @@ } li { - margin-left: 24px; + margin-left: 0px; } p { diff --git a/app/components/Root/Routes.jsx b/app/components/Root/Routes.jsx index 63c4989d2..ae861f70c 100644 --- a/app/components/Root/Routes.jsx +++ b/app/components/Root/Routes.jsx @@ -28,6 +28,7 @@ import { ROUTES } from '../../core/constants' import OfflineSigningPrompt from '../../containers/OfflineSigningPrompt' import NetworkConfiguration from '../../containers/NetworkConfiguration' import Mobile from '../../containers/Mobile' +import Migration from '../../containers/Migration' export default ({ store }: { store: any }) => ( @@ -129,6 +130,7 @@ export default ({ store }: { store: any }) => ( component={OfflineSigningPrompt} /> + diff --git a/app/components/Send/SendPanel/SendPanel.scss b/app/components/Send/SendPanel/SendPanel.scss index 67515f871..44a54b3e2 100644 --- a/app/components/Send/SendPanel/SendPanel.scss +++ b/app/components/Send/SendPanel/SendPanel.scss @@ -121,7 +121,7 @@ .sendFormButton { width: 450px; - margin: 12px auto 50px auto; + margin: 12px auto 36px auto; } .confirmButtonsContainer { @@ -140,6 +140,7 @@ .sendSuccessPanel { height: 100%; + overflow-y: auto; } .priorityExplanationText { @@ -196,3 +197,22 @@ border: solid thin #8d98ae !important; } } + +.migrationButtonContainer { + // margin-top: 75px; + display: flex; + justify-content: flex-end; + width: 100%; + margin-top: -54px; + + button { + max-width: 212px; + margin: unset; + } +} + +.migrationContent { + flex-direction: column; + flex: 1; + display: flex; +} diff --git a/app/components/Send/SendPanel/SendRecipientList/SendRecipientList.scss b/app/components/Send/SendPanel/SendRecipientList/SendRecipientList.scss index 794c54e77..384ef0b86 100644 --- a/app/components/Send/SendPanel/SendRecipientList/SendRecipientList.scss +++ b/app/components/Send/SendPanel/SendRecipientList/SendRecipientList.scss @@ -1,5 +1,35 @@ @import '../../../../styles/variables'; +.migrationContainer { + display: flex; + flex-direction: column; + width: 100% !important; + // max-width: 100%; + // padding-left: 24px; + margin: auto !important; + // margin-left: 24px !important; + + height: 100%; + max-height: calc(100vh - 120px) !important; + display: flex; + flex-direction: column; + flex: 1; + + .asset { + min-width: 110px; + } + + .amount { + min-width: 160px; + } + + .address { + min-width: 350px !important; + width: 350px !important; + margin-right: 0 !important; + } +} + .sendRecipientListContainer { width: 85%; margin: 0 45px 0 auto; diff --git a/app/components/Send/SendPanel/SendRecipientList/SendRecipientListItem/index.jsx b/app/components/Send/SendPanel/SendRecipientList/SendRecipientListItem/index.jsx index 995c8e275..8d7e0f50c 100644 --- a/app/components/Send/SendPanel/SendRecipientList/SendRecipientListItem/index.jsx +++ b/app/components/Send/SendPanel/SendRecipientList/SendRecipientListItem/index.jsx @@ -5,7 +5,7 @@ import { injectIntl, IntlShape } from 'react-intl' import SelectInput from '../../../../Inputs/SelectInput' import NumberInput from '../../../../Inputs/NumberInput' import DisplayInput from '../../../DisplayInput' -import { toBigNumber } from '../../../../../core/math' +import { isNumber, toBigNumber } from '../../../../../core/math' import { formatNumberByDecimalScale } from '../../../../../core/formatters' import TrashCanIcon from '../../../../../assets/icons/delete.svg' @@ -27,6 +27,7 @@ type Props = { updateRowField: (index: number, field: string, value: any) => any, calculateMaxValue: (asset: string, index: number) => string, intl: IntlShape, + isMigration: boolean, } class SendRecipientListItem extends Component { @@ -38,6 +39,7 @@ class SendRecipientListItem extends Component { clearErrors, calculateMaxValue, asset, + isMigration, } = this.props let normalizedValue = value @@ -49,7 +51,7 @@ class SendRecipientListItem extends Component { if (isContactString) { normalizedValue = contacts[value] } - } else if (type === 'amount' && value) { + } else if (type === 'amount' && value && isNumber(value)) { const dynamicMax = calculateMaxValue(asset, index) normalizedValue = toBigNumber(value).gt(toBigNumber(dynamicMax)) ? dynamicMax @@ -92,6 +94,7 @@ class SendRecipientListItem extends Component { showConfirmSend, numberOfRecipients, intl, + isMigration, } = this.props const selectInput = showConfirmSend ? ( @@ -103,7 +106,6 @@ class SendRecipientListItem extends Component { onChange={value => this.handleFieldChange(value, 'asset')} items={this.createAssetList()} onFocus={this.clearErrorsOnFocus} - disabled /> ) @@ -131,9 +133,10 @@ class SendRecipientListItem extends Component { value={address || ''} name="address" onChange={value => this.handleFieldChange(value, 'address')} - items={this.createContactList()} + items={isMigration ? [] : this.createContactList()} onFocus={this.clearErrorsOnFocus} error={errors && errors.address} + disabled={isMigration} /> ) @@ -150,13 +153,19 @@ class SendRecipientListItem extends Component { return (
  • -
    {`${`0${index + 1}`.slice(-2)}`}
    + {!isMigration && ( +
    {`${`0${index + 1}`.slice( + -2, + )}`}
    + )}
    {selectInput}
    {numberInput}
    {addressInput}
    -
    - {numberOfRecipients > 1 && trashCanButton} -
    + {!isMigration && ( +
    + {numberOfRecipients > 1 && trashCanButton} +
    + )}
  • ) } diff --git a/app/components/Send/SendPanel/SendRecipientList/index.jsx b/app/components/Send/SendPanel/SendRecipientList/index.jsx index e28d519a7..a79639f57 100644 --- a/app/components/Send/SendPanel/SendRecipientList/index.jsx +++ b/app/components/Send/SendPanel/SendRecipientList/index.jsx @@ -18,6 +18,7 @@ type Props = { removeRow: (index: number) => any, updateRowField: (index: number, field: string, value: any) => any, calculateMaxValue: (asset: string, index: number) => string, + isMigration?: boolean, } const SendRecipientList = ({ @@ -29,6 +30,7 @@ const SendRecipientList = ({ clearErrors, showConfirmSend, calculateMaxValue, + isMigration, }: Props) => { const renderRows = () => sendRowDetails.map((row, index) => ( @@ -44,11 +46,17 @@ const SendRecipientList = ({ contacts={contacts} clearErrors={clearErrors} calculateMaxValue={calculateMaxValue} + isMigration={isMigration} /> )) return ( -
    +

    -
    + {!isMigration &&
    }
      {renderRows()}
    diff --git a/app/components/Send/SendPanel/index.jsx b/app/components/Send/SendPanel/index.jsx index 5cf5c014c..2358f0695 100644 --- a/app/components/Send/SendPanel/index.jsx +++ b/app/components/Send/SendPanel/index.jsx @@ -53,6 +53,7 @@ type Props = { toggleHasEnoughGas: () => void, hasEnoughGas: boolean, loading: boolean, + isMigration: boolean, } const shouldDisableSendButton = (sendRowDetails, loading) => @@ -95,10 +96,14 @@ const SendPanel = ({ toggleHasEnoughGas, hasEnoughGas, loading, + isMigration, }: Props) => { if (noSendableAssets) { return } + + // TODO: handle condition where is migration but not enough assets + const maxRecipientsMet = sendRowDetails.length === maxNumberOfRecipients let content = ( @@ -113,24 +118,43 @@ const SendPanel = ({ showConfirmSend={showConfirmSend} calculateMaxValue={calculateMaxValue} isWatchOnly={isWatchOnly} + isMigration={isMigration} /> - {chain === 'neo2' && ( -
    - -
    - )} - + {chain === 'neo2' && + !isMigration && ( +
    + +
    + )} {chain === 'neo3' && (
    {' '}
    )} - {isWatchOnly ? ( + {/* eslint-disable-next-line */} + {isMigration ? ( +
    + +
    + ) : isWatchOnly ? ( + + ) : ( + } + text="Ledger support for N3 coming soon." /> - - + )}
    ) } diff --git a/app/containers/LoginLedgerNanoS/index.js b/app/containers/LoginLedgerNanoS/index.js index 5ec4c7c83..9551ed82e 100644 --- a/app/containers/LoginLedgerNanoS/index.js +++ b/app/containers/LoginLedgerNanoS/index.js @@ -12,6 +12,7 @@ import { import LoginLedgerNanoS from './LoginLedgerNanoS' import ledgerActions from '../../actions/ledgerActions' import { ledgerLoginActions } from '../../actions/authActions' +import withChainData from '../../hocs/withChainData' const mapLedgerActionsToProps = () => ({ connect: () => ledgerActions.call(), @@ -31,6 +32,7 @@ const mapLedgerErrorToProps = error => ({ error }) export default compose( withCall(ledgerActions), + withChainData(), withActions(ledgerActions, mapLedgerActionsToProps), withActions(ledgerLoginActions, mapAccountActionsToProps), withProgress(ledgerActions, { diff --git a/app/containers/LoginLocalStorage/LoginLocalStorage.jsx b/app/containers/LoginLocalStorage/LoginLocalStorage.jsx index 71a55c7e1..702bc2594 100644 --- a/app/containers/LoginLocalStorage/LoginLocalStorage.jsx +++ b/app/containers/LoginLocalStorage/LoginLocalStorage.jsx @@ -9,6 +9,7 @@ import StyledReactSelect from '../../components/Inputs/StyledReactSelect/StyledR import LoginIcon from '../../assets/icons/login.svg' import styles from '../Home/Home.scss' +import { resetCachedNode } from '../../actions/nodeStorageActions' type Props = { loading: boolean, @@ -16,6 +17,9 @@ type Props = { accounts: Object, n3Accounts: Object, chain: string, + newMigratedWalletName: string, + newWalletCreated: Function, + setChain: string => any, } type State = { @@ -47,6 +51,49 @@ export default class LoginLocalStorage extends Component { }) } + // NOTE: this is brutal but literally the only way + // I could seem to get all of these state updates + // to render as expected... No idea why. + handleNewMigrationWalletLogic = () => { + this.props.setChain('neo3') + setTimeout(() => { + resetCachedNode() + const selectedAccount = this.returnMappedAccounts().find( + account => account.label === this.props.newMigratedWalletName, + ) + this.setState({ selectedAccount }) + setTimeout(() => { + this.focusPasswordInput() + }, 100) + }, 100) + } + + focusPasswordInput = () => { + const input = document.querySelector('input[type="password"]') + if (input) { + input.focus() + } + } + + componentDidUpdate(prevProps: Props) { + if ( + this.props.newMigratedWalletName && + prevProps.newMigratedWalletName !== this.props.newMigratedWalletName + ) { + this.handleNewMigrationWalletLogic() + } + } + + componentDidMount() { + if (this.props.newMigratedWalletName) { + this.handleNewMigrationWalletLogic() + } + } + + componentWillUnmount() { + if (this.props.newMigratedWalletName) this.props.newWalletCreated('') + } + render() { const { loading } = this.props const { passphrase, selectedAccount } = this.state diff --git a/app/containers/LoginLocalStorage/index.js b/app/containers/LoginLocalStorage/index.js index 47d555a50..653b63c69 100644 --- a/app/containers/LoginLocalStorage/index.js +++ b/app/containers/LoginLocalStorage/index.js @@ -10,6 +10,12 @@ import withLoadingProp from '../../hocs/withLoadingProp' import withFailureNotification from '../../hocs/withFailureNotification' import pureStrategy from '../../hocs/helpers/pureStrategy' import withChainData from '../../hocs/withChainData' +import newMigrationWalletActions from '../../actions/newMigrationWalletActions' +import { updateSettingsActions } from '../../actions/settingsActions' + +type NewWalletProps = { + newWalletCreated: Function, +} const mapAccountsDataToProps = accounts => ({ accounts, @@ -24,11 +30,29 @@ const mapActionsToProps = actions => ({ actions.call({ passphrase, encryptedWIF, chain }), }) +const mapNewWalletDataToProps = (name: string) => ({ + newMigratedWalletName: name, +}) + +const mapNewWalletActionsToProps = (actions): NewWalletProps => ({ + newWalletCreated: name => actions.call({ name }), +}) + +const mapSettingsActionsToProps = actions => ({ + setChain: chain => + actions.call({ + chain, + }), +}) + export default compose( withChainData(), withData(accountsActions, mapAccountsDataToProps), withData(n3AccountsActions, mapN3AccountsDataToProps), withActions(nep2LoginActions, mapActionsToProps), withLoadingProp(nep2LoginActions, { strategy: pureStrategy }), + withData(newMigrationWalletActions, mapNewWalletDataToProps), + withActions(newMigrationWalletActions, mapNewWalletActionsToProps), + withActions(updateSettingsActions, mapSettingsActionsToProps), withFailureNotification(nep2LoginActions), )(LoginLocalStorage) diff --git a/app/containers/Migration/Migration.jsx b/app/containers/Migration/Migration.jsx new file mode 100644 index 000000000..1235c06fb --- /dev/null +++ b/app/containers/Migration/Migration.jsx @@ -0,0 +1,334 @@ +// @flow +import React from 'react' +import axios from 'axios' +import classNames from 'classnames' +import Confetti from 'react-dom-confetti' +import { wallet as n3Wallet } from '@cityofzion/neon-js-next' + +import Button from '../../components/Button' +import HeaderBar from '../../components/HeaderBar' +import CreateMigrationWallet from '../../components/Migration/CreateMigrationWallet' +import TokenSwap from '../../components/Migration/TokenSwap' +import History from '../../components/Migration/History' +import Explanation from '../../components/Migration/Explanation' +import Panel from '../../components/Panel' +import Loader from '../../components/Loader' +import RefreshIcon from '../../assets/icons/refresh.svg' + +import styles from './Migration.scss' +import { MODAL_TYPES } from '../../core/constants' +// import Details from '../../components/Modals/MigrationDetails' + +const CREATE_WALLET_STEP = 'CREATE_WALLET_STEP' +const SELECT_TOKEN_STEP = 'SELECT_TOKEN_STEP' +const MIGRATION_HISTORY_STEP = 'MIGRATION_HISTORY_STEP' + +type Props = { + address: string, + showModal: (modalType: string, modalProps: Object) => any, + wif: string, + hideModal: () => any, + logout: () => any, + newWalletCreated: (name: string) => any, +} + +type State = { + step: string, + loading: boolean, + migrationData: { + transactions: [], + pageCount: 0, + }, + paginationData: { + page: string, + size: string, + }, + hasCreatedN3Wallet: boolean, + isExploding: boolean, + createdWalletName: string, +} + +export default class Migration extends React.Component { + state = { + step: '', + loading: false, + migrationData: { + transactions: [], + pageCount: 0, + }, + paginationData: { + page: '1', + size: '10', + }, + hasCreatedN3Wallet: false, + isExploding: false, + createdWalletName: '', + } + + async componentDidMount() { + this.setState({ loading: true }) + const { address } = this.props + const HAS_CREATED_WALLET = await localStorage.getItem( + `hasMigrated-${address}`, + ) + + const migrationData = await this.fetchHistoryData() + const HAS_MIGRATED = !!migrationData.data.items.length + + if (HAS_MIGRATED) { + return this.setState({ + step: MIGRATION_HISTORY_STEP, + loading: false, + migrationData: { + transactions: migrationData.data.items, + pageCount: migrationData.data.pageCount, + }, + hasCreatedN3Wallet: !!HAS_CREATED_WALLET, + createdWalletName: HAS_CREATED_WALLET || '', + }) + } + if (HAS_CREATED_WALLET) { + return this.setState({ + step: SELECT_TOKEN_STEP, + loading: false, + hasCreatedN3Wallet: !!HAS_CREATED_WALLET, + createdWalletName: HAS_CREATED_WALLET, + }) + } + + return this.setState({ step: CREATE_WALLET_STEP, loading: false }) + } + + showTxHistoryModal = (tx: Object) => { + this.props.showModal(MODAL_TYPES.MIGRATION_DETAILS, { tx }) + } + + handleRefreshHistory = async () => { + this.setState({ loading: true }) + const paginationData = { + page: '1', + size: '10', + } + this.setState({ paginationData }, async () => { + const migrationData = await this.fetchHistoryData() + const HAS_MIGRATED = migrationData.data.items.length + + this.setState({ + loading: false, + }) + if (HAS_MIGRATED) { + return this.setState({ + step: MIGRATION_HISTORY_STEP, + migrationData: { + // $FlowFixMe + transactions: migrationData.data.items, + pageCount: migrationData.data.pageCount, + }, + }) + } + }) + } + + fetchHistoryData = async () => { + this.setState({ loading: true }) + const { address } = this.props + const { paginationData } = this.state + const { page, size } = paginationData + + // $FlowFixMe + const migrationResults = await axios.get( + `https://migration.ngd.network/transactions?addresses=${address}&assetHashes=c56f33fc6ecfcd0c225c4ab356fee59390af8560be0e930faebe74a6daff7c9b,f46719e2d16bf50cddcef9d4bbfece901f73cbb6,602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7,74f2dc36a68fdc4682034178eb2220729231db76&page=${page}&pageSize=${size}`, + ) + this.setState({ loading: false }) + return migrationResults + } + + handlePagination = async () => { + const { page, size } = this.state.paginationData + const { pageCount, transactions } = this.state.migrationData + + if (Number(page) === Number(pageCount)) { + // eslint-disable-next-line + return console.log('All pages have been returned.') + } + + const nextPaginationData = { + page: String(Number(page) + 1), + size, + } + this.setState({ paginationData: nextPaginationData }, async () => { + const migrationData = await this.fetchHistoryData() + this.setState({ + migrationData: { + transactions: [...transactions, ...migrationData.data.items], + pageCount: migrationData.data.pageCount, + }, + }) + }) + } + + handleMigrationSuccess = () => { + this.setState({ isExploding: true }) + + setTimeout(() => { + this.setState({ isExploding: false, step: MIGRATION_HISTORY_STEP }) + }, 5000) + } + + render() { + const { step, migrationData, loading, hasCreatedN3Wallet } = this.state + + const TO_ACCOUNT = new n3Wallet.Account(this.props.wif) + + const config = { + angle: 90, + spread: 360, + startVelocity: 40, + elementCount: 70, + dragFriction: 0.12, + duration: 5000, + stagger: 3, + width: '10px', + height: '10px', + perspective: '1000px', + colors: ['#a864fd', '#29cdff', '#78ff44', '#ff718d', '#fdff6a'], + } + + return ( + + {/* {true &&
    } */} + +
    + + + ( +
    +

    Token Migration

    + +
    + {step === MIGRATION_HISTORY_STEP && ( + + )} +
    +
    + )} + contentClassName={styles.migrationPanelContent} + className={styles.migrationPanel} + > + !loading && this.setState({ step })} + /> + + + + {this.state.loading ? ( +
    + +
    + ) : ( + + {step === CREATE_WALLET_STEP && ( + { + this.setState({ + step: SELECT_TOKEN_STEP, + hasCreatedN3Wallet: true, + createdWalletName: name, + }) + if (showModal) { + this.props.showModal(MODAL_TYPES.CONFIRM, { + title: 'Confirm Migration', + shouldRenderHeader: false, + shouldRenderFooter: false, + height: '450px', + width: '600px', + renderBody: () => ( +
    +

    Congratulations!

    +

    You have created your new Neo N3 wallet.

    +
    + Your address is:
    + {TO_ACCOUNT.address} +
    +
    +
    Your wallet name is: {name}
    +
    +
    +
    + + + This will log you out of your Neo Legacy + wallet and take you back to the Neon login + screen. You can log back into your Neo + Legacy account and continue with migration + at any time.{' '} + +
    + +
    +
    + ), + }) + } + }} + /> + )} + + {step === SELECT_TOKEN_STEP && ( +
    + +
    + )} + + {step === MIGRATION_HISTORY_STEP && ( + + )} +
    + )} +
    +
    + + ) + } +} diff --git a/app/containers/Migration/Migration.scss b/app/containers/Migration/Migration.scss new file mode 100644 index 000000000..4884d561d --- /dev/null +++ b/app/containers/Migration/Migration.scss @@ -0,0 +1,146 @@ +@import '../../styles/animations'; + +.migrationPanelContent { + padding: 0 !important; + display: flex; + height: calc(100vh - 100px) !important; + overflow: hidden !important; + // max-height: calc(100vh - 120px) !important; +} + +.section { + height: 100%; + max-height: calc(100vh - 120px) !important; + // display: flex; + // flex-direction: column; + // flex: 1; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.migrationPanel { + height: 100%; + max-height: calc(100vh - 100px) !important; +} + +.explanationContainer { + max-width: 305px; + height: 100%; + max-height: calc(100vh - 120px) !important; +} + +.buttonsContainer { + display: flex; + width: 100%; + + .innerButtonsContainer { + width: 100%; + display: flex; + justify-content: flex-end; + padding: 12px; + + background: var(--input-background); + button { + max-width: 215px; + } + } + + // .explanationPlaceholder { + // width: 305px; + // min-width: 305px; + // max-width: 305px; + // padding: 24px; + // background: var(--panel-receive-explanation); + // } +} + +.tokenSwapContainer { + padding: 24px; + overflow: hidden !important; +} + +.loadingContainer { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + margin: auto; +} + +.loader { + position: relative; + left: unset; + top: unset; +} + +.refreshButtonSpan { + margin-left: 6px; +} + +.refresh { + cursor: pointer; +} + +.refreshButton { + display: flex; + align-items: center; + cursor: pointer; + margin-left: 24px; + + svg { + path { + fill: var(--header-bar-default-icon-color); + } + } +} + +.loading { + animation: spin 2s linear infinite; +} + +.confirmWalletCreation { + // min-height: 400px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + + h2 { + margin-bottom: 0px; + } + + code { + font-size: 14px; + } + + small { + margin-bottom: 24px; + } + + .fee { + font-size: 13px; + } +} + +.modalFooter { + display: flex; + justify-content: space-around; + width: 100%; + margin-top: 12px; + + button, + div { + max-width: 225px; + margin-bottom: 12px; + } + + small { + font-size: 10px; + } +} diff --git a/app/containers/Migration/index.js b/app/containers/Migration/index.js new file mode 100644 index 000000000..0198cc3d6 --- /dev/null +++ b/app/containers/Migration/index.js @@ -0,0 +1,48 @@ +// @flow +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { bindActionCreators } from 'redux' +import { withActions, withData } from 'spunky' + +import withAuthData from '../../hocs/withAuthData' +import { showModal, hideModal } from '../../modules/modal' +import { logoutActions } from '../../actions/authActions' + +import Migration from './Migration' +import newMigrationWalletActions from '../../actions/newMigrationWalletActions' + +type Props = { + logout: Function, +} + +type NewWalletProps = { + newWalletCreated: Function, +} + +const mapLogoutActionsToProps = (actions): Props => ({ + logout: () => actions.call(), +}) + +const mapNewWalletActionsToProps = (actions): NewWalletProps => ({ + newWalletCreated: name => actions.call({ name }), +}) + +const mapDispatchToProps = (dispatch: Function) => + bindActionCreators( + { + showModal, + hideModal, + }, + dispatch, + ) + +export default compose( + connect( + null, + mapDispatchToProps, + ), + withActions(newMigrationWalletActions, mapNewWalletActionsToProps), + withActions(logoutActions, mapLogoutActionsToProps), + withAuthData(), + withData(newMigrationWalletActions), +)(Migration) diff --git a/app/containers/ModalRenderer/ModalRenderer.jsx b/app/containers/ModalRenderer/ModalRenderer.jsx index 1fd6c2e2b..2c4090bce 100644 --- a/app/containers/ModalRenderer/ModalRenderer.jsx +++ b/app/containers/ModalRenderer/ModalRenderer.jsx @@ -13,6 +13,7 @@ import ImportTransactionModal from '../../components/Modals/ImportTransactionMod import { MODAL_TYPES } from '../../core/constants' import ReleaseNotesModal from '../../components/Modals/ReleaseNotesModal' import ShowQrForExportModal from '../../components/Modals/ShowQrForExportModal' +import MigrationDetails from '../../components/Modals/MigrationDetails' const { CONFIRM, @@ -25,6 +26,7 @@ const { IMPORT_TRANSACTION, RELEASE_NOTES, SHOW_QR_FOR_EXPORT, + MIGRATION_DETAILS, } = MODAL_TYPES const MODAL_COMPONENTS = { @@ -38,6 +40,7 @@ const MODAL_COMPONENTS = { [IMPORT_TRANSACTION]: ImportTransactionModal, [RELEASE_NOTES]: ReleaseNotesModal, [SHOW_QR_FOR_EXPORT]: ShowQrForExportModal, + [MIGRATION_DETAILS]: MigrationDetails, } type Props = { diff --git a/app/containers/Send/Send.jsx b/app/containers/Send/Send.jsx index 568631e87..91e2190a9 100644 --- a/app/containers/Send/Send.jsx +++ b/app/containers/Send/Send.jsx @@ -13,12 +13,13 @@ import { addNumber, } from '../../core/math' import { isBlacklisted } from '../../core/wallet' -import { PRICE_UNAVAILABLE } from '../../core/constants' +import { MODAL_TYPES, PRICE_UNAVAILABLE } from '../../core/constants' import AmountsPanel from '../../components/AmountsPanel' import SendPanel from '../../components/Send/SendPanel' import HeaderBar from '../../components/HeaderBar' - +import WarningIcon from '../../assets/icons/warning.svg' import styles from './Send.scss' +import DialogueBox from '../../components/DialogueBox' const MAX_NUMBER_OF_RECIPIENTS = 25 @@ -29,6 +30,9 @@ type Props = { sendEntries: Array, fees: number, }) => Object, + performMigration: ({ + sendEntries: Array, + }) => Object, calculateN3Fees: ({ sendEntries: Array, }) => Object, @@ -45,6 +49,10 @@ type Props = { showImportModal: (props: Object) => void, intl: IntlShape, chain: string, + isMigration: boolean, + wif: string, + handleSwapComplete: () => void, + showModal: (modalType: string, modalProps: Object) => any, } type State = { @@ -91,7 +99,7 @@ export default class Send extends React.Component { tokens: [], } - componentDidMount() { + async componentDidMount() { this.setState((prevState: Object) => { const newState = [...prevState.sendRowDetails] @@ -105,6 +113,11 @@ export default class Send extends React.Component { this.updateRowField(0, 'address', address) } } + + if (this.props.isMigration) { + const account = new n3Wallet.Account(this.props.wif) + this.updateRowField(0, 'address', account.address) + } } pushQRCodeData = (data: Object) => { @@ -287,6 +300,11 @@ export default class Send extends React.Component { MIN_EXPECTED_GAS_FEE * rowsWithAsset.length, ) + if (totalSendableAssets < 0) { + // this.props.showErrorNotification({ message: 'oops' }) + return toNumber(sendableAssets[asset].balance).toFixed(decimals) + } + if (rowsWithAsset.length === 1 || rowsWithAsset.length === 0) { return toNumber(totalSendableAssets).toFixed(decimals) } @@ -364,6 +382,10 @@ export default class Send extends React.Component { Promise.all(promises).then(values => { const isValid = values.every((result: boolean) => result) + if (isValid && this.props.isMigration) { + return this.handleMigration() + } + if (isValid && !this.props.isWatchOnly && !generateTransaction) { this.setState({ showConfirmSend: true }) } @@ -374,6 +396,87 @@ export default class Send extends React.Component { } } + handleMigration = () => { + this.setState({ loading: true }) + + const BLACK_HOLE_MAIN_NET = 'ANeo2toNeo3MigrationAddressxwPB2Hz' // MainNet + const BLACK_HOLE_TEST_NET = 'AJ36ZCpMhiHYMdMAUaP7i1i9pJz4jMdiQV' // TestNet + + const { sendRowDetails } = this.state + + const sendEntries = sendRowDetails.map((row: Object) => ({ + address: + this.props.networkId === '1' + ? BLACK_HOLE_MAIN_NET + : BLACK_HOLE_TEST_NET, + amount: toNumber(row.amount.toString()), + symbol: row.asset, + })) + + const TO_ACCOUNT = new n3Wallet.Account(this.props.wif) + + const feeIsRequired = (symbol, amount) => { + const userMustPayFee = + (symbol === 'NEO' && Number(amount) < 10) || + (symbol === 'GAS' && Number(amount) < 20) + + return userMustPayFee + } + + this.props.showModal(MODAL_TYPES.CONFIRM, { + title: 'Confirm Migration', + shouldRenderHeader: false, + height: '524px', + renderBody: () => ( +
    +

    Confirmation

    +

    + You are about to migrate{' '} + {toBigNumber(sendEntries[0].amount).toString()}{' '} + {sendEntries[0].symbol} +

    +
    + From (Neo Legacy):
    + {this.props.address} +
    +
    + +
    + To (Neo N3):
    + {TO_ACCOUNT.address} +
    +
    + {feeIsRequired(sendEntries[0].symbol, sendEntries[0].amount) && ( +
    + } text="1 GAS Fee" /> +
    + )} +
    + + Most users should recieve their tokens on Neo N3 within 30 minutes, + however some migrations may take up to 24 hours.{' '} + +
    + ), + onClick: () => { + this.props + .performMigration({ + sendEntries, + }) + .then(() => { + this.props.handleSwapComplete() + }) + .catch(() => { + this.setState({ loading: false }) + // TODO: implement possible additional error state here + }) + }, + onCancel: () => { + this.setState({ loading: false }) + }, + }) + } + validateRowAmounts = (rows: Array) => { const { sendableAssets, intl } = this.props @@ -388,7 +491,7 @@ export default class Send extends React.Component { ) if ( toBigNumber(accum[currRow.asset]).greaterThan( - toBigNumber(sendableAssets[currRow.asset].balance), + toBigNumber(Number(sendableAssets[currRow.asset].balance)), ) ) { const { errors } = this.state.sendRowDetails[index] @@ -547,10 +650,10 @@ export default class Send extends React.Component { } validateAddress = async (formAddress: string, index: number) => { - const { intl, chain } = this.props + const { intl, chain, isMigration } = this.props const { errors } = this.state.sendRowDetails[index] - if (chain === 'neo3') { + if (chain === 'neo3' || isMigration) { if (!n3Wallet.isAddress(formAddress)) { errors.address = intl.formatMessage({ id: 'errors.send.invalidAddress', @@ -625,28 +728,49 @@ export default class Send extends React.Component { isWatchOnly, showImportModal, chain, + isMigration, } = this.props const noSendableAssets = Object.keys(sendableAssets).length === 0 + const assets = isMigration ? {} : sendableAssets + + if (isMigration) { + if (sendableAssets.NEO) { + assets.NEO = sendableAssets.NEO + } + if (sendableAssets.GAS) { + assets.GAS = sendableAssets.GAS + } + if (sendableAssets.CGAS) { + assets.CGAS = sendableAssets.CGAS + } + if (sendableAssets.nNEO) { + assets.nNEO = sendableAssets.nNEO + } + } + return (
    - {shouldRenderHeaderBar && ( - } - shouldRenderRefresh - /> - )} - {!noSendableAssets && ( - - )} + {shouldRenderHeaderBar && + !isMigration && ( + } + shouldRenderRefresh + /> + )} + {!noSendableAssets && + !isMigration && ( + + )} + { hasEnoughGas={hasEnoughGas} loading={loading} toggleHasEnoughGas={this.toggleHasEnoughGas} + isMigration={isMigration} />
    ) diff --git a/app/containers/Send/Send.scss b/app/containers/Send/Send.scss index 0c12084cf..a1eb98c07 100644 --- a/app/containers/Send/Send.scss +++ b/app/containers/Send/Send.scss @@ -6,3 +6,33 @@ height: 100%; } } + +.confirmMigration { + // min-height: 400px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + + h2 { + margin-bottom: 0px; + } + + code { + font-size: 14px; + } + + small { + margin-bottom: 24px; + } + + .fee { + font-size: 13px; + color: #d355e7; + } + + .feeWarningContainer { + margin: -36px 0; + width: 100%; + } +} diff --git a/app/containers/Send/index.js b/app/containers/Send/index.js index 23f3339e1..fb2067a96 100644 --- a/app/containers/Send/index.js +++ b/app/containers/Send/index.js @@ -8,6 +8,7 @@ import { injectIntl } from 'react-intl' import Send from './Send' import { sendTransaction, calculateN3Fees } from '../../modules/transactions' +import { performMigration } from '../../modules/migration' import { showModal } from '../../modules/modal' import { getNotifications } from '../../modules/notifications' import withPricesData from '../../hocs/withPricesData' @@ -29,6 +30,8 @@ const mapDispatchToProps = (dispatch: Function) => { sendTransaction, calculateN3Fees, + performMigration, + showModal, showSendModal: props => dispatch(showModal(MODAL_TYPES.SEND, props)), showGeneratedTransactionModal: props => dispatch(showModal(MODAL_TYPES.GENERATED_TRANSACTION, props)), diff --git a/app/core/constants.js b/app/core/constants.js index 831c9619b..6fc5b5ff5 100644 --- a/app/core/constants.js +++ b/app/core/constants.js @@ -151,6 +151,7 @@ export const ROUTES = { OFFLINE_SIGNING_PROMPT: '/offline-signing-prompt', NETWORK_CONFIGURATION: '/network-configuration', MOBILE: '/mobile', + MIGRATION: '/migration', } export const NOTIFICATION_LEVELS = { @@ -183,6 +184,7 @@ export const MODAL_TYPES = { IMPORT_TRANSACTION: 'IMPORT_TRANSACTION', RELEASE_NOTES: 'RELEASE_NOTES', SHOW_QR_FOR_EXPORT: 'SHOW_QR_FOR_EXPORT', + MIGRATION_DETAILS: 'MIGRATION_DETAILS', } export const TX_TYPES = { diff --git a/app/core/explorer.js b/app/core/explorer.js index 8e874940e..b21807100 100644 --- a/app/core/explorer.js +++ b/app/core/explorer.js @@ -53,7 +53,11 @@ export const getExplorerTxLink = ( case DORA: return `${baseURL}/transaction/${chain}/${ // eslint-disable-next-line - networkId == '1' ? 'mainnet' : 'testnet_rc4' + networkId == '1' + ? 'mainnet' + : chain === 'neo3' + ? 'testnet_rc4' + : 'testnet' }/0x${txId}` default: throw new Error(`Unknown explorer ${explorer}`) @@ -78,7 +82,14 @@ export const getExplorerAddressLink = ( case NEOTUBE: return `${baseURL}/address/${address}` case DORA: - return `${baseURL}/address/${chain}/mainnet/${address}` + return `${baseURL}/address/${chain}/${ + // eslint-disable-next-line + networkId == '1' + ? 'mainnet' + : chain === 'neo3' + ? 'testnet_rc4' + : 'testnet' + }/${address}` default: throw new Error(`Unknown explorer ${explorer}`) } diff --git a/app/core/nodes-main-net.json b/app/core/nodes-main-net.json index 51563b47e..7a1355825 100644 --- a/app/core/nodes-main-net.json +++ b/app/core/nodes-main-net.json @@ -1,10 +1,4 @@ [ - { - "url": "https://seed1.switcheo.network:10331" - }, - { - "url": "https://seed3.switcheo.network:10331" - }, { "url": "http://seed1.ngd.network:10332" }, @@ -32,18 +26,6 @@ { "url": "http://seed9.ngd.network:10332" }, - { - "url": "https://m2.neo.nash.io" - }, - { - "url": "https://m3.neo.nash.io" - }, - { - "url": "https://m4.neo.nash.io" - }, - { - "url": "https://m5.neo.nash.io" - }, { "url": "https://mainnet1.neo2.coz.io:443" }, diff --git a/app/modules/generateWallet.js b/app/modules/generateWallet.js index 9b82f9afb..1250ce83a 100644 --- a/app/modules/generateWallet.js +++ b/app/modules/generateWallet.js @@ -255,6 +255,7 @@ export const generateN3NewWalletAccount = ( walletName: string, authenticated: boolean = false, onFailure: () => any = () => undefined, + isMigration?: { legacyAddress: string, walletCreatedCallback: () => void }, ) => (dispatch: DispatchType) => { const dispatchError = (message: string) => { dispatch(showErrorNotification({ message })) @@ -307,7 +308,7 @@ export const generateN3NewWalletAccount = ( if (walletHasKey(storedWallet, encryptedWIF)) { onFailure() return dispatchError( - 'A wallet with this encrypted key already exists locally.', + 'A wallet with this encrypted key already exists locally, try using a different password.', ) } @@ -321,6 +322,7 @@ export const generateN3NewWalletAccount = ( ) dispatch(hideNotification(infoNotificationId)) + dispatch( newWalletAccount({ account: { @@ -333,6 +335,13 @@ export const generateN3NewWalletAccount = ( isImport, }), ) + if (isMigration) { + localStorage.setItem( + `hasMigrated-${isMigration.legacyAddress}`, + walletName, + ) + return isMigration.walletCreatedCallback() + } if (wif) history.push(ROUTES.HOME) if (authenticated) @@ -367,6 +376,7 @@ export const generateNewWalletAccount = ( authenticated: boolean = false, onFailure: () => any = () => undefined, chain: string = 'neo2', + isMigration?: { legacyAddress: string, walletCreatedCallback: () => void }, ) => (dispatch: DispatchType) => { const dispatchError = (message: string) => { dispatch(showErrorNotification({ message })) @@ -392,6 +402,7 @@ export const generateNewWalletAccount = ( walletName, authenticated, onFailure, + isMigration, ), ) } diff --git a/app/modules/migration.js b/app/modules/migration.js new file mode 100644 index 000000000..56cec8ad4 --- /dev/null +++ b/app/modules/migration.js @@ -0,0 +1,252 @@ +// @flow +import axios from 'axios' +import { keyBy } from 'lodash-es' +// import { wallet } from '@cityofzion/neon-js' + +import { getNode, getRPCEndpoint } from '../actions/nodeStorageActions' +import { addPendingTransaction } from '../actions/pendingTransactionActions' +import { getAssetBalances, getTokenBalances, getWIF } from '../core/deprecated' +import { + showErrorNotification, + showInfoNotification, + showSuccessNotification, +} from './notifications' +import { getTokenBalancesMap } from '../core/wallet' +import { toBigNumber } from '../core/math' +import { buildTransferScript } from './transactions' + +const N2 = require('@cityofzion/neon-js-legacy-latest') +const N3 = require('@cityofzion/neon-js-next') +const { wallet } = require('@cityofzion/neon-js-legacy-latest') + +const populateTestNetBalances = async (address: string) => { + const net = 'TestNet' + + const testnetBalances = await axios.get( + `https://dora.coz.io/api/v1/neo2/testnet/get_balance/${address}`, + ) + const parsedTestNetBalances = {} + + testnetBalances.data.balance.forEach(token => { + parsedTestNetBalances[token.asset_symbol || token.symbol] = { + name: token.asset_symbol || token.symbol, + balance: token.amount, + unspent: token.unspent, + } + }) + + const Balance = new wallet.Balance({ + address, + net, + }) + + Object.values(parsedTestNetBalances).forEach( + // $FlowFixMe + ({ name, balance, unspent }) => { + if (name === 'GAS' || name === 'NEO') { + Balance.addAsset(name, { balance, unspent }) + } else { + Balance.addToken(name, balance) + } + }, + ) + + return Balance +} + +export const performMigration = ({ + sendEntries, +}: { + sendEntries: Array, +}) => (dispatch: DispatchType, getState: GetStateType): Promise<*> => + // TODO: will need to be dynamic based on network + // eslint-disable-next-line + // const provider = new N2.api.neoCli.instance('https://testnet1.neo2.coz.io') + + new Promise(async (resolve, reject) => { + try { + const state = getState() + const wif = getWIF(state) + const tokenBalances = getTokenBalances(state) + const balances = { + ...getAssetBalances(state), + ...getTokenBalancesMap(tokenBalances), + } + const tokensBalanceMap = keyBy(tokenBalances, 'symbol') + const TO_ACCOUNT = new N3.wallet.Account(wif) + const FROM_ACCOUNT = new N2.wallet.Account(wif) + const entry = sendEntries[0] + + // eslint-disable-next-line + const net = state.spunky.network.data == 1 ? 'MainNet' : 'TestNet' + let endpoint = await getNode(net) + if (!endpoint) { + endpoint = await getRPCEndpoint(net) + } + // eslint-disable-next-line + const provider = new N2.api.neoCli.instance(endpoint) + const { symbol, amount, address } = entry + let intent + let script = '' + + if (symbol === 'GAS' || symbol === 'NEO') { + intent = N2.api.makeIntent({ [symbol]: Number(amount) }, address) + } else { + script = buildTransferScript( + net, + sendEntries, + FROM_ACCOUNT.address, + // $FlowFixMe + tokensBalanceMap, + ) + } + + if (symbol === 'nNEO' && Number(amount) < 1) { + return dispatch( + showErrorNotification({ + message: 'Oops... you cannot migrate less than 1 nNEO.', + }), + ) + } + + const hexRemark = N2.u.str2hexstring(TO_ACCOUNT.address) + const hexTagRemark = N2.u.str2hexstring('Neon Desktop Migration') + + const hasBalanceForRequiredFee = (MIN_FEE = 1) => { + if ( + !balances.GAS || + (balances.GAS && toBigNumber(balances.GAS).lt(MIN_FEE)) + ) { + return false + } + return true + } + + const feeIsRequired = () => { + const userMustPayFee = + (symbol === 'NEO' && Number(amount) < 10) || + (symbol === 'GAS' && Number(amount) < 20) || + (symbol === 'CGAS' && Number(amount) < 20) || + (symbol === 'nNEO' && Number(amount) < 10) + + return userMustPayFee + } + + if (!hasBalanceForRequiredFee() && feeIsRequired()) { + const generateMinRequirementString = () => { + const requirementMap = { + GAS: ' OR migrate at least 20 GAS.', + NEO: ' OR migrate at least 10 NEO.', + nNEO: '.', + CGAS: '.', + OTHER: '.', + } + + if (requirementMap[symbol]) { + return requirementMap[symbol] + } + return requirementMap.OTHER + } + const message = `Account does not have enough to cover the 1 GAS fee... Please transfer at least 1 GAS to ${ + FROM_ACCOUNT.address + } to proceed${generateMinRequirementString()}` + const error = new Error(message) + dispatch( + showErrorNotification({ + message, + autoDismiss: 10000, + }), + ) + return reject(error) + } + + const CONFIG = { + api: provider, + account: FROM_ACCOUNT, + intents: intent, + fees: feeIsRequired() ? 1.0 : null, + script, + } + + dispatch( + showInfoNotification({ + message: 'Broadcasting transaction to network...', + autoDismiss: 0, + }), + ) + + let c = await N2.api.fillSigningFunction(CONFIG) + c = await N2.api.fillUrl(c) + // if (net !== 'TestNet') { + c = await N2.api.fillBalance(c) + + if (symbol === 'GAS' || symbol === 'NEO') { + const { balance } = c + if ( + balance.assets[symbol].unspent.length && + balance.assets[symbol].unspent.length > 15 + ) { + dispatch( + showErrorNotification({ + message: + 'This migration transaction has an excessive number of UTXO inputs and will require extra GAS in fees. Consider sending all your (GAS|NEO) to yourself first in order to consolidate your UTXOs into a single input before migrating, in order to avoid the extra fee.', + }), + ) + return reject() + } + } + + c = script + ? await N2.api.createInvocationTx(c) + : await N2.api.createContractTx(c) + + c.tx.attributes.push( + new N2.tx.TransactionAttribute({ + usage: N2.tx.TxAttrUsage.Remark14, + data: hexRemark, + }), + new N2.tx.TransactionAttribute({ + usage: N2.tx.TxAttrUsage.Remark15, + data: hexTagRemark, + }), + ) + c = await N2.api.signTx(c) + c = await N2.api.sendTx(c) + // eslint-disable-next-line + if (c.response.hasOwnProperty('txid')) { + // eslint-disable-next-line + console.log( + `Swap initiated to ${TO_ACCOUNT.address} in tx 0x${c.response.txid}`, + ) + + dispatch( + showSuccessNotification({ + message: + 'Transaction pending! Your balance will automatically update when the blockchain has processed it.', + }), + ) + + dispatch( + addPendingTransaction.call({ + address: c.account.address, + tx: { + hash: c.response.txid, + sendEntries, + }, + net, + }), + ) + + return resolve() + } + } catch (e) { + dispatch( + showErrorNotification({ + message: `Oops... Something went wrong please try again. ${ + e.message + }`, + }), + ) + return reject(e) + } + }) diff --git a/app/modules/notifications.js b/app/modules/notifications.js index 3c411704f..3f4ff830d 100644 --- a/app/modules/notifications.js +++ b/app/modules/notifications.js @@ -10,7 +10,6 @@ type NotificationArgsType = { position?: $Values, dismissible?: boolean, autoDismiss?: number, - autoDismiss?: number, stack?: boolean, children?: React$Node, } @@ -104,7 +103,7 @@ export const showErrorNotification = (args: NotificationArgsType) => ( title: DEFAULT_ERROR_TITLE, ...args, level: NOTIFICATION_LEVELS.ERROR, - autoDismiss: 5, + autoDismiss: args.autoDismiss ? args.autoDismiss : 5, }, dispatch, ) diff --git a/app/modules/transactions.js b/app/modules/transactions.js index 6003659a0..0a69e3a05 100644 --- a/app/modules/transactions.js +++ b/app/modules/transactions.js @@ -1,5 +1,4 @@ // @flow -/* eslint-disable camelcase */ import { api, sc, u, wallet, settings } from '@cityofzion/neon-js' import { api as n3Api, @@ -9,6 +8,7 @@ import { tx, } from '@cityofzion/neon-js-next' import { flatMap, keyBy, isEmpty, get } from 'lodash-es' +import axios from 'axios' import { showErrorNotification, @@ -34,6 +34,8 @@ import { toNumber } from '../core/math' import { getNode, getRPCEndpoint } from '../actions/nodeStorageActions' import { addPendingTransaction } from '../actions/pendingTransactionActions' +const N2 = require('@cityofzion/neon-js-legacy-latest') + const { reverseHex, ab2hexstring } = u const MAX_FREE_TX_SIZE = 1024 @@ -54,7 +56,7 @@ const extractTokens = (sendEntries: Array) => const extractAssets = (sendEntries: Array) => sendEntries.filter(({ symbol }) => !isToken(symbol)) -const buildIntents = (sendEntries: Array) => { +export const buildIntents = (sendEntries: Array) => { const assetEntries = extractAssets(sendEntries) // $FlowFixMe return flatMap(assetEntries, ({ address, amount, symbol }) => @@ -62,7 +64,7 @@ const buildIntents = (sendEntries: Array) => { ) } -const buildTransferScript = ( +export const buildTransferScript = ( net: NetworkType, sendEntries: Array, fromAddress: string, @@ -76,7 +78,7 @@ const buildTransferScript = ( tokenEntries.forEach(({ address, amount, symbol }) => { const toAcct = new wallet.Account(address) - const { scriptHash, decimals } = tokensBalanceMap[symbol] + const { scriptHash, decimals = 8 } = tokensBalanceMap[symbol] const args = [ u.reverseHex(fromAcct.scriptHash), u.reverseHex(toAcct.scriptHash), @@ -100,6 +102,12 @@ const makeRequest = ( config.intents = buildIntents(sendEntries) if (script === '') { + if (config.net === 'TestNet') { + // eslint-disable-next-line + const provider = new N2.api.neoCli.instance(config.url) + config.api = provider + return N2.api.sendAsset(config) + } return api.sendAsset(config, api.neoscan) } // eslint-disable-next-line no-param-reassign @@ -122,10 +130,15 @@ export const generateBalanceInfo = ( } // This adds some random bits to the transaction to prevent any hash collision. -const attachAttributesForEmptyTransaction = (config: api.apiConfig) => { +export const attachAttributesForEmptyTransaction = ( + config: api.apiConfig, + addressString?: string, +) => { config.tx.addAttribute( 32, - reverseHex(wallet.getScriptHashFromAddress(config.address)), + reverseHex( + wallet.getScriptHashFromAddress(addressString || config.address), + ), ) config.tx.addRemark( Date.now().toString() + ab2hexstring(wallet.generateRandomArray(4)), @@ -446,20 +459,53 @@ export const sendTransaction = ({ intents: undefined, script: undefined, gas: undefined, + account: new wallet.Account(wif), } - const balanceResults = await api - .getBalanceFrom({ net, address: fromAddress }, api.neoscan) - .catch(e => { - // indicates that neo scan is down and that api.sendAsset and api.doInvoke - // will fail unless balances are supplied - console.error(e) - config.balance = generateBalanceInfo( - tokensBalanceMap, - fromAddress, - net, - ) + + if (net === 'MainNet') { + const balanceResults = await api + .getBalanceFrom({ net, address: fromAddress }, api.neoscan) + .catch(e => { + // indicates that neo scan is down and that api.sendAsset and api.doInvoke + // will fail unless balances are supplied + console.error(e) + config.balance = generateBalanceInfo( + tokensBalanceMap, + fromAddress, + net, + ) + }) + config.balance = balanceResults.balance + } + if (net === 'TestNet') { + const testnetBalances = await axios.get( + `https://dora.coz.io/api/v1/neo2/testnet/get_balance/${fromAddress}`, + ) + const parsedTestNetBalances = {} + + testnetBalances.data.balance.forEach(token => { + parsedTestNetBalances[token.asset_symbol || token.symbol] = { + name: token.asset_symbol || token.symbol, + balance: token.amount, + unspent: token.unspent, + } }) - if (balanceResults) config.balance = balanceResults.balance + + const Balance = new wallet.Balance({ address: fromAddress, net }) + + Object.values(parsedTestNetBalances).forEach( + // $FlowFixMe + ({ name, balance, unspent }) => { + if (name === 'GAS' || name === 'NEO') { + Balance.addAsset(name, { balance, unspent }) + } else { + Balance.addToken(name, balance) + } + }, + ) + + config.balance = Balance + } try { const script = buildTransferScript( @@ -469,6 +515,7 @@ export const sendTransaction = ({ // $FlowFixMe config.tokensBalanceMap, ) + if (isWatchOnly) { config.intents = buildIntents(sendEntries) config.script = script diff --git a/package.json b/package.json index 2640a2f23..69cd102ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Neon", - "version": "2.7.4", + "version": "2.8.0", "main": "./main.js", "description": "Light wallet for NEO blockchain", "homepage": "https://github.com/CityOfZion/neon-wallet", @@ -85,6 +85,7 @@ }, "dependencies": { "@cityofzion/neon-js": "3.11.9", + "@cityofzion/neon-js-legacy-latest": "npm:@cityofzion/neon-js@4.9.0", "@cityofzion/neon-js-next": "npm:@cityofzion/neon-js@5.0.0-next.13", "@formatjs/intl-pluralrules": "^1.5.2", "@ledgerhq/hw-transport-node-hid": "5.0.0", @@ -127,6 +128,7 @@ "react-click-outside": "3.0.0", "react-data-grid": "2.0.73", "react-dom": "16.4.2", + "react-dom-confetti": "^0.2.0", "react-highlight-words": "0.12.0", "react-hot-loader": "4.6.3", "react-icons": "2.2.7", diff --git a/yarn.lock b/yarn.lock index 2fe1fa594..42c6dd663 100644 --- a/yarn.lock +++ b/yarn.lock @@ -165,6 +165,16 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@cityofzion/neon-api@^4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@cityofzion/neon-api/-/neon-api-4.9.0.tgz#ab11aef2c132baced5a764ac42573577938eaf9c" + integrity sha512-8eN3N3sGgd4L7qFaFATKr94kA/65626eo6hB7fHL+S8OGCVCrrl3tfh8GAOv50vLxd2YyoDu9pBY/0NPKY8tsQ== + dependencies: + "@types/node" "15.0.3" + axios "0.21.1" + isomorphic-ws "4.0.1" + ws "7.4.5" + "@cityofzion/neon-api@^5.0.0-next.13": version "5.0.0-next.13" resolved "https://registry.yarnpkg.com/@cityofzion/neon-api/-/neon-api-5.0.0-next.13.tgz#4740f7b60390b20d6a311b5db8755775ec806440" @@ -174,6 +184,28 @@ isomorphic-ws "4.0.1" ws "7.4.4" +"@cityofzion/neon-core@^4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@cityofzion/neon-core/-/neon-core-4.9.0.tgz#aed0c67997534a7ca1a4c4fbef43858551d0cbbb" + integrity sha512-z4UgEIjAS9E2QP6HSTzri02DFY++nYmfzbspi1818mvnTst6Lf8bDNYYxG/686wdYN2dEF3RuccMXraw2Bm31g== + dependencies: + "@types/bn.js" "5.1.0" + "@types/bs58" "4.0.1" + "@types/crypto-js" "4.0.1" + "@types/elliptic" "6.4.12" + axios "0.21.1" + bignumber.js "7.2.1" + bn.js "5.2.0" + bs58 "4.0.1" + bs58check "2.1.2" + crypto-js "4.0.0" + elliptic "6.5.4" + loglevel "1.7.1" + loglevel-plugin-prefix "0.8.4" + scrypt-js "3.0.1" + secure-random "1.1.2" + wif "2.0.6" + "@cityofzion/neon-core@^5.0.0-next.12": version "5.0.0-next.12" resolved "https://registry.yarnpkg.com/@cityofzion/neon-core/-/neon-core-5.0.0-next.12.tgz#17da929501285553f6be5192ececdbf59ed8ebaa" @@ -191,6 +223,15 @@ loglevel-plugin-prefix "0.8.4" scrypt-js "3.0.1" +"@cityofzion/neon-js-legacy-latest@npm:@cityofzion/neon-js@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@cityofzion/neon-js/-/neon-js-4.9.0.tgz#39f46720cff76f807128d5882832a3d722d9fd25" + integrity sha512-YYeMbQGZJkC8Wq2UQt98OUys8f8tPCaXMplbV8GwiJEdG+PJJOFGg3NkvrDGUfcuasff0dcn8LWjXTPKPp+Gyw== + dependencies: + "@cityofzion/neon-api" "^4.9.0" + "@cityofzion/neon-core" "^4.9.0" + "@cityofzion/neon-nep5" "^4.9.0" + "@cityofzion/neon-js-next@npm:@cityofzion/neon-js@5.0.0-next.13": version "5.0.0-next.13" resolved "https://registry.yarnpkg.com/@cityofzion/neon-js/-/neon-js-5.0.0-next.13.tgz#11612878df353a5b18cb16cf6c7f883bf28eca70" @@ -219,6 +260,13 @@ semver "5.6.0" wif "2.0.6" +"@cityofzion/neon-nep5@^4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@cityofzion/neon-nep5/-/neon-nep5-4.9.0.tgz#aa170bfa83c5ef75270d10951e02f87e7faf4f47" + integrity sha512-MSRXHl+UGYnh8f9FC7zZvMNRblnl496VxG3tjA+GIShtLR6u75/FK7syVXua9h8/khljL7qqSfnNqA+sy7q+eg== + dependencies: + "@cityofzion/neon-core" "^4.9.0" + "@concordance/react@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@concordance/react/-/react-1.0.0.tgz#fcf3cad020e5121bfd1c61d05bc3516aac25f734" @@ -417,11 +465,37 @@ dependencies: defer-to-connect "^1.0.1" +"@types/bn.js@*", "@types/bn.js@5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.1.0.tgz#32c5d271503a12653c62cf4d2b45e6eab8cebc68" + integrity sha512-QSSVYj7pYFN49kW77o2s9xTCwZ8F2xLbjLLSEVh8D2F4JUhZtPAGOFLTD+ffqksBx/u4cE/KImFjyhqCjn/LIA== + dependencies: + "@types/node" "*" + +"@types/bs58@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/bs58/-/bs58-4.0.1.tgz#3d51222aab067786d3bc3740a84a7f5a0effaa37" + integrity sha512-yfAgiWgVLjFCmRv8zAcOIHywYATEwiTVccTLnRp6UxTNavT55M9d/uhK3T03St/+8/z/wW+CRjGKUNmEqoHHCA== + dependencies: + base-x "^3.0.6" + +"@types/crypto-js@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.0.1.tgz#3a4bd24518b0e6c5940da4e2659eeb2ef0806963" + integrity sha512-6+OPzqhKX/cx5xh+yO8Cqg3u3alrkhoxhE5ZOdSEv0DOzJ13lwJ6laqGU0Kv6+XDMFmlnGId04LtY22PsFLQUw== + "@types/debug@^4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== +"@types/elliptic@6.4.12": + version "6.4.12" + resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.12.tgz#e8add831f9cc9a88d9d84b3733ff669b68eaa124" + integrity sha512-gP1KsqoouLJGH6IJa28x7PXb3cRqh83X8HCLezd2dF+XcAIMKYv53KV+9Zn6QA561E120uOqZBQ+Jy/cl+fviw== + dependencies: + "@types/bn.js" "*" + "@types/fs-extra@^9.0.1": version "9.0.2" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.2.tgz#e1e1b578c48e8d08ae7fc36e552b94c6f4621609" @@ -470,6 +544,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.8.tgz#fe2012f2355e4ce08bca44aeb3abbb21cf88d33f" integrity sha512-KPcKqKm5UKDkaYPTuXSx8wEP7vE9GnuaXIZKijwRYcePpZFDVuy2a57LarFKiORbHOuTOOwYzxVxcUzsh2P2Pw== +"@types/node@15.0.3": + version "15.0.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.0.3.tgz#ee09fcaac513576474c327da5818d421b98db88a" + integrity sha512-/WbxFeBU+0F79z9RdEOXH4CsDga+ibi5M8uEYr91u3CkT/pdWcV8MCook+4wDPnZBexRdwWS+PiVZ2xJviAzcQ== + "@types/node@^12.0.12": version "12.20.12" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.12.tgz#fd9c1c2cfab536a2383ed1ef70f94adea743a226" @@ -2389,7 +2468,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base-x@^3.0.2: +base-x@^3.0.2, base-x@^3.0.6: version "3.0.8" resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d" integrity sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA== @@ -4622,6 +4701,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-confetti@0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-confetti/-/dom-confetti-0.2.2.tgz#bdf2e7652d37b5cffb532c0a3263d108dd8a2363" + integrity sha512-+UVH9Y85qmpTnbmFURwLWjqLIykyIrsNSRkPX/eFlBuOURz9RDX8JoZHnajZHyFuCV0w/K3+tZK0ztfoTw6ejg== + dom-converter@^0.2: version "0.2.0" resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" @@ -11417,6 +11501,13 @@ react-data-grid@2.0.73: resolved "https://registry.yarnpkg.com/react-data-grid/-/react-data-grid-2.0.73.tgz#8c718b2cc5f3c2f7e34485d55b08e7bdcabfbb20" integrity sha1-jHGLLMXzwvfjRIXVWwjnvcq/uyA= +react-dom-confetti@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/react-dom-confetti/-/react-dom-confetti-0.2.0.tgz#76df26762da532057d5b1fbe38a8096f9dc33d40" + integrity sha512-+XRTi+WlCrcRN2dTjdEopOaPFtS7hpaHRRQ0sHiVRGqpchKz4QVh3i+6eLEEpNHYpN2VgPmhjvJ/vnjmUYhlIQ== + dependencies: + dom-confetti "0.2.2" + react-dom@16.4.2: version "16.4.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.2.tgz#4afed569689f2c561d2b8da0b819669c38a0bda4" @@ -12528,6 +12619,11 @@ secure-random@1.1.1: resolved "https://registry.yarnpkg.com/secure-random/-/secure-random-1.1.1.tgz#0880f2d8c5185f4bcb4684058c836b4ddb07145a" integrity sha1-CIDy2MUYX0vLRoQFjINrTdsHFFo= +secure-random@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/secure-random/-/secure-random-1.1.2.tgz#ed103b460a851632d420d46448b2a900a41e7f7c" + integrity sha512-H2bdSKERKdBV1SwoqYm6C0y+9EA94v6SUBOWO8kDndc4NoUih7Dv6Tsgma7zO1lv27wIvjlD0ZpMQk7um5dheQ== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -14787,6 +14883,11 @@ ws@7.4.4: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59" integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== +ws@7.4.5: + version "7.4.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1" + integrity sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g== + ws@^5.2.0: version "5.2.2" resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f"