Browse Source

Improve errors in send flow

master
Gaëtan Renaudeau 7 years ago
parent
commit
15a34a9652
  1. 15
      src/api/Ethereum.js
  2. 9
      src/components/CounterValue/index.js
  3. 20
      src/components/DeviceConfirm/index.js
  4. 2
      src/components/DeviceConfirm/stories.js
  5. 22
      src/components/DeviceSignTransaction.js
  6. 3
      src/components/RecipientAddress/index.js
  7. 6
      src/components/RequestAmount/index.js
  8. 18
      src/components/base/Input/index.js
  9. 4
      src/components/modals/Receive/03-step-confirm-address.js
  10. 54
      src/components/modals/Send/01-step-amount.js
  11. 22
      src/components/modals/Send/03-step-verification.js
  12. 8
      src/components/modals/Send/04-step-confirmation.js
  13. 14
      src/components/modals/Send/AccountField.js
  14. 53
      src/components/modals/Send/AmountField.js
  15. 7
      src/components/modals/Send/Footer.js
  16. 79
      src/components/modals/Send/RecipientField.js
  17. 22
      src/components/modals/Send/SendModalBody.js
  18. 3
      src/helpers/errors.js
  19. 7
      src/helpers/promise.js
  20. 1
      src/internals/index.js
  21. 10
      static/i18n/en/send.yml

15
src/api/Ethereum.js

@ -1,5 +1,6 @@
// @flow
import axios from 'axios'
import { retry } from 'helpers/promise'
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types'
import { blockchainBaseURL, userFriendlyError } from './Ledger'
@ -52,19 +53,25 @@ export const apiForCurrency = (currency: CryptoCurrency): API => {
return data
},
async getCurrentBlock() {
const { data } = await userFriendlyError(axios.get(`${baseURL}/blocks/current`))
const { data } = await userFriendlyError(retry(() => axios.get(`${baseURL}/blocks/current`)))
return data
},
async getAccountNonce(address) {
const { data } = await userFriendlyError(axios.get(`${baseURL}/addresses/${address}/nonce`))
const { data } = await userFriendlyError(
retry(() => axios.get(`${baseURL}/addresses/${address}/nonce`)),
)
return data[0].nonce
},
async broadcastTransaction(tx) {
const { data } = await userFriendlyError(axios.post(`${baseURL}/transactions/send`, { tx }))
const { data } = await userFriendlyError(
retry(() => axios.post(`${baseURL}/transactions/send`, { tx })),
)
return data.result
},
async getAccountBalance(address) {
const { data } = await userFriendlyError(axios.get(`${baseURL}/addresses/${address}/balance`))
const { data } = await userFriendlyError(
retry(() => axios.get(`${baseURL}/addresses/${address}/balance`)),
)
return data[0].balance
},
}

9
src/components/CounterValue/index.js

@ -20,6 +20,8 @@ type OwnProps = {
date?: Date,
value: number,
alwaysShowSign?: boolean,
}
type Props = OwnProps & {
@ -47,8 +49,11 @@ const mapStateToProps = (state: State, props: OwnProps) => {
}
class CounterValue extends PureComponent<Props> {
static defaultProps = {
alwaysShowSign: true, // FIXME this shouldn't be true by default
}
render() {
const { value, counterValueCurrency, date, ...props } = this.props
const { value, counterValueCurrency, date, alwaysShowSign, ...props } = this.props
if (!value && value !== 0) {
return null
}
@ -57,7 +62,7 @@ class CounterValue extends PureComponent<Props> {
val={value}
unit={counterValueCurrency.units[0]}
showCode
alwaysShowSign
alwaysShowSign={alwaysShowSign}
{...props}
/>
)

20
src/components/DeviceConfirm/index.js

@ -23,17 +23,17 @@ const pulseAnimation = p => keyframes`
`
const Wrapper = styled(Box).attrs({
color: p => (p.notValid ? 'alertRed' : 'wallet'),
color: p => (p.error ? 'alertRed' : 'wallet'),
relative: true,
})`
padding-top: ${p => (p.notValid ? 0 : 30)}px;
padding-top: ${p => (p.error ? 0 : 30)}px;
transition: color ease-in-out 0.1s;
`
const WrapperIcon = styled(Box)`
color: ${p => (p.notValid ? p.theme.colors.alertRed : p.theme.colors.positiveGreen)};
color: ${p => (p.error ? p.theme.colors.alertRed : p.theme.colors.positiveGreen)};
position: absolute;
left: ${p => (p.notValid ? 152 : 193)}px;
left: ${p => (p.error ? 152 : 193)}px;
bottom: 16px;
svg {
@ -41,9 +41,9 @@ const WrapperIcon = styled(Box)`
}
`
const Check = ({ notValid }: { notValid: boolean }) => (
<WrapperIcon notValid={notValid}>
{notValid ? <IconCross size={10} /> : <IconCheck size={10} />}
const Check = ({ error }: { error: * }) => (
<WrapperIcon error={error}>
{error ? <IconCross size={10} /> : <IconCheck size={10} />}
</WrapperIcon>
)
@ -74,7 +74,7 @@ const PushButton = styled(Box)`
`
type Props = {
notValid: boolean,
error: *,
}
const SVG = (
@ -165,8 +165,8 @@ const SVG = (
const DeviceConfirm = (props: Props) => (
<Wrapper {...props}>
{props.notValid ? <PushButton /> : null}
<Check notValid={props.notValid} />
{!props.error ? <PushButton /> : null}
<Check error={props.error} />
{SVG}
</Wrapper>
)

2
src/components/DeviceConfirm/stories.js

@ -8,4 +8,4 @@ import DeviceConfirm from 'components/DeviceConfirm'
const stories = storiesOf('Components', module)
stories.add('DeviceConfirm', () => <DeviceConfirm notValid={boolean('notValid', false)} />)
stories.add('DeviceConfirm', () => <DeviceConfirm error={boolean('notValid', false)} />)

22
src/components/DeviceSignTransaction.js

@ -5,23 +5,16 @@ import type { Device } from 'types/common'
import type { WalletBridge } from 'bridge/types'
type Props = {
children: *,
onOperationBroadcasted: (op: Operation) => void,
render: ({ error: ?Error }) => React$Node,
onError: Error => void,
device: Device,
account: Account,
bridge: WalletBridge<*>,
transaction: *,
}
type State = {
error: ?Error,
}
class DeviceSignTransaction extends PureComponent<Props, State> {
state = {
error: null,
}
class DeviceSignTransaction extends PureComponent<Props> {
componentDidMount() {
this.sign()
}
@ -32,20 +25,17 @@ class DeviceSignTransaction extends PureComponent<Props, State> {
unmount = false
sign = async () => {
const { device, account, transaction, bridge, onOperationBroadcasted } = this.props
const { device, account, transaction, bridge, onOperationBroadcasted, onError } = this.props
try {
const optimisticOperation = await bridge.signAndBroadcast(account, transaction, device.path)
onOperationBroadcasted(optimisticOperation)
} catch (error) {
console.warn(error)
this.setState({ error })
onError(error)
}
}
render() {
const { render } = this.props
const { error } = this.state
return render({ error })
return this.props.children
}
}

3
src/components/RecipientAddress/index.js

@ -77,12 +77,13 @@ class RecipientAddress extends PureComponent<Props, State> {
}
render() {
const { onChange, withQrCode, value } = this.props
const { onChange, withQrCode, value, ...rest } = this.props
const { qrReaderOpened } = this.state
return (
<Box relative justifyContent="center">
<Input
{...rest}
value={value}
withQrCode={withQrCode}
onChange={onChange}

6
src/components/RequestAmount/index.js

@ -50,6 +50,8 @@ type Props = {
// left value (always the one which is returned)
value: number,
canBeSpent: boolean,
// max left value
max: number,
@ -76,6 +78,7 @@ type Props = {
export class RequestAmount extends PureComponent<Props> {
static defaultProps = {
max: Infinity,
canBeSpent: true,
withMax: true,
}
@ -97,12 +100,13 @@ export class RequestAmount extends PureComponent<Props> {
}
renderInputs(containerProps: Object) {
const { value, account, rightCurrency, getCounterValue, exchange } = this.props
const { value, account, rightCurrency, getCounterValue, exchange, canBeSpent } = this.props
const right = getCounterValue(account.currency, rightCurrency, exchange)(value) || 0
const rightUnit = rightCurrency.units[0]
return (
<Box horizontal grow shrink>
<InputCurrency
error={canBeSpent ? null : 'Not enough balance'}
containerProps={containerProps}
defaultUnit={account.unit}
value={value}

18
src/components/base/Input/index.js

@ -14,9 +14,20 @@ const Container = styled(Box).attrs({
})`
background: ${p => p.theme.colors.white};
border-radius: ${p => p.theme.radii[1]}px;
border: 1px solid ${p => (p.isFocus ? p.theme.colors.wallet : p.theme.colors.fog)};
border: 1px solid
${p =>
p.error ? p.theme.colors.pearl : p.isFocus ? p.theme.colors.wallet : p.theme.colors.fog};
box-shadow: ${p => (p.isFocus ? `rgba(0, 0, 0, 0.05) 0 2px 2px` : 'none')};
height: ${p => (p.small ? '34' : '40')}px;
position: relative;
`
const ErrorDisplay = styled(Box)`
position: absolute;
bottom: -20px;
left: 0px;
font-size: 12px;
color: ${p => p.theme.colors.pearl};
`
const Base = styled.input.attrs({
@ -74,6 +85,7 @@ type Props = {
renderLeft?: any,
renderRight?: any,
containerProps?: Object,
error?: string | boolean,
small?: boolean,
}
@ -145,7 +157,7 @@ class Input extends PureComponent<Props, State> {
render() {
const { isFocus } = this.state
const { renderLeft, renderRight, containerProps, small } = this.props
const { renderLeft, renderRight, containerProps, small, error } = this.props
return (
<Container
@ -154,6 +166,7 @@ class Input extends PureComponent<Props, State> {
shrink
{...containerProps}
small={small}
error={error}
>
{renderLeft}
<Box px={3} grow shrink>
@ -166,6 +179,7 @@ class Input extends PureComponent<Props, State> {
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
/>
{error && typeof error === 'string' ? <ErrorDisplay>{error}</ErrorDisplay> : null}
</Box>
{renderRight}
</Container>

4
src/components/modals/Receive/03-step-confirm-address.js

@ -44,7 +44,7 @@ export default (props: Props) => (
<Fragment>
<Title>{props.t('receive:steps.confirmAddress.error.title')}</Title>
<Text mb={5}>{props.t('receive:steps.confirmAddress.error.text')}</Text>
<DeviceConfirm notValid />
<DeviceConfirm error />
</Fragment>
) : (
<Fragment>
@ -58,7 +58,7 @@ export default (props: Props) => (
account={props.account}
device={props.device}
onCheck={props.onCheck}
render={({ isVerified }) => <DeviceConfirm notValid={isVerified === false} />}
render={({ isVerified }) => <DeviceConfirm error={isVerified === false} />}
/>
</Box>
)}

54
src/components/modals/Send/01-step-amount.js

@ -4,57 +4,9 @@ import type { Account } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common'
import type { WalletBridge } from 'bridge/types'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import LabelInfoTooltip from 'components/base/LabelInfoTooltip'
import RecipientAddress from 'components/RecipientAddress'
import RequestAmount from 'components/RequestAmount'
import SelectAccount from 'components/SelectAccount'
const AccountField = ({ onChange, value, t }: *) => (
<Box flow={1}>
<Label>{t('send:steps.amount.selectAccountDebit')}</Label>
<SelectAccount onChange={onChange} value={value} />
</Box>
)
// TODO we should use isRecipientValid & provide a feedback to user
const RecipientField = ({ bridge, account, transaction, onChangeTransaction, t }: *) => (
<Box flow={1}>
<Label>
<span>{t('send:steps.amount.recipientAddress')}</span>
<LabelInfoTooltip ml={1} text={t('send:steps.amount.recipientAddress')} />
</Label>
<RecipientAddress
withQrCode
value={bridge.getTransactionRecipient(account, transaction)}
onChange={(recipient, maybeExtra) => {
const { amount, currency } = maybeExtra || {}
if (currency && currency.scheme !== account.currency.scheme) return false
let t = transaction
if (amount) {
t = bridge.editTransactionAmount(account, t, amount)
}
t = bridge.editTransactionRecipient(account, t, recipient)
onChangeTransaction(t)
return true
}}
/>
</Box>
)
const AmountField = ({ bridge, account, transaction, onChangeTransaction, t }: *) => (
<Box flow={1}>
<Label>{t('send:steps.amount.amount')}</Label>
<RequestAmount
withMax={false}
account={account}
onChange={amount =>
onChangeTransaction(bridge.editTransactionAmount(account, transaction, amount))
}
value={bridge.getTransactionAmount(account, transaction)}
/>
</Box>
)
import AccountField from './AccountField'
import RecipientField from './RecipientField'
import AmountField from './AmountField'
type PropsStepAmount<Transaction> = {
t: T,

22
src/components/modals/Send/03-step-verification.js

@ -35,10 +35,21 @@ type Props = {
bridge: ?WalletBridge<*>,
transaction: *,
onOperationBroadcasted: (op: Operation) => void,
onError: (e: Error) => void,
hasError: boolean,
t: T,
}
export default ({ account, device, bridge, transaction, onOperationBroadcasted, t }: Props) => (
export default ({
account,
device,
bridge,
transaction,
onOperationBroadcasted,
t,
onError,
hasError,
}: Props) => (
<Container>
<WarnBox>{multiline(t('send:steps.verification.warning'))}</WarnBox>
<Info>{t('send:steps.verification.body')}</Info>
@ -52,11 +63,10 @@ export default ({ account, device, bridge, transaction, onOperationBroadcasted,
transaction={transaction}
bridge={bridge}
onOperationBroadcasted={onOperationBroadcasted}
render={({ error }) => (
// FIXME we really really REALLY should use error for the display. otherwise we are completely blind on error cases..
<DeviceConfirm notValid={!!error} />
)}
/>
onError={onError}
>
<DeviceConfirm error={hasError} />
</DeviceSignTransaction>
)}
</Container>
)

8
src/components/modals/Send/04-step-confirmation.js

@ -8,6 +8,7 @@ import IconExclamationCircleThin from 'icons/ExclamationCircleThin'
import Box from 'components/base/Box'
import { multiline } from 'styles/helpers'
import { colors } from 'styles/theme'
import { formatError } from 'helpers/errors'
import type { T } from 'types/common'
@ -39,10 +40,11 @@ const Text = styled(Box).attrs({
type Props = {
optimisticOperation: ?Operation,
t: T,
error: ?Error,
}
function StepConfirmation(props: Props) {
const { t, optimisticOperation } = props
const { t, optimisticOperation, error } = props
const Icon = optimisticOperation ? IconCheckCircle : IconExclamationCircleThin
const iconColor = optimisticOperation ? colors.positiveGreen : colors.alertRed
const tPrefix = optimisticOperation
@ -55,7 +57,9 @@ function StepConfirmation(props: Props) {
<Icon size={43} />
</span>
<Title>{t(`${tPrefix}.title`)}</Title>
<Text>{multiline(t(`${tPrefix}.text`))}</Text>
<Text style={{ userSelect: 'text' }}>
{optimisticOperation ? multiline(t(`${tPrefix}.text`)) : error ? formatError(error) : null}
</Text>
<Text style={{ userSelect: 'text' }}>
{optimisticOperation ? optimisticOperation.hash : ''}
</Text>

14
src/components/modals/Send/AccountField.js

@ -0,0 +1,14 @@
// @flow
import React from 'react'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import SelectAccount from 'components/SelectAccount'
const AccountField = ({ onChange, value, t }: *) => (
<Box flow={1}>
<Label>{t('send:steps.amount.selectAccountDebit')}</Label>
<SelectAccount onChange={onChange} value={value} />
</Box>
)
export default AccountField

53
src/components/modals/Send/AmountField.js

@ -0,0 +1,53 @@
// @flow
import React, { Component } from 'react'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import RequestAmount from 'components/RequestAmount'
class AmountField extends Component<*, { canBeSpent: boolean }> {
state = {
canBeSpent: true,
}
componentDidMount() {
this.resync()
}
componentDidUpdate(nextProps: *) {
if (
nextProps.account !== this.props.account ||
nextProps.transaction !== this.props.transaction
) {
this.resync()
}
}
componentWillUnmount() {
this.unmount = true
}
unmount = false
async resync() {
const { account, bridge, transaction } = this.props
const canBeSpent = await bridge.canBeSpent(account, transaction)
if (this.unmount) return
this.setState({ canBeSpent })
}
render() {
const { bridge, account, transaction, onChangeTransaction, t } = this.props
const { canBeSpent } = this.state
return (
<Box flow={1}>
<Label>{t('send:steps.amount.amount')}</Label>
<RequestAmount
withMax={false}
account={account}
canBeSpent={canBeSpent}
onChange={amount =>
onChangeTransaction(bridge.editTransactionAmount(account, transaction, amount))
}
value={bridge.getTransactionAmount(account, transaction)}
/>
</Box>
)
}
}
export default AmountField

7
src/components/modals/Send/Footer.js

@ -54,7 +54,7 @@ class Footer extends PureComponent<
this.resync()
}
}
async componentWillUnmount() {
componentWillUnmount() {
this.unmount = true
}
unmount = false
@ -77,7 +77,7 @@ class Footer extends PureComponent<
<Box horizontal flow={2} align="center">
<FormattedVal
disableRounding
color={!canBeSpent ? 'pearl' : 'dark'}
color="dark"
val={totalSpent}
unit={account.unit}
showCode
@ -94,6 +94,7 @@ class Footer extends PureComponent<
color="grey"
fontSize={3}
showCode
alwaysShowSign={false}
/>
<Text ff="Rubik" fontSize={3}>
{')'}
@ -102,7 +103,7 @@ class Footer extends PureComponent<
</Box>
</Box>
)}
<Button primary onClick={onNext} disabled={!canNext}>
<Button primary onClick={onNext} disabled={!canNext || !canBeSpent}>
{'Next'}
</Button>
</Box>

79
src/components/modals/Send/RecipientField.js

@ -0,0 +1,79 @@
// @flow
import React, { Component } from 'react'
import type { Account } from '@ledgerhq/live-common/lib/types'
import type { T } from 'types/common'
import type { WalletBridge } from 'bridge/types'
import Box from 'components/base/Box'
import Label from 'components/base/Label'
import LabelInfoTooltip from 'components/base/LabelInfoTooltip'
import RecipientAddress from 'components/RecipientAddress'
type Props<Transaction> = {
t: T,
account: Account,
bridge: WalletBridge<Transaction>,
transaction: Transaction,
onChangeTransaction: Transaction => void,
}
class RecipientField<Transaction> extends Component<Props<Transaction>, { isValid: boolean }> {
state = {
isValid: true,
}
componentDidMount() {
this.resync()
}
componentDidUpdate(nextProps: Props<Transaction>) {
if (
nextProps.account !== this.props.account ||
nextProps.transaction !== this.props.transaction
) {
this.resync()
}
}
componentWillUnmount() {
this.unmount = true
}
unmount = false
async resync() {
const { account, bridge, transaction } = this.props
const isValid = await bridge.isRecipientValid(
account.currency,
bridge.getTransactionRecipient(account, transaction),
)
if (this.unmount) return
this.setState({ isValid })
}
render() {
const { bridge, account, transaction, onChangeTransaction, t } = this.props
const { isValid } = this.state
const value = bridge.getTransactionRecipient(account, transaction)
return (
<Box flow={1}>
<Label>
<span>{t('send:steps.amount.recipientAddress')}</span>
<LabelInfoTooltip ml={1} text={t('send:steps.amount.recipientAddress')} />
</Label>
<RecipientAddress
withQrCode
error={!value || isValid ? null : `This is not a valid ${account.currency.name} address`}
value={value}
onChange={(recipient, maybeExtra) => {
const { amount, currency } = maybeExtra || {}
if (currency && currency.scheme !== account.currency.scheme) return false
let t = transaction
if (amount) {
t = bridge.editTransactionAmount(account, t, amount)
}
t = bridge.editTransactionRecipient(account, t, recipient)
onChangeTransaction(t)
return true
}}
/>
</Box>
)
}
}
export default RecipientField

22
src/components/modals/Send/SendModalBody.js

@ -43,6 +43,7 @@ type State<T> = {
appStatus: ?string,
deviceSelected: ?Device,
optimisticOperation: ?Operation,
error: ?Error,
}
type Step = {
@ -74,6 +75,7 @@ class SendModalBody extends PureComponent<Props, State<*>> {
account,
bridge,
transaction,
error: null,
}
this.steps = [
@ -97,7 +99,7 @@ class SendModalBody extends PureComponent<Props, State<*>> {
},
{
label: t('send:steps.confirmation.title'),
prevStep: 1,
prevStep: 0,
},
]
}
@ -142,9 +144,20 @@ class SendModalBody extends PureComponent<Props, State<*>> {
this.setState({
optimisticOperation,
stepIndex: stepIndex + 1,
error: null,
})
}
onOperationError = (error: Error) => {
// $FlowFixMe
if (error.statusCode === 0x6985) {
// User denied on device
this.setState({ error })
} else {
this.setState({ error, stepIndex: 3 })
}
}
onChangeAccount = account => {
const bridge = getBridgeForCurrency(account.currency)
this.setState({
@ -159,7 +172,7 @@ class SendModalBody extends PureComponent<Props, State<*>> {
}
onGoToFirstStep = () => {
this.setState({ stepIndex: 0 })
this.setState({ stepIndex: 0, error: null })
}
steps: Step[]
@ -173,6 +186,7 @@ class SendModalBody extends PureComponent<Props, State<*>> {
bridge,
optimisticOperation,
deviceSelected,
error,
} = this.state
const step = this.steps[stepIndex]
@ -216,9 +230,11 @@ class SendModalBody extends PureComponent<Props, State<*>> {
transaction={transaction}
device={deviceSelected}
onOperationBroadcasted={this.onOperationBroadcasted}
onError={this.onOperationError}
hasError={!!error}
/>
<StepConfirmation t={t} optimisticOperation={optimisticOperation} />
<StepConfirmation t={t} optimisticOperation={optimisticOperation} error={error} />
</ChildSwitch>
</ModalContent>

3
src/helpers/errors.js

@ -0,0 +1,3 @@
// @flow
export const formatError = (e: Error) => e.message

7
src/helpers/promise.js

@ -20,8 +20,9 @@ export function retry<A>(f: () => Promise<A>, options?: $Shape<typeof defaults>)
return result
}
// In case of failure, wait the interval, retry the action
return result.catch(() =>
delay(interval).then(() => rec(remainingTry - 1, interval * intervalMultiplicator)),
)
return result.catch(e => {
console.warn('Promise#retry', e)
return delay(interval).then(() => rec(remainingTry - 1, interval * intervalMultiplicator))
})
}
}

1
src/internals/index.js

@ -39,6 +39,7 @@ process.on('message', m => {
type: 'ERROR',
requestId,
data: {
...error,
name: error && error.name,
message: error && error.message,
},

10
static/i18n/en/send.yml

@ -23,12 +23,10 @@ steps:
confirmation:
title: Confirmation
success:
title: Transaction successfully completed
text: You may have to wait few confirmations unitl the transaction appear
title: Transaction successfully broadcasted
text: |
with the following transaction id:
cta: View operation details
error:
title: Transaction aborted
text: |
The transaction have been aborted on your device.
You can try again the operation.
title: Transaction error
cta: Retry operation

Loading…
Cancel
Save