diff --git a/package.json b/package.json index b0a307c1..49757fc7 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@ledgerhq/hw-transport": "^4.12.0", "@ledgerhq/hw-transport-node-hid": "^4.12.0", "@ledgerhq/ledger-core": "^1.2.0", - "@ledgerhq/live-common": "2.8.0-beta.2", + "@ledgerhq/live-common": "^2.8.1", "axios": "^0.18.0", "babel-runtime": "^6.26.0", "bcryptjs": "^2.4.3", diff --git a/src/api/Ethereum.js b/src/api/Ethereum.js index 9d6e260d..716accad 100644 --- a/src/api/Ethereum.js +++ b/src/api/Ethereum.js @@ -1,7 +1,7 @@ // @flow import axios from 'axios' import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' -import { blockchainBaseURL } from './Ledger' +import { blockchainBaseURL, userFriendlyError } from './Ledger' export type Block = { height: number } // TODO more fields actually export type Tx = { @@ -34,7 +34,7 @@ export type API = { txs: Tx[], }>, getCurrentBlock: () => Promise, - getAccountNonce: (address: string) => Promise, + getAccountNonce: (address: string) => Promise, broadcastTransaction: (signedTransaction: string) => Promise, getAccountBalance: (address: string) => Promise, } @@ -44,25 +44,27 @@ export const apiForCurrency = (currency: CryptoCurrency): API => { return { async getTransactions(address, blockHash) { - const { data } = await axios.get(`${baseURL}/addresses/${address}/transactions`, { - params: { blockHash, noToken: 1 }, - }) + const { data } = await userFriendlyError( + axios.get(`${baseURL}/addresses/${address}/transactions`, { + params: { blockHash, noToken: 1 }, + }), + ) return data }, async getCurrentBlock() { - const { data } = await axios.get(`${baseURL}/blocks/current`) + const { data } = await userFriendlyError(axios.get(`${baseURL}/blocks/current`)) return data }, async getAccountNonce(address) { - const { data } = await axios.get(`${baseURL}/addresses/${address}/nonce`) + const { data } = await userFriendlyError(axios.get(`${baseURL}/addresses/${address}/nonce`)) return data[0].nonce }, async broadcastTransaction(tx) { - const { data } = await axios.post(`${baseURL}/transactions/send`, { tx }) + const { data } = await userFriendlyError(axios.post(`${baseURL}/transactions/send`, { tx })) return data.result }, async getAccountBalance(address) { - const { data } = await axios.get(`${baseURL}/addresses/${address}/balance`) + const { data } = await userFriendlyError(axios.get(`${baseURL}/addresses/${address}/balance`)) return data[0].balance }, } diff --git a/src/api/Fees.js b/src/api/Fees.js index 00e4278b..1ce1b83b 100644 --- a/src/api/Fees.js +++ b/src/api/Fees.js @@ -1,14 +1,14 @@ // @flow import axios from 'axios' import type { Currency } from '@ledgerhq/live-common/lib/types' -import { blockchainBaseURL } from './Ledger' +import { blockchainBaseURL, userFriendlyError } from './Ledger' export type Fees = { [_: string]: number, } export const getEstimatedFees = async (currency: Currency): Promise => { - const { data, status } = await axios.get(`${blockchainBaseURL(currency)}/fees`) + const { data, status } = await userFriendlyError(axios.get(`${blockchainBaseURL(currency)}/fees`)) if (data) { return data } diff --git a/src/api/Ledger.js b/src/api/Ledger.js index d8ee1ad0..76c66191 100644 --- a/src/api/Ledger.js +++ b/src/api/Ledger.js @@ -14,5 +14,39 @@ export const currencyToFeeTicker = (currency: Currency) => { return mapping[currency.id] || tickerLowerCase } +export const userFriendlyError = (p: Promise): Promise => + p.catch(error => { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + const { data } = error.response + if (data && typeof data.error === 'string') { + const msg = data.error || data.message + if (typeof msg === 'string') { + const m = msg.match(/^JsDefined\((.*)\)$/) + if (m) { + try { + const { message } = JSON.parse(m[1]) + if (typeof message === 'string') { + throw new Error(message) + } + } catch (e) { + console.log(e) + } + } + throw new Error(msg) + } + } + console.log('Ledger API: HTTP status', error.response.status, 'data: ', error.response.data) + throw new Error('A problem occurred with Ledger Servers. Please try again later.') + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + throw new Error('Your network is down. Please try again later.') + } + throw error + }) + export const blockchainBaseURL = (currency: Currency) => `${BASE_URL}blockchain/v2/${currencyToFeeTicker(currency)}` diff --git a/src/bridge/EthereumJSBridge.js b/src/bridge/EthereumJSBridge.js index 2433d6dd..4cc6d826 100644 --- a/src/bridge/EthereumJSBridge.js +++ b/src/bridge/EthereumJSBridge.js @@ -2,6 +2,7 @@ import React from 'react' import EthereumKind from 'components/FeesField/EthereumKind' import throttle from 'lodash/throttle' +import uniqBy from 'lodash/uniqBy' import type { Account, Operation } from '@ledgerhq/live-common/lib/types' import { apiForCurrency } from 'api/Ethereum' import type { Tx } from 'api/Ethereum' @@ -29,7 +30,7 @@ const toAccountOperation = (account: Account) => (tx: Tx): $Exact => const receiving = account.freshAddress.toLowerCase() === tx.to.toLowerCase() const type = sending && receiving ? 'SELF' : sending ? 'OUT' : 'IN' return { - id: tx.hash, + id: `${account.id}-${tx.hash}-${type}`, hash: tx.hash, type, value: tx.value, @@ -47,9 +48,9 @@ function isRecipientValid(currency, recipient) { } function mergeOps(existing: Operation[], newFetched: Operation[]) { - const ids = existing.map(o => o.id) - const all = existing.concat(newFetched.filter(o => !ids.includes(o.id))) - return all.sort((a, b) => a.date - b.date) + const ids = newFetched.map(o => o.id) + const all = newFetched.concat(existing.filter(o => !ids.includes(o.id))) + return uniqBy(all.sort((a, b) => a.date - b.date), 'id') } const paginateMoreTransactions = async ( @@ -66,7 +67,7 @@ const paginateMoreTransactions = async ( } const fetchCurrentBlock = (perCurrencyId => currency => { - if (perCurrencyId[currency.id]) return perCurrencyId[currency.id] + if (perCurrencyId[currency.id]) return perCurrencyId[currency.id]() const api = apiForCurrency(currency) const f = throttle( () => @@ -77,7 +78,7 @@ const fetchCurrentBlock = (perCurrencyId => currency => { 5000, ) perCurrencyId[currency.id] = f - return f + return f() })({}) const EthereumBridge: WalletBridge = { @@ -113,7 +114,6 @@ const EthereumBridge: WalletBridge = { if (isStandard) { if (newAccountCount === 0) { // first zero account will emit one account as opportunity to create a new account.. - const currentBlock = await fetchCurrentBlock(currency) const accountId = `${currency.id}_${address}` const account: $Exact = { id: accountId, @@ -158,7 +158,7 @@ const EthereumBridge: WalletBridge = { unit: currency.units[0], lastSyncDate: new Date(), } - account.operations = txs.map(toAccountOperation(account)) + account.operations = mergeOps([], txs.map(toAccountOperation(account))) return { account } } @@ -205,6 +205,8 @@ const EthereumBridge: WalletBridge = { if (unsubscribed) return const { txs } = await api.getTransactions(freshAddress) if (unsubscribed) return + const nonce = await api.getAccountNonce(freshAddress) + if (unsubscribed) return next(a => { const currentOps = a.operations const newOps = txs.map(toAccountOperation(a)) @@ -219,8 +221,15 @@ const EthereumBridge: WalletBridge = { return a } const operations = mergeOps(currentOps, newOps) + const pendingOperations = a.pendingOperations.filter( + o => + o.transactionSequenceNumber && + o.transactionSequenceNumber >= nonce && + !operations.some(op => o.hash === op.hash), + ) return { ...a, + pendingOperations, operations, balance, blockHeight: block.height, @@ -234,6 +243,7 @@ const EthereumBridge: WalletBridge = { } } main() + return { unsubscribe() { unsubscribed = true @@ -291,10 +301,31 @@ const EthereumBridge: WalletBridge = { }) .toPromise() - const result = await api.broadcastTransaction(transaction) + const hash = await api.broadcastTransaction(transaction) - return result + return { + id: `${a.id}-${hash}-OUT`, + hash, + type: 'OUT', + value: t.amount, + blockHeight: null, + blockHash: null, + accountId: a.id, + senders: [a.freshAddress], + recipients: [t.recipient], + transactionSequenceNumber: nonce, + date: new Date(), + } }, + + addPendingOperation: (account, operation) => ({ + ...account, + pendingOperations: [operation].concat( + account.pendingOperations.filter( + o => o.transactionSequenceNumber === operation.transactionSequenceNumber, + ), + ), + }), } export default EthereumBridge diff --git a/src/bridge/LibcoreBridge.js b/src/bridge/LibcoreBridge.js index b6eb3de3..6c4f17d4 100644 --- a/src/bridge/LibcoreBridge.js +++ b/src/bridge/LibcoreBridge.js @@ -44,7 +44,16 @@ const LibcoreBridge: WalletBridge = { switch (msg.type) { case 'account.sync.progress': { next(a => a) - // use next(), to actually emit account updates..... + // FIXME TODO: use next(), to actually emit account updates..... + // - need to sync the balance + // - need to sync block height & block hash + // - need to sync operations. + // - once all that, need to set lastSyncDate to new Date() + + // - when you implement addPendingOperation you also here need to: + // - if there were pendingOperations that are now in operations, remove them as well. + // - if there are pendingOperations that is older than a threshold (that depends on blockchain speed typically) + // then we probably should trash them out? it's a complex question for UI break } case 'account.sync.fail': { diff --git a/src/bridge/RippleJSBridge.js b/src/bridge/RippleJSBridge.js index b44e8ca1..440c72d6 100644 --- a/src/bridge/RippleJSBridge.js +++ b/src/bridge/RippleJSBridge.js @@ -119,6 +119,7 @@ type Tx = { const txToOperation = (account: Account) => ({ id, + sequence, outcome: { deliveredAmount, ledgerVersion, timestamp }, specification: { source, destination }, }: Tx): Operation => { @@ -135,6 +136,7 @@ const txToOperation = (account: Account) => ({ senders: [source.address], recipients: [destination.address], date: new Date(timestamp), + transactionSequenceNumber: sequence, } return op } @@ -299,9 +301,18 @@ const RippleJSBridge: WalletBridge = { next(a => { const newOps = transactions.map(txToOperation(a)) const operations = mergeOps(a.operations, newOps) + const [last] = operations + const pendingOperations = a.pendingOperations.filter( + o => + last && + last.transactionSequenceNumber && + o.transactionSequenceNumber && + o.transactionSequenceNumber > last.transactionSequenceNumber, + ) return { ...a, operations, + pendingOperations, blockHeight: maxLedgerVersion, lastSyncDate: new Date(), } @@ -394,11 +405,37 @@ const RippleJSBridge: WalletBridge = { throw new Error(submittedPayment.resultMessage) } - return computeBinaryTransactionHash(transaction) + const hash = computeBinaryTransactionHash(transaction) + + return { + id: `${a.id}-${hash}-OUT`, + hash, + accountId: a.id, + type: 'OUT', + value: t.amount, + blockHash: null, + blockHeight: null, + senders: [a.freshAddress], + recipients: [t.recipient], + date: new Date(), + // we probably can't get it so it's a predictive value + transactionSequenceNumber: + (a.operations.length > 0 ? a.operations[0].transactionSequenceNumber : 0) + + a.pendingOperations.length, + } } finally { api.disconnect() } }, + + addPendingOperation: (account, operation) => ({ + ...account, + pendingOperations: [operation].concat( + account.pendingOperations.filter( + o => o.transactionSequenceNumber === operation.transactionSequenceNumber, + ), + ), + }), } export default RippleJSBridge diff --git a/src/bridge/makeMockBridge.js b/src/bridge/makeMockBridge.js index 87931642..f41b6daf 100644 --- a/src/bridge/makeMockBridge.js +++ b/src/bridge/makeMockBridge.js @@ -152,12 +152,14 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> { const op = genOperation(account, account.operations, account.currency, rng) op.type = 'OUT' op.value = t.amount + op.blockHash = null + op.blockHeight = null op.senders = [account.freshAddress] op.recipients = [t.recipient] op.blockHeight = account.blockHeight op.date = new Date() broadcasted[account.id] = (broadcasted[account.id] || []).concat(op) - return op.id + return { ...op } }, } } diff --git a/src/bridge/types.js b/src/bridge/types.js index 716ba35d..c400fc8c 100644 --- a/src/bridge/types.js +++ b/src/bridge/types.js @@ -1,6 +1,6 @@ // @flow -import type { Account, Currency } from '@ledgerhq/live-common/lib/types' +import type { Account, Operation, Currency } from '@ledgerhq/live-common/lib/types' // a WalletBridge is implemented on renderer side. // this is an abstraction on top of libcore / ethereumjs / ripple js / ... @@ -93,10 +93,18 @@ export interface WalletBridge { * finalize the transaction by * - signing it with the ledger device * - broadcasting it to network - * - retrieve and return the id related to this transaction (typically a tx id hash) + * - retrieve and return the optimistic Operation that this transaction is likely to create in the future * * NOTE: in future, when transaction balance is close to account.balance, we could wipe it all at this level... * to implement that, we might want to have special logic `account.balance-transaction.amount < dust` but not sure where this should leave (i would say on UI side because we need to inform user visually). */ - signAndBroadcast(account: Account, transaction: Transaction, deviceId: DeviceId): Promise; + signAndBroadcast( + account: Account, + transaction: Transaction, + deviceId: DeviceId, + ): Promise; + + // Implement an optimistic response for signAndBroadcast. + // you likely should add the operation in account.pendingOperations but maybe you want to clean it (because maybe some are replaced / cancelled by this one?) + addPendingOperation?: (account: Account, optimisticOperation: Operation) => Account; } diff --git a/src/components/DeviceSignTransaction.js b/src/components/DeviceSignTransaction.js index 31801aac..91ccaa31 100644 --- a/src/components/DeviceSignTransaction.js +++ b/src/components/DeviceSignTransaction.js @@ -1,11 +1,11 @@ // @flow import { PureComponent } from 'react' -import type { Account } from '@ledgerhq/live-common/lib/types' +import type { Account, Operation } from '@ledgerhq/live-common/lib/types' import type { Device } from 'types/common' import type { WalletBridge } from 'bridge/types' type Props = { - onSuccess: (txid: string) => void, + onOperationBroadcasted: (op: Operation) => void, render: ({ error: ?Error }) => React$Node, device: Device, account: Account, @@ -32,10 +32,10 @@ class DeviceSignTransaction extends PureComponent { unmount = false sign = async () => { - const { device, account, transaction, bridge, onSuccess } = this.props + const { device, account, transaction, bridge, onOperationBroadcasted } = this.props try { - const txid = await bridge.signAndBroadcast(account, transaction, device.path) - onSuccess(txid) + const optimisticOperation = await bridge.signAndBroadcast(account, transaction, device.path) + onOperationBroadcasted(optimisticOperation) } catch (error) { console.warn(error) this.setState({ error }) diff --git a/src/components/OperationsList/ConfirmationCheck.js b/src/components/OperationsList/ConfirmationCheck.js index cdd7c62c..599f8ae1 100644 --- a/src/components/OperationsList/ConfirmationCheck.js +++ b/src/components/OperationsList/ConfirmationCheck.js @@ -46,22 +46,18 @@ const WrapperClock = styled(Box).attrs({ const ConfirmationCheck = ({ marketColor, - confirmations, - minConfirmations, + isConfirmed, t, type, withTooltip, ...props }: { marketColor: string, - confirmations: number, - minConfirmations: number, + isConfirmed: boolean, t: T, type: OperationType, withTooltip?: boolean, }) => { - const isConfirmed = confirmations >= minConfirmations - const renderContent = () => ( {type === 'IN' ? : } diff --git a/src/components/OperationsList/Operation.js b/src/components/OperationsList/Operation.js index 202f6779..c46606fc 100644 --- a/src/components/OperationsList/Operation.js +++ b/src/components/OperationsList/Operation.js @@ -11,7 +11,7 @@ import { getOperationAmountNumber } from '@ledgerhq/live-common/lib/helpers/oper import type { Account, Operation } from '@ledgerhq/live-common/lib/types' -import type { T } from 'types/common' +import type { T, CurrencySettings } from 'types/common' import { currencySettingsForAccountSelector, marketIndicatorSelector } from 'reducers/settings' import { rgba, getMarketColor } from 'styles/helpers' @@ -33,7 +33,7 @@ const ACCOUNT_COL_SIZE = 150 const AMOUNT_COL_SIZE = 150 const CONFIRMATION_COL_SIZE = 44 -const OperationRaw = styled(Box).attrs({ +const OperationRow = styled(Box).attrs({ horizontal: true, alignItems: 'center', })` @@ -103,7 +103,7 @@ const Cell = styled(Box).attrs({ type Props = { account: Account, - currencySettings: *, + currencySettings: CurrencySettings, onAccountClick: (account: Account) => void, onOperationClick: ({ operation: Operation, account: Account, marketColor: string }) => void, marketIndicator: string, @@ -135,19 +135,26 @@ class OperationComponent extends PureComponent { const Icon = getCryptoCurrencyIcon(account.currency) const amount = getOperationAmountNumber(op) const isNegative = amount < 0 + const isOptimistic = op.blockHeight === null + const isConfirmed = + (op.blockHeight ? account.blockHeight - op.blockHeight : 0) > currencySettings.confirmationsNb const marketColor = getMarketColor({ marketIndicator, isNegative, }) + // FIXME each cell in a component + return ( - onOperationClick({ operation: op, account, marketColor })}> + onOperationClick({ operation: op, account, marketColor })} + > @@ -208,7 +215,7 @@ class OperationComponent extends PureComponent { /> - + ) } } diff --git a/src/components/modals/OperationDetails.js b/src/components/modals/OperationDetails.js index 350a91d4..5c3d8917 100644 --- a/src/components/modals/OperationDetails.js +++ b/src/components/modals/OperationDetails.js @@ -9,7 +9,7 @@ import moment from 'moment' import { getOperationAmountNumber } from '@ledgerhq/live-common/lib/helpers/operation' import type { Account, Operation } from '@ledgerhq/live-common/lib/types' -import type { T } from 'types/common' +import type { T, CurrencySettings } from 'types/common' import { MODAL_OPERATION_DETAILS } from 'config/constants' @@ -63,7 +63,7 @@ type Props = { operation: Operation, account: Account, onClose: () => void, - currencySettings: *, + currencySettings: CurrencySettings, marketColor: string, } @@ -74,7 +74,7 @@ const OperationDetails = connect(mapStateToProps)((props: Props) => { const { name, unit, currency } = account const confirmations = operation.blockHeight ? account.blockHeight - operation.blockHeight : 0 - const isConfirmed = confirmations >= currencySettings.minConfirmations + const isConfirmed = confirmations >= currencySettings.confirmationsNb return ( Operation details @@ -82,8 +82,7 @@ const OperationDetails = connect(mapStateToProps)((props: Props) => { , transaction: *, - onValidate: Function, + onOperationBroadcasted: (op: Operation) => void, t: T, } -export default ({ account, device, bridge, transaction, onValidate, t }: Props) => ( +export default ({ account, device, bridge, transaction, onOperationBroadcasted, t }: Props) => ( {multiline(t('send:steps.verification.warning'))} {t('send:steps.verification.body')} @@ -51,7 +51,7 @@ export default ({ account, device, bridge, transaction, onValidate, t }: Props) device={device} transaction={transaction} bridge={bridge} - onSuccess={onValidate} + onOperationBroadcasted={onOperationBroadcasted} render={({ error }) => ( // FIXME we really really REALLY should use error for the display. otherwise we are completely blind on error cases.. diff --git a/src/components/modals/Send/04-step-confirmation.js b/src/components/modals/Send/04-step-confirmation.js index 7085fe71..c897ad35 100644 --- a/src/components/modals/Send/04-step-confirmation.js +++ b/src/components/modals/Send/04-step-confirmation.js @@ -1,6 +1,7 @@ // @flow import React from 'react' import styled from 'styled-components' +import type { Operation } from '@ledgerhq/live-common/lib/types' import IconCheckCircle from 'icons/CheckCircle' import IconExclamationCircleThin from 'icons/ExclamationCircleThin' @@ -36,15 +37,17 @@ const Text = styled(Box).attrs({ ` type Props = { - txValidated: ?string, + optimisticOperation: ?Operation, t: T, } function StepConfirmation(props: Props) { - const { t, txValidated } = props - const Icon = txValidated ? IconCheckCircle : IconExclamationCircleThin - const iconColor = txValidated ? colors.positiveGreen : colors.alertRed - const tPrefix = txValidated ? 'send:steps.confirmation.success' : 'send:steps.confirmation.error' + const { t, optimisticOperation } = props + const Icon = optimisticOperation ? IconCheckCircle : IconExclamationCircleThin + const iconColor = optimisticOperation ? colors.positiveGreen : colors.alertRed + const tPrefix = optimisticOperation + ? 'send:steps.confirmation.success' + : 'send:steps.confirmation.error' return ( @@ -53,7 +56,9 @@ function StepConfirmation(props: Props) { {t(`${tPrefix}.title`)} {multiline(t(`${tPrefix}.text`))} - {txValidated || ''} + + {optimisticOperation ? optimisticOperation.hash : ''} + ) } diff --git a/src/components/modals/Send/ConfirmationFooter.js b/src/components/modals/Send/ConfirmationFooter.js index 072a1472..085bb6e6 100644 --- a/src/components/modals/Send/ConfirmationFooter.js +++ b/src/components/modals/Send/ConfirmationFooter.js @@ -1,23 +1,24 @@ // @flow import React from 'react' +import type { Operation } from '@ledgerhq/live-common/lib/types' import Button from 'components/base/Button' import { ModalFooter } from 'components/base/Modal' import type { T } from 'types/common' export default ({ t, - txValidated, + optimisticOperation, onClose, onGoToFirstStep, }: { t: T, - txValidated: ?string, + optimisticOperation: ?Operation, onClose: () => void, onGoToFirstStep: () => void, }) => ( - {txValidated ? ( + {optimisticOperation ? ( // TODO: actually go to operations details