diff --git a/package.json b/package.json index 29a8728..85639cd 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "license": "LGPL", "private": true, "dependencies": { + "@types/promise-timeout": "^1.3.0", "aws-sdk": "^2.222.1", "axios": "^0.18.0", "date-fns": "^1.29.0", @@ -18,6 +19,7 @@ "make-runnable": "^1.3.6", "numeral": "^2.0.6", "ont-sdk-ts": "backslash47/ontology-ts-sdk#exported", + "promise-timeout": "^1.3.0", "query-string": "^6.0.0", "react": "^16.2.0", "react-copy-to-clipboard": "^5.0.1", diff --git a/src/accounts/accountDetail.ts b/src/accounts/accountDetail.ts index 08bb005..05339ae 100644 --- a/src/accounts/accountDetail.ts +++ b/src/accounts/accountDetail.ts @@ -23,6 +23,7 @@ import { StateSetter } from '~/utils'; import { getAccount } from '~/shared/accountsApi'; import { Account } from '~/shared/ont/model'; import View from './accountDetailView'; +import { isOwnAccount } from '~/shared/walletApi'; interface PropsOuter { match: match<{id: string}>; @@ -35,6 +36,7 @@ interface PropsOwn { interface State { account: Account; + own: boolean; loaded: boolean; } @@ -46,14 +48,17 @@ export default compose( accountId: props.match.params.id })), withState, 'state', 'setState'>('state', 'setState', { - loaded: false + loaded: false, + own: false }), lifecycle, null>({ async componentDidMount() { const account = await getAccount(this.props.accountId); + const own = isOwnAccount(account.address); this.props.setState({ account, + own, loaded: true }); } diff --git a/src/accounts/accountDetailView.tsx b/src/accounts/accountDetailView.tsx index 6b87d44..1ce2725 100644 --- a/src/accounts/accountDetailView.tsx +++ b/src/accounts/accountDetailView.tsx @@ -18,7 +18,7 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; -import { Breadcrumb, Segment, Table, Header, Popup, Loader } from 'semantic-ui-react'; +import { Breadcrumb, Segment, Table, Header, Popup, Loader, Button } from 'semantic-ui-react'; import { distanceInWordsToNow, format } from 'date-fns'; import { PropsInner as Props } from './accountDetail'; import AccountTransfers from './accountTransfers'; @@ -31,6 +31,9 @@ const Transaction: React.SFC = (props) => ( Accounts {props.accountId} + {props.own ? ( +  (Own) + ) : null} @@ -49,21 +52,30 @@ const Transaction: React.SFC = (props) => ( Created - - {distanceInWordsToNow(props.account.firstTime)}}> - {format(props.account.firstTime, 'MMM Do YYYY HH:mm:ss')} - - + {props.account.firstTime !== undefined ? ( + + {distanceInWordsToNow(props.account.firstTime)}}> + {format(props.account.firstTime, 'MMM Do YYYY HH:mm:ss')} + + + ) : ( + <>new + )} + Last transaction + {props.account.lastTime !== undefined ? ( {distanceInWordsToNow(props.account.lastTime)}}> {format(props.account.lastTime, 'MMM Do YYYY HH:mm:ss')} + ) : ( + <>new + )} @@ -77,6 +89,15 @@ const Transaction: React.SFC = (props) => ( {props.loaded ? (
Assets
+ {props.own ? ( + + ) : null} diff --git a/src/accounts/accountsGridView.tsx b/src/accounts/accountsGridView.tsx index 1835e27..7be6eb6 100644 --- a/src/accounts/accountsGridView.tsx +++ b/src/accounts/accountsGridView.tsx @@ -91,36 +91,49 @@ const Accounts: React.SFC = (props) => ( ) : null} {props.items.map(account => ( - - {account.address} - - - - {distanceInWordsToNow(account.firstTime)}}> - {format(account.firstTime, 'MMM Do YYYY HH:mm:ss')} - - - - - - {distanceInWordsToNow(account.lastTime)}}> - {format(account.lastTime, 'MMM Do YYYY HH:mm:ss')} - - - - - {account.transactionsCount} - - - - {account.ontBalance} - - - - - {account.ongBalance} - - + {account.firstTime !== undefined && account.lastTime !== undefined ? ( + <> + + {account.address} + + + + {distanceInWordsToNow(account.firstTime)}}> + {format(account.firstTime, 'MMM Do YYYY HH:mm:ss')} + + + + + + {distanceInWordsToNow(account.lastTime)}}> + {format(account.lastTime, 'MMM Do YYYY HH:mm:ss')} + + + + + {account.transactionsCount} + + + + {account.ontBalance} + + + + + {account.ongBalance} + + + + ) : ( + <> + {account.address} + + + {account.transactionsCount} + {account.ontBalance} + {account.ongBalance} + + )} ))} diff --git a/src/accounts/transfer.ts b/src/accounts/transfer.ts new file mode 100644 index 0000000..8fd5334 --- /dev/null +++ b/src/accounts/transfer.ts @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2018 Matus Zamborsky + * This file is part of The ONT Detective. + * + * The ONT Detective is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The ONT Detective is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with The ONT Detective. If not, see . + */ + +import { compose, withHandlers, flattenProp, withProps, withState } from 'recompose'; +import { get } from 'lodash'; +import { RouterProps, match } from 'react-router'; +import { withRouter } from 'react-router-dom'; +import { timeout, TimeoutError } from 'promise-timeout'; +import { transferAsset } from '~/shared/walletApi'; +import View from './transferView'; +import { StateSetter } from '~/utils'; + +interface PropsOuter { + match: match<{id: string}>; +} + +interface PropsOwn { + id: string; +} + +interface State { + sending: boolean; +} + +interface Handlers { + handleSend: (values: object) => void; + handleValidateNotEmpty: (value: string) => boolean; + handleValidateAddress: (value: string) => boolean; +} + +export interface PropsInner extends Handlers, State, PropsOwn, PropsOuter { +} + +export default compose( + withRouter, + withProps(props => ({ + id: props.match.params.id + })), + withState, 'state', 'setState'>('state', 'setState', { + sending: false + }), + withHandlers, Handlers>({ + handleSend: (props) => async (values) => { + props.setState({ + ...props.state, + sending: true + }); + + const destination = get(values, 'destination', ''); + const amount = get(values, 'amount', '0'); + const password = get(values, 'password', ''); + + try { + await timeout(transferAsset(props.id, destination, password, amount), 15000); + props.setState({ + ...props.state, + sending: false + }); + + props.history.push(`/accounts/${props.id}`); + return Promise.resolve({}); + } catch (e) { + props.setState({ + ...props.state, + sending: false, + }); + + if (e instanceof TimeoutError) { + return Promise.resolve({ FORM_ERROR: 'Failed to transfer.'}); + } else { + return Promise.resolve({ password: 'Invalid password.'}); + } + } + }, + handleValidateNotEmpty: (props) => (value) => (value === undefined || value.trim().length === 0), + handleValidateAddress: (props) => (value) => (value === undefined || value.trim().length !== 34) + }), + flattenProp('state'), +) (View); diff --git a/src/accounts/transferView.tsx b/src/accounts/transferView.tsx new file mode 100644 index 0000000..fc08261 --- /dev/null +++ b/src/accounts/transferView.tsx @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2018 Matus Zamborsky + * This file is part of The ONT Detective. + * + * The ONT Detective is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The ONT Detective is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with The ONT Detective. If not, see . + */ + +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { Form as FinalForm, Field } from 'react-final-form'; +import { Breadcrumb, Segment, Header, Message, Button, Form as SemanticForm, Loader } from 'semantic-ui-react'; +import { InputField, Form } from '~/form/formWrapper'; +import { PropsInner as Props } from './transfer'; + +const assetOptions = [ + { + text: 'ONT', + value: 'ONT' + }, + { + text: 'ONG', + value: 'ONG' + }, +]; + +const CreateAccount: React.SFC = (props) => ( + + +
+ + Wallet + + Transfer asset + +
+
+ + +

+ Check the destination address twice before sending any asset. +

+
+ {props.sending ? ( + Transfering ... + ) : ( null )} + + +

+ Failed to transfer asset ! +

+
+ {!props.sending ? ( + <> + + + + + + + ) : null} +
+
+
+); + +export default CreateAccount; diff --git a/src/app/appView.tsx b/src/app/appView.tsx index 0928a02..8470688 100644 --- a/src/app/appView.tsx +++ b/src/app/appView.tsx @@ -31,7 +31,9 @@ import OntIdsGrid from '~/ontIds/ontIdsGrid'; import OntIdDetail from '~/ontIds/ontIdDetail'; import Wallet from '~/wallet/wallet'; import CreateWallet from '~/wallet/createWallet'; +import CreateAccount from '~/wallet/createAccount'; import CreateClaim from '~/ontIds/createClaim'; +import Transfer from '~/accounts/transfer'; import Layout from '~/layout/layoutView'; const App: React.SFC<{}> = () => ( @@ -46,11 +48,13 @@ const App: React.SFC<{}> = () => ( + + ); diff --git a/src/form/formWrapper.ts b/src/form/formWrapper.ts index 1e5b847..0dd7bb1 100644 --- a/src/form/formWrapper.ts +++ b/src/form/formWrapper.ts @@ -40,6 +40,7 @@ export const FormWrapper = compose( mapProps((outer) => ({ onSubmit: outer.handleSubmit, + error: outer.submitFailed, children: get(outer, 'children') })) ); diff --git a/src/index.css b/src/index.css index f2db2d4..d790e81 100644 --- a/src/index.css +++ b/src/index.css @@ -65,6 +65,7 @@ body { .ui.form textarea, .ui.menu, .ui.tabular.menu .active.item, +.ui.dropdown, .ui.dropdown .menu, .ui.message { border-radius: 0 !important; diff --git a/src/shared/accountsApi.ts b/src/shared/accountsApi.ts index 02a04d3..915359f 100644 --- a/src/shared/accountsApi.ts +++ b/src/shared/accountsApi.ts @@ -17,6 +17,8 @@ */ import { SearchParams } from 'elasticsearch'; +import { core } from 'ont-sdk-ts'; +import { concat } from 'lodash'; import { getClient } from './elastic/api'; import { Indices } from './elastic/model'; import { Account } from './ont/model'; @@ -31,12 +33,34 @@ export async function getAccount(address: string): Promise { return response._source; } +export async function getMissingAccountIds( + addresses: string[] +): Promise { + const client = getClient(); + + const params: SearchParams = { + index: Indices.Account, + size: 10000, + _sourceInclude: ['address'], + body: { + query: { + ids : { values: addresses } + } + } + }; + + const response = await client.search<{address: string}>(params); + const items = response.hits.hits.map(account => account._source.address); + + return addresses.filter(address => !items.includes(address)); +} + export async function getAccountsByIds( addresses: string[], from: number, size: number, sortColumn: SortColumn, - direction: Direction + direction: Direction, ): Promise> { const client = getClient(); @@ -55,7 +79,19 @@ export async function getAccountsByIds( const response = await client.search(params); const items = response.hits.hits.map(account => account._source); - return { items, count: response.hits.total }; + const missingAddresses = await getMissingAccountIds(addresses); + + const missingAccounts: Account[] = missingAddresses.map(address => ({ + address, + u160Address: core.addressToU160(address), + transactionsCount: 0, + ontBalance: 0, + ongBalance: 0 + })); + + const allAccounts = concat(missingAccounts, items); + + return { items: allAccounts, count: response.hits.total }; } export async function getAccounts( diff --git a/src/shared/ont/model.ts b/src/shared/ont/model.ts index 17f10b6..b72e52d 100644 --- a/src/shared/ont/model.ts +++ b/src/shared/ont/model.ts @@ -129,10 +129,10 @@ export interface Sig { export type Account = { address: string; u160Address: string, - firstTime: number; - firstTx: string; - lastTime: number; - lastTx: string; + firstTime?: number; + firstTx?: string; + lastTime?: number; + lastTx?: string; transactionsCount: number; ontBalance: number; ongBalance: number; diff --git a/src/shared/walletApi.ts b/src/shared/walletApi.ts index 38c1f78..a2fde62 100644 --- a/src/shared/walletApi.ts +++ b/src/shared/walletApi.ts @@ -1,4 +1,16 @@ -import { OntidContract, WebSocketClientApi, CONST, Wallet, Metadata, Claim, utils, scrypt } from 'ont-sdk-ts'; +import { + OntidContract, + WebSocketClientApi, + CONST, + Wallet, + Metadata, + Claim, + utils, + scrypt, + Account, + core, + TransactionBuilder +} from 'ont-sdk-ts'; import { get, find } from 'lodash'; import TxSender from './txSender'; @@ -10,6 +22,93 @@ export enum Errors { WRONG_PASSWORD = 'Wrong password' } +export function createAccount(label: string, password: string): Account { + const wallet = loadWallet(); + + if (wallet !== null) { + // check password + if (wallet.defaultOntid !== undefined) { + const identity = find(wallet.identities, ident => ident.ontid === wallet.defaultOntid); + + if (identity !== undefined) { + try { + const encryptedPrivateKey = identity.controls[0].key; + scrypt.decrypt(encryptedPrivateKey, password); + } catch (e) { + throw new Error(Errors.WRONG_PASSWORD); + } + } + } + + const account = new Account(); + const privateKey = core.generatePrivateKeyStr(); + account.create(privateKey, password, label); + + wallet.addAccount(account); + if (wallet.defaultAccountAddress === '') { + wallet.setDefaultAccount(account.address); + } + + saveWallet(wallet.toJson()); + + return account; + } else { + throw new Error(Errors.NOT_SIGNED_IN); + } +} + +export async function transferAsset( + from: string, + to: string, + password: string, + amount: number +): Promise { + const wallet = loadWallet(); + + if (wallet !== null) { + const account = find(wallet.accounts, a => a.address === from); + + if (account !== undefined) { + + try { + const encryptedPrivateKey = account.key; + const privateKey = scrypt.decrypt(encryptedPrivateKey, password); + + return new Promise((resolve, reject) => { + const tx = TransactionBuilder.makeTransferTransaction( + 'ONT', + core.addressToU160(from), + core.addressToU160(to), + amount.toString(), + privateKey + ); + const raw = builder.sendRawTransaction(tx.serialize()); + + const txSender = new TxSender(CONST.TEST_ONT_URL.SOCKET_URL); + txSender.sendTxWithSocket(raw, (err, res, socket) => { + if (err !== null) { + reject(err); + } else if ( + get(res, 'Action') === 'InvokeTransaction' && + get(res, 'Desc') === 'SUCCESS' && + socket !== null + ) { + socket.close(); + resolve(); + } + }); + }); + } catch (e) { + return Promise.reject(Errors.WRONG_PASSWORD); + } + } else { + return Promise.reject(Errors.IDENTITY_NOT_FOUND); + } + } else { + return Promise.reject(Errors.NOT_SIGNED_IN); + } +} + export async function registerIdentity(ontId: string, privKey: string): Promise { return new Promise((resolve, reject) => { const tx = OntidContract.buildRegisterOntidTx(ontId, privKey); @@ -138,3 +237,13 @@ export function isOwnIdentity(ontId: string): boolean { return false; } + +export function isOwnAccount(address: string): boolean { + const wallet = loadWallet(); + + if (wallet !== null) { + return wallet.accounts.map(a => a.address).includes(address); + } + + return false; +} diff --git a/src/wallet/createAccount.ts b/src/wallet/createAccount.ts new file mode 100644 index 0000000..46f7c52 --- /dev/null +++ b/src/wallet/createAccount.ts @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2018 Matus Zamborsky + * This file is part of The ONT Detective. + * + * The ONT Detective is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The ONT Detective is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with The ONT Detective. If not, see . + */ + +import { compose, withHandlers, flattenProp } from 'recompose'; +import { get } from 'lodash'; +import { RouterProps } from 'react-router'; +import { withRouter } from 'react-router-dom'; +import { createAccount } from '~/shared/walletApi'; +import View from './createAccountView'; + +interface PropsOuter { +} + +interface Handlers { + handleCreate: (values: object) => void; + handleValidateNotEmpty: (value: string) => boolean; +} + +export interface PropsInner extends Handlers, PropsOuter { +} + +export default compose( + withRouter, + withHandlers({ + handleCreate: (props) => async (values) => { + + const name = get(values, 'name', ''); + const password = get(values, 'password', ''); + + try { + createAccount(name, password); + + props.history.push('/wallet'); + + return Promise.resolve({}); + } catch (e) { + return Promise.resolve({ password: 'Invalid password.'}); + } + }, + handleValidateNotEmpty: (props) => (value) => (value === undefined || value.trim().length === 0) + }), + flattenProp('state'), +) (View); diff --git a/src/wallet/createAccountView.tsx b/src/wallet/createAccountView.tsx new file mode 100644 index 0000000..f34fd98 --- /dev/null +++ b/src/wallet/createAccountView.tsx @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2018 Matus Zamborsky + * This file is part of The ONT Detective. + * + * The ONT Detective is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The ONT Detective is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with The ONT Detective. If not, see . + */ + +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { Form as FinalForm, Field } from 'react-final-form'; +import { Breadcrumb, Segment, Header, Message, Button } from 'semantic-ui-react'; +import { InputField, Form } from '~/form/formWrapper'; +import { PropsInner as Props } from './createAccount'; + +const CreateAccount: React.SFC = (props) => ( + + +
+ + Wallet + + Create account + +
+
+ + +

+ This wizzard creates new account with private key and encrypt it with provided + password. No data is transmitted to the server. +

+
+ + + + + +
+
+); + +export default CreateAccount; diff --git a/src/wallet/walletView.tsx b/src/wallet/walletView.tsx index 9e9d82b..924d63d 100644 --- a/src/wallet/walletView.tsx +++ b/src/wallet/walletView.tsx @@ -96,6 +96,7 @@ const Wallet: React.SFC = (props) => ( menuItem: 'Accounts', render: () => ( + {props.wallet !== undefined ? ( ) : null} diff --git a/yarn.lock b/yarn.lock index 2bd1b2a..b00cb24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -93,6 +93,10 @@ version "0.0.22" resolved "https://registry.yarnpkg.com/@types/numeral/-/numeral-0.0.22.tgz#86bef1f0a2d743afdc2ef3168d45f2905e1a0b93" +"@types/promise-timeout@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/promise-timeout/-/promise-timeout-1.3.0.tgz#90649ff6f48c1ead9de142e6dd9f62f8c5a54022" + "@types/query-string@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@types/query-string/-/query-string-5.1.0.tgz#7f40cdea49ddafa0ea4f3db35fb6c24d3bfd4dcc" @@ -6194,6 +6198,10 @@ promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" +promise-timeout@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/promise-timeout/-/promise-timeout-1.3.0.tgz#d1c78dd50a607d5f0a5207410252a3a0914e1014" + promise@8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/promise/-/promise-8.0.1.tgz#e45d68b00a17647b6da711bf85ed6ed47208f450"