You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

501 lines
15 KiB

// @flow
import { Observable } from 'rxjs'
import { BigNumber } from 'bignumber.js'
import logger from 'logger'
import React from 'react'
import FeesField from 'components/FeesField/EthereumKind'
import AdvancedOptions from 'components/AdvancedOptions/EthereumKind'
import throttle from 'lodash/throttle'
import flatMap from 'lodash/flatMap'
import uniqBy from 'lodash/uniqBy'
import {
getDerivationModesForCurrency,
getDerivationScheme,
runDerivationScheme,
isIterableDerivationMode,
getMandatoryEmptyAccountSkip,
} from '@ledgerhq/live-common/lib/derivation'
import {
getAccountPlaceholderName,
getNewAccountPlaceholderName,
} from '@ledgerhq/live-common/lib/account'
import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
import eip55 from 'eip55'
import { apiForCurrency } from 'api/Ethereum'
import type { Tx } from 'api/Ethereum'
import getAddressCommand from 'commands/getAddress'
import signTransactionCommand from 'commands/signTransaction'
import { NotEnoughBalance, FeeNotLoaded, ETHAddressNonEIP } from 'config/errors'
import type { EditProps, WalletBridge } from './types'
type Transaction = {
recipient: string,
amount: BigNumber,
gasPrice: ?BigNumber,
gasLimit: BigNumber,
}
const serializeTransaction = t => ({
recipient: t.recipient,
amount: `0x${BigNumber(t.amount).toString(16)}`,
gasPrice: !t.gasPrice ? '0x00' : `0x${BigNumber(t.gasPrice).toString(16)}`,
gasLimit: `0x${BigNumber(t.gasLimit).toString(16)}`,
})
const EditFees = ({ account, onChange, value }: EditProps<Transaction>) => (
<FeesField
onChange={gasPrice => {
onChange({ ...value, gasPrice })
}}
gasPrice={value.gasPrice}
account={account}
/>
)
const EditAdvancedOptions = ({ onChange, value }: EditProps<Transaction>) => (
<AdvancedOptions
gasLimit={value.gasLimit}
onChangeGasLimit={gasLimit => {
onChange({ ...value, gasLimit })
}}
/>
)
// in case of a SELF send, 2 ops are returned.
const txToOps = (account: Account) => (tx: Tx): Operation[] => {
const freshAddress = account.freshAddress.toLowerCase()
const from = tx.from.toLowerCase()
const to = tx.to.toLowerCase()
const sending = freshAddress === from
const receiving = freshAddress === to
const ops = []
// FIXME problem with our api, precision lost here...
const value = BigNumber(tx.value)
const fee = BigNumber(tx.gas_price * tx.gas_used)
if (sending) {
const op: $Exact<Operation> = {
id: `${account.id}-${tx.hash}-OUT`,
hash: tx.hash,
type: 'OUT',
value: value.plus(fee),
fee,
blockHeight: tx.block && tx.block.height,
blockHash: tx.block && tx.block.hash,
accountId: account.id,
senders: [tx.from],
recipients: [tx.to],
date: new Date(tx.received_at),
extra: {},
}
ops.push(op)
}
if (receiving) {
const op: $Exact<Operation> = {
id: `${account.id}-${tx.hash}-IN`,
hash: tx.hash,
type: 'IN',
value,
fee,
blockHeight: tx.block && tx.block.height,
blockHash: tx.block && tx.block.hash,
accountId: account.id,
senders: [tx.from],
recipients: [tx.to],
date: new Date(new Date(tx.received_at) + 1), // hack: make the IN appear after the OUT in history.
extra: {},
}
ops.push(op)
}
return ops
}
function isRecipientValid(currency, recipient) {
if (!recipient.match(/^0x[0-9a-fA-F]{40}$/)) return false
// To handle non-eip55 addresses we stop validation here if we detect
// address is either full upper or full lower.
// see https://github.com/LedgerHQ/ledger-live-desktop/issues/1397
const slice = recipient.substr(2)
const isFullUpper = slice === slice.toUpperCase()
const isFullLower = slice === slice.toLowerCase()
if (isFullUpper || isFullLower) return true
try {
return eip55.verify(recipient)
} catch (error) {
return false
}
}
// Returns a warning if we detect a non-eip address
function getRecipientWarning(currency, recipient) {
if (!recipient.match(/^0x[0-9a-fA-F]{40}$/)) return null
const slice = recipient.substr(2)
const isFullUpper = slice === slice.toUpperCase()
const isFullLower = slice === slice.toLowerCase()
if (isFullUpper || isFullLower) {
return new ETHAddressNonEIP()
}
return null
}
function mergeOps(existing: Operation[], newFetched: Operation[]) {
const ids = newFetched.map(o => o.id)
const all = newFetched.concat(existing.filter(o => !ids.includes(o.id)))
return uniqBy(all.sort((a, b) => b.date - a.date), 'id')
}
const signAndBroadcast = async ({
a,
t,
deviceId,
isCancelled,
onSigned,
onOperationBroadcasted,
}) => {
const { gasPrice, amount, gasLimit } = t
if (!gasPrice) throw new FeeNotLoaded()
const api = apiForCurrency(a.currency)
const nonce = await api.getAccountNonce(a.freshAddress)
const transaction = await signTransactionCommand
.send({
currencyId: a.currency.id,
devicePath: deviceId,
path: a.freshAddressPath,
transaction: { ...serializeTransaction(t), nonce },
})
.toPromise()
if (!isCancelled()) {
onSigned()
const hash = await api.broadcastTransaction(transaction)
const op: $Exact<Operation> = {
id: `${a.id}-${hash}-OUT`,
hash,
type: 'OUT',
value: amount,
fee: gasPrice.times(gasLimit),
blockHeight: null,
blockHash: null,
accountId: a.id,
senders: [a.freshAddress],
recipients: [t.recipient],
transactionSequenceNumber: nonce,
date: new Date(),
extra: {},
}
onOperationBroadcasted(op)
}
}
const SAFE_REORG_THRESHOLD = 80
const fetchCurrentBlock = (perCurrencyId => currency => {
if (perCurrencyId[currency.id]) return perCurrencyId[currency.id]()
const api = apiForCurrency(currency)
const f = throttle(
() =>
api.getCurrentBlock().catch(e => {
f.cancel()
throw e
}),
5000,
)
perCurrencyId[currency.id] = f
return f()
})({})
const EthereumBridge: WalletBridge<Transaction> = {
scanAccountsOnDevice: (currency, deviceId) =>
Observable.create(o => {
let finished = false
const unsubscribe = () => {
finished = true
}
const api = apiForCurrency(currency)
// in future ideally what we want is:
// return mergeMap(addressesObservable, address => fetchAccount(address))
let newAccountCount = 0
async function stepAddress(
index,
{ address, path: freshAddressPath },
derivationMode,
shouldSkipEmpty,
): { account?: Account, complete?: boolean } {
const balance = await api.getAccountBalance(address)
if (finished) return { complete: true }
const currentBlock = await fetchCurrentBlock(currency)
if (finished) return { complete: true }
let { txs } = await api.getTransactions(address)
if (finished) return { complete: true }
const freshAddress = address
const accountId = `ethereumjs:2:${currency.id}:${address}:${derivationMode}`
if (txs.length === 0 && balance.isZero()) {
// this is an empty account
if (derivationMode === '') {
// is standard derivation
if (newAccountCount === 0) {
// first zero account will emit one account as opportunity to create a new account..
const account: $Exact<Account> = {
id: accountId,
seedIdentifier: freshAddress,
freshAddress,
freshAddressPath,
derivationMode,
name: getNewAccountPlaceholderName({ currency, index, derivationMode }),
balance,
blockHeight: currentBlock.height,
index,
currency,
operations: [],
pendingOperations: [],
unit: currency.units[0],
lastSyncDate: new Date(),
}
return { account, complete: true }
}
newAccountCount++
}
if (shouldSkipEmpty) {
return {}
}
// NB for legacy addresses maybe we will continue at least for the first 10 addresses
return { complete: true }
}
const account: $Exact<Account> = {
id: accountId,
seedIdentifier: freshAddress,
freshAddress,
freshAddressPath,
derivationMode,
name: getAccountPlaceholderName({ currency, index, derivationMode }),
balance,
blockHeight: currentBlock.height,
index,
currency,
operations: [],
pendingOperations: [],
unit: currency.units[0],
lastSyncDate: new Date(),
}
for (let i = 0; i < 50; i++) {
const api = apiForCurrency(account.currency)
const last = txs[txs.length - 1]
if (!last) break
const { block } = last
if (!block) break
const next = await api.getTransactions(account.freshAddress, block.hash)
if (next.txs.length === 0) break
txs = txs.concat(next.txs)
}
txs.reverse()
account.operations = mergeOps([], flatMap(txs, txToOps(account)))
return { account }
}
async function main() {
try {
const derivationModes = getDerivationModesForCurrency(currency)
for (const derivationMode of derivationModes) {
let emptyCount = 0
const mandatoryEmptyAccountSkip = getMandatoryEmptyAccountSkip(derivationMode)
const derivationScheme = getDerivationScheme({ derivationMode, currency })
const stopAt = isIterableDerivationMode(derivationMode) ? 255 : 1
for (let index = 0; index < stopAt; index++) {
const freshAddressPath = runDerivationScheme(derivationScheme, currency, {
account: index,
})
const res = await getAddressCommand
.send({ currencyId: currency.id, devicePath: deviceId, path: freshAddressPath })
.toPromise()
const r = await stepAddress(
index,
res,
derivationMode,
emptyCount < mandatoryEmptyAccountSkip,
)
logger.log(
`scanning ${currency.id} at ${freshAddressPath}: ${res.address} resulted of ${
r.account ? `Account with ${r.account.operations.length} txs` : 'no account'
}. ${r.complete ? 'ALL SCANNED' : ''}`,
)
if (r.account) {
o.next(r.account)
} else {
emptyCount++
}
if (r.complete) {
break
}
}
}
o.complete()
} catch (e) {
o.error(e)
}
}
main()
return unsubscribe
}),
synchronize: ({ freshAddress, blockHeight, currency, operations }) =>
Observable.create(o => {
let unsubscribed = false
const api = apiForCurrency(currency)
async function main() {
try {
const block = await fetchCurrentBlock(currency)
if (unsubscribed) return
if (block.height === blockHeight) {
o.complete()
} else {
const filterConfirmedOperations = o =>
o.blockHeight && blockHeight - o.blockHeight > SAFE_REORG_THRESHOLD
operations = operations.filter(filterConfirmedOperations)
const blockHash = operations.length > 0 ? operations[0].blockHash : undefined
const { txs } = await api.getTransactions(freshAddress, blockHash)
if (unsubscribed) return
const balance = await api.getAccountBalance(freshAddress)
if (unsubscribed) return
if (txs.length === 0) {
o.next(a => ({
...a,
balance,
blockHeight: block.height,
lastSyncDate: new Date(),
}))
o.complete()
return
}
const nonce = await api.getAccountNonce(freshAddress)
if (unsubscribed) return
o.next(a => {
const currentOps = a.operations.filter(filterConfirmedOperations)
const newOps = flatMap(txs, txToOps(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,
lastSyncDate: new Date(),
}
})
o.complete()
}
} catch (e) {
o.error(e)
}
}
main()
return () => {
unsubscribed = true
}
}),
pullMoreOperations: () => Promise.resolve(a => a), // NOT IMPLEMENTED
isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)),
getRecipientWarning: (currency, recipient) =>
Promise.resolve(getRecipientWarning(currency, recipient)),
createTransaction: () => ({
amount: BigNumber(0),
recipient: '',
gasPrice: null,
gasLimit: BigNumber(0x5208),
}),
editTransactionAmount: (account, t, amount) => ({
...t,
amount,
}),
getTransactionAmount: (a, t) => t.amount,
editTransactionRecipient: (account, t, recipient) => ({
...t,
recipient,
}),
getTransactionRecipient: (a, t) => t.recipient,
EditFees,
EditAdvancedOptions,
checkValidTransaction: (a, t) =>
!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 &&
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 || BigNumber(0)).times(t.gasLimit))),
signAndBroadcast: (a, t, deviceId) =>
Observable.create(o => {
let cancelled = false
const isCancelled = () => cancelled
const onSigned = () => {
o.next({ type: 'signed' })
}
const onOperationBroadcasted = operation => {
o.next({ type: 'broadcasted', operation })
}
signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }).then(
() => {
o.complete()
},
e => {
o.error(e)
},
)
return () => {
cancelled = true
}
}),
addPendingOperation: (account, operation) => ({
...account,
pendingOperations: [operation].concat(
account.pendingOperations.filter(
o => o.transactionSequenceNumber === operation.transactionSequenceNumber,
),
),
}),
}
export default EthereumBridge