diff --git a/.prettierignore b/.prettierignore index 2c4bd388..f8564104 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ package.json -test-e2e/sync/data +test-e2e/**/*.json diff --git a/src/bridge/EthereumJSBridge.js b/src/bridge/EthereumJSBridge.js index 2a48e6cf..69be1675 100644 --- a/src/bridge/EthereumJSBridge.js +++ b/src/bridge/EthereumJSBridge.js @@ -16,20 +16,20 @@ import { getDerivations } from 'helpers/derivations' import getAddressCommand from 'commands/getAddress' import signTransactionCommand from 'commands/signTransaction' import { getAccountPlaceholderName, getNewAccountPlaceholderName } from 'helpers/accountName' -import { NotEnoughBalance, ETHAddressNonEIP } from 'config/errors' +import { NotEnoughBalance, FeeNotLoaded, ETHAddressNonEIP } from 'config/errors' import type { EditProps, WalletBridge } from './types' type Transaction = { recipient: string, amount: BigNumber, - gasPrice: BigNumber, + gasPrice: ?BigNumber, gasLimit: BigNumber, } const serializeTransaction = t => ({ recipient: t.recipient, amount: `0x${BigNumber(t.amount).toString(16)}`, - gasPrice: `0x${BigNumber(t.gasPrice).toString(16)}`, + gasPrice: !t.gasPrice ? '0x00' : `0x${BigNumber(t.gasPrice).toString(16)}`, gasLimit: `0x${BigNumber(t.gasLimit).toString(16)}`, }) @@ -140,6 +140,8 @@ const signAndBroadcast = async ({ onSigned, onOperationBroadcasted, }) => { + const { gasPrice, amount, gasLimit } = t + if (!gasPrice) throw new FeeNotLoaded() const api = apiForCurrency(a.currency) const nonce = await api.getAccountNonce(a.freshAddress) @@ -162,8 +164,8 @@ const signAndBroadcast = async ({ id: `${a.id}-${hash}-OUT`, hash, type: 'OUT', - value: t.amount, - fee: t.gasPrice.times(t.gasLimit), + value: amount, + fee: gasPrice.times(gasLimit), blockHeight: null, blockHash: null, accountId: a.id, @@ -402,7 +404,7 @@ const EthereumBridge: WalletBridge = { createTransaction: () => ({ amount: BigNumber(0), recipient: '', - gasPrice: BigNumber(0), + gasPrice: null, gasLimit: BigNumber(0x5208), }), @@ -425,16 +427,22 @@ const EthereumBridge: WalletBridge = { EditAdvancedOptions, checkValidTransaction: (a, t) => - t.amount.isLessThanOrEqualTo(a.balance) - ? Promise.resolve(true) - : Promise.reject(new NotEnoughBalance()), + !t.gasPrice + ? Promise.reject(new FeeNotLoaded()) + : t.amount.isLessThanOrEqualTo(a.balance) + ? Promise.resolve(true) + : Promise.reject(new NotEnoughBalance()), getTotalSpent: (a, t) => - t.amount.isGreaterThan(0) && t.gasPrice.isGreaterThan(0) && t.gasLimit.isGreaterThan(0) + t.amount.isGreaterThan(0) && + t.gasPrice && + t.gasPrice.isGreaterThan(0) && + t.gasLimit.isGreaterThan(0) ? Promise.resolve(t.amount.plus(t.gasPrice.times(t.gasLimit))) : Promise.resolve(BigNumber(0)), - getMaxAmount: (a, t) => Promise.resolve(a.balance.minus(t.gasPrice.times(t.gasLimit))), + getMaxAmount: (a, t) => + Promise.resolve(a.balance.minus((t.gasPrice || BigNumber(0)).times(t.gasLimit))), signAndBroadcast: (a, t, deviceId) => Observable.create(o => { diff --git a/src/bridge/LibcoreBridge.js b/src/bridge/LibcoreBridge.js index 355232e6..fa86c0eb 100644 --- a/src/bridge/LibcoreBridge.js +++ b/src/bridge/LibcoreBridge.js @@ -11,7 +11,7 @@ import libcoreSyncAccount from 'commands/libcoreSyncAccount' import libcoreSignAndBroadcast from 'commands/libcoreSignAndBroadcast' import libcoreGetFees, { extractGetFeesInputFromAccount } from 'commands/libcoreGetFees' import libcoreValidAddress from 'commands/libcoreValidAddress' -import { NotEnoughBalance } from 'config/errors' +import { NotEnoughBalance, FeeNotLoaded } from 'config/errors' import type { WalletBridge, EditProps } from './types' const NOT_ENOUGH_FUNDS = 52 @@ -20,15 +20,18 @@ const notImplemented = new Error('LibcoreBridge: not implemented') type Transaction = { amount: BigNumber, - feePerByte: BigNumber, + feePerByte: ?BigNumber, recipient: string, } -const serializeTransaction = t => ({ - recipient: t.recipient, - amount: t.amount.toString(), - feePerByte: t.feePerByte.toString(), -}) +const serializeTransaction = t => { + const { feePerByte } = t + return { + recipient: t.recipient, + amount: t.amount.toString(), + feePerByte: (feePerByte && feePerByte.toString()) || '0', + } +} const decodeOperation = (encodedAccount, rawOp) => decodeAccount({ ...encodedAccount, operations: [rawOp] }).operations[0] @@ -75,7 +78,9 @@ const isRecipientValid = (currency, recipient) => { const feesLRU = LRU({ max: 100 }) const getFeesKey = (a, t) => - `${a.id}_${a.blockHeight || 0}_${t.amount.toString()}_${t.recipient}_${t.feePerByte.toString()}` + `${a.id}_${a.blockHeight || 0}_${t.amount.toString()}_${t.recipient}_${ + t.feePerByte ? t.feePerByte.toString() : '' + }` const getFees = async (a, transaction) => { const isValid = await isRecipientValid(a.currency, transaction.recipient) @@ -83,6 +88,7 @@ const getFees = async (a, transaction) => { const key = getFeesKey(a, transaction) let promise = feesLRU.get(key) if (promise) return promise + promise = libcoreGetFees .send({ ...extractGetFeesInputFromAccount(a), @@ -95,17 +101,19 @@ const getFees = async (a, transaction) => { } const checkValidTransaction = (a, t) => - !t.amount - ? Promise.resolve(true) - : getFees(a, t) - .then(() => true) - .catch(e => { - if (e.code === NOT_ENOUGH_FUNDS) { - throw new NotEnoughBalance() - } - feesLRU.del(getFeesKey(a, t)) - throw e - }) + !t.feePerByte + ? Promise.reject(new FeeNotLoaded()) + : !t.amount + ? Promise.resolve(true) + : getFees(a, t) + .then(() => true) + .catch(e => { + if (e.code === NOT_ENOUGH_FUNDS) { + throw new NotEnoughBalance() + } + feesLRU.del(getFeesKey(a, t)) + throw e + }) const LibcoreBridge: WalletBridge = { scanAccountsOnDevice(currency, devicePath) { @@ -169,7 +177,7 @@ const LibcoreBridge: WalletBridge = { createTransaction: () => ({ amount: BigNumber(0), recipient: '', - feePerByte: BigNumber(0), + feePerByte: null, isRBF: false, }), diff --git a/src/bridge/RippleJSBridge.js b/src/bridge/RippleJSBridge.js index 952fdaf9..94337482 100644 --- a/src/bridge/RippleJSBridge.js +++ b/src/bridge/RippleJSBridge.js @@ -20,13 +20,17 @@ import { import FeesRippleKind from 'components/FeesField/RippleKind' import AdvancedOptionsRippleKind from 'components/AdvancedOptions/RippleKind' import { getAccountPlaceholderName, getNewAccountPlaceholderName } from 'helpers/accountName' -import { NotEnoughBalance } from 'config/errors' +import { + NotEnoughBalance, + FeeNotLoaded, + NotEnoughBalanceBecauseDestinationNotCreated, +} from 'config/errors' import type { WalletBridge, EditProps } from './types' type Transaction = { amount: BigNumber, recipient: string, - fee: BigNumber, + fee: ?BigNumber, tag: ?number, } @@ -51,6 +55,8 @@ const EditAdvancedOptions = ({ onChange, value }: EditProps) => ( async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }) { const api = apiForEndpointConfig(a.endpointConfig) + const { fee } = t + if (!fee) throw new FeeNotLoaded() try { await api.connect() const amount = formatAPICurrencyXRP(t.amount) @@ -66,7 +72,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera }, } const instruction = { - fee: formatAPICurrencyXRP(t.fee).value, + fee: formatAPICurrencyXRP(fee).value, maxLedgerVersionOffset: 12, } @@ -97,7 +103,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera accountId: a.id, type: 'OUT', value: t.amount, - fee: t.fee, + fee, blockHash: null, blockHeight: null, senders: [a.freshAddress], @@ -114,7 +120,7 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera } } -function isRecipientValid(currency, recipient) { +function isRecipientValid(recipient) { try { bs58check.decode(recipient) return true @@ -241,6 +247,31 @@ const getServerInfo = (map => endpointConfig => { return f() })({}) +const recipientIsNew = async (endpointConfig, recipient) => { + if (!isRecipientValid(recipient)) return false + const api = apiForEndpointConfig(endpointConfig) + try { + await api.connect() + try { + await api.getAccountInfo(recipient) + return false + } catch (e) { + if (e.message !== 'actNotFound') { + throw e + } + return true + } + } finally { + api.disconnect() + } +} + +const cacheRecipientsNew = {} +const cachedRecipientIsNew = (endpointConfig, recipient) => { + if (recipient in cacheRecipientsNew) return cacheRecipientsNew[recipient] + return (cacheRecipientsNew[recipient] = recipientIsNew(endpointConfig, recipient)) +} + const RippleJSBridge: WalletBridge = { scanAccountsOnDevice: (currency, deviceId) => Observable.create(o => { @@ -446,13 +477,13 @@ const RippleJSBridge: WalletBridge = { pullMoreOperations: () => Promise.resolve(a => a), // FIXME not implemented - isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)), + isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(recipient)), getRecipientWarning: () => Promise.resolve(null), createTransaction: () => ({ amount: BigNumber(0), recipient: '', - fee: BigNumber(0), + fee: null, tag: undefined, }), @@ -495,11 +526,23 @@ const RippleJSBridge: WalletBridge = { getTransactionRecipient: (a, t) => t.recipient, checkValidTransaction: async (a, t) => { + if (!t.fee) throw new FeeNotLoaded() const r = await getServerInfo(a.endpointConfig) + const reserveBaseXRP = parseAPIValue(r.validatedLedger.reserveBaseXRP) + if (t.recipient) { + if (await cachedRecipientIsNew(a.endpointConfig, t.recipient)) { + if (t.amount.lt(reserveBaseXRP)) { + const f = formatAPICurrencyXRP(reserveBaseXRP) + throw new NotEnoughBalanceBecauseDestinationNotCreated('', { + minimalAmount: `${f.currency} ${f.value}`, + }) + } + } + } if ( t.amount - .plus(t.fee) - .plus(parseAPIValue(r.validatedLedger.reserveBaseXRP)) + .plus(t.fee || 0) + .plus(reserveBaseXRP) .isLessThanOrEqualTo(a.balance) ) { return true @@ -507,12 +550,13 @@ const RippleJSBridge: WalletBridge = { throw new NotEnoughBalance() }, - getTotalSpent: (a, t) => Promise.resolve(t.amount.plus(t.fee)), + getTotalSpent: (a, t) => Promise.resolve(t.amount.plus(t.fee || 0)), - getMaxAmount: (a, t) => Promise.resolve(a.balance.minus(t.fee)), + getMaxAmount: (a, t) => Promise.resolve(a.balance.minus(t.fee || 0)), signAndBroadcast: (a, t, deviceId) => Observable.create(o => { + delete cacheRecipientsNew[t.recipient] let cancelled = false const isCancelled = () => cancelled const onSigned = () => { diff --git a/src/components/FeesField/BitcoinKind.js b/src/components/FeesField/BitcoinKind.js index d2ce95c0..7e5f542c 100644 --- a/src/components/FeesField/BitcoinKind.js +++ b/src/components/FeesField/BitcoinKind.js @@ -8,6 +8,7 @@ import { translate } from 'react-i18next' import type { T } from 'types/common' +import { FeeNotLoaded } from 'config/errors' import InputCurrency from 'components/base/InputCurrency' import Select from 'components/base/Select' import type { Fees } from 'api/Fees' @@ -17,7 +18,7 @@ import Box from '../base/Box' type Props = { account: Account, - feePerByte: BigNumber, + feePerByte: ?BigNumber, onChange: BigNumber => void, t: T, } @@ -50,6 +51,12 @@ const customItem = { blockCount: 0, feePerByte: BigNumber(0), } +const notLoadedItem = { + label: 'Standard', + value: 'standard', + blockCount: 0, + feePerByte: BigNumber(0), +} type State = { isFocused: boolean, items: FeeItem[], selectedItem: FeeItem } @@ -57,13 +64,13 @@ type OwnProps = Props & { fees?: Fees, error?: Error } class FeesField extends Component { state = { - items: [customItem], - selectedItem: customItem, + items: [notLoadedItem], + selectedItem: notLoadedItem, isFocused: false, } static getDerivedStateFromProps(nextProps, prevState) { - const { fees, feePerByte } = nextProps + const { fees, feePerByte, error } = nextProps let items: FeeItem[] = [] if (fees) { for (const key of Object.keys(fees)) { @@ -80,17 +87,18 @@ class FeesField extends Component { } items = items.sort((a, b) => a.blockCount - b.blockCount) } - items.push(customItem) - const selectedItem = prevState.selectedItem.feePerByte.eq(feePerByte) - ? prevState.selectedItem - : items.find(f => f.feePerByte.eq(feePerByte)) || items[items.length - 1] + items.push(!feePerByte && !error ? notLoadedItem : customItem) + const selectedItem = + !feePerByte && prevState.selectedItem.feePerByte.eq(feePerByte) + ? prevState.selectedItem + : items.find(f => f.feePerByte.eq(feePerByte)) || items[items.length - 1] return { items, selectedItem } } componentDidUpdate({ fees: prevFees }: OwnProps) { const { feePerByte, fees, onChange } = this.props const { items, isFocused } = this.state - if (fees && fees !== prevFees && feePerByte.isZero() && !isFocused) { + if (fees && fees !== prevFees && !feePerByte && !isFocused) { // initialize with the median const feePerByte = (items.find(item => item.blockCount === defaultBlockCount) || items[0]) .feePerByte @@ -127,7 +135,7 @@ class FeesField extends Component { const satoshi = units[units.length - 1] return ( - +