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.
500 lines
13 KiB
500 lines
13 KiB
// @flow
|
|
import invariant from 'invariant'
|
|
import { Observable } from 'rxjs'
|
|
import React from 'react'
|
|
import bs58check from 'ripple-bs58check'
|
|
import { computeBinaryTransactionHash } from 'ripple-hashes'
|
|
import throttle from 'lodash/throttle'
|
|
import type { Account, Operation } from '@ledgerhq/live-common/lib/types'
|
|
import { getDerivations } from 'helpers/derivations'
|
|
import getAddress from 'commands/getAddress'
|
|
import signTransaction from 'commands/signTransaction'
|
|
import {
|
|
apiForCurrency,
|
|
parseAPIValue,
|
|
parseAPICurrencyObject,
|
|
formatAPICurrencyXRP,
|
|
} from 'api/Ripple'
|
|
import FeesRippleKind from 'components/FeesField/RippleKind'
|
|
import AdvancedOptionsRippleKind from 'components/AdvancedOptions/RippleKind'
|
|
import { getAccountPlaceholderName, getNewAccountPlaceholderName } from 'helpers/accountName'
|
|
import type { WalletBridge, EditProps } from './types'
|
|
|
|
type Transaction = {
|
|
amount: number,
|
|
recipient: string,
|
|
fee: number,
|
|
tag: ?number,
|
|
}
|
|
|
|
const EditFees = ({ account, onChange, value }: EditProps<Transaction>) => (
|
|
<FeesRippleKind
|
|
onChange={fee => {
|
|
onChange({ ...value, fee })
|
|
}}
|
|
fee={value.fee}
|
|
account={account}
|
|
/>
|
|
)
|
|
|
|
const EditAdvancedOptions = ({ onChange, value }: EditProps<Transaction>) => (
|
|
<AdvancedOptionsRippleKind
|
|
tag={value.tag}
|
|
onChangeTag={tag => {
|
|
onChange({ ...value, tag })
|
|
}}
|
|
/>
|
|
)
|
|
|
|
async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }) {
|
|
const api = apiForCurrency(a.currency)
|
|
try {
|
|
await api.connect()
|
|
const amount = formatAPICurrencyXRP(t.amount)
|
|
const payment = {
|
|
source: {
|
|
address: a.freshAddress,
|
|
amount,
|
|
},
|
|
destination: {
|
|
address: t.recipient,
|
|
minAmount: amount,
|
|
tag: t.tag,
|
|
},
|
|
}
|
|
const instruction = {
|
|
fee: formatAPICurrencyXRP(t.fee).value,
|
|
}
|
|
|
|
const prepared = await api.preparePayment(a.freshAddress, payment, instruction)
|
|
|
|
const transaction = await signTransaction
|
|
.send({
|
|
currencyId: a.currency.id,
|
|
devicePath: deviceId,
|
|
path: a.freshAddressPath,
|
|
transaction: JSON.parse(prepared.txJSON),
|
|
})
|
|
.toPromise()
|
|
|
|
if (!isCancelled()) {
|
|
onSigned()
|
|
const submittedPayment = await api.submit(transaction)
|
|
|
|
if (submittedPayment.resultCode !== 'tesSUCCESS') {
|
|
throw new Error(submittedPayment.resultMessage)
|
|
}
|
|
|
|
const hash = computeBinaryTransactionHash(transaction)
|
|
|
|
onOperationBroadcasted({
|
|
id: `${a.id}-${hash}-OUT`,
|
|
hash,
|
|
accountId: a.id,
|
|
type: 'OUT',
|
|
value: t.amount,
|
|
fee: t.fee,
|
|
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()
|
|
}
|
|
}
|
|
|
|
function isRecipientValid(currency, recipient) {
|
|
try {
|
|
bs58check.decode(recipient)
|
|
return true
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
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) => b.date - a.date)
|
|
}
|
|
|
|
type Tx = {
|
|
type: string,
|
|
address: string,
|
|
sequence: number,
|
|
id: string,
|
|
specification: {
|
|
source: {
|
|
address: string,
|
|
maxAmount: {
|
|
currency: string,
|
|
value: string,
|
|
},
|
|
},
|
|
destination: {
|
|
address: string,
|
|
amount: {
|
|
currency: string,
|
|
value: string,
|
|
},
|
|
},
|
|
paths: string,
|
|
},
|
|
outcome: {
|
|
result: string,
|
|
fee: string,
|
|
timestamp: string,
|
|
deliveredAmount?: {
|
|
currency: string,
|
|
value: string,
|
|
counterparty: string,
|
|
},
|
|
balanceChanges: {
|
|
[addr: string]: Array<{
|
|
counterparty: string,
|
|
currency: string,
|
|
value: string,
|
|
}>,
|
|
},
|
|
orderbookChanges: {
|
|
[addr: string]: Array<{
|
|
direction: string,
|
|
quantity: {
|
|
currency: string,
|
|
value: string,
|
|
},
|
|
totalPrice: {
|
|
currency: string,
|
|
counterparty: string,
|
|
value: string,
|
|
},
|
|
makeExchangeRate: string,
|
|
sequence: number,
|
|
status: string,
|
|
}>,
|
|
},
|
|
ledgerVersion: number,
|
|
indexInLedger: number,
|
|
},
|
|
}
|
|
|
|
const txToOperation = (account: Account) => ({
|
|
id,
|
|
sequence,
|
|
outcome: { fee, deliveredAmount, ledgerVersion, timestamp },
|
|
specification: { source, destination },
|
|
}: Tx): Operation => {
|
|
const type = source.address === account.freshAddress ? 'OUT' : 'IN'
|
|
let value = deliveredAmount ? parseAPICurrencyObject(deliveredAmount) : 0
|
|
const feeValue = parseAPIValue(fee)
|
|
if (type === 'OUT') {
|
|
if (!isNaN(feeValue)) {
|
|
value += feeValue
|
|
}
|
|
}
|
|
|
|
const op: $Exact<Operation> = {
|
|
id,
|
|
hash: id,
|
|
accountId: account.id,
|
|
type,
|
|
value,
|
|
fee: feeValue,
|
|
blockHash: null,
|
|
blockHeight: ledgerVersion,
|
|
senders: [source.address],
|
|
recipients: [destination.address],
|
|
date: new Date(timestamp),
|
|
transactionSequenceNumber: sequence,
|
|
}
|
|
return op
|
|
}
|
|
|
|
const getServerInfo = (perCurrencyId => currency => {
|
|
if (perCurrencyId[currency.id]) return perCurrencyId[currency.id]()
|
|
const f = throttle(async () => {
|
|
const api = apiForCurrency(currency)
|
|
try {
|
|
await api.connect()
|
|
const res = await api.getServerInfo()
|
|
return res
|
|
} catch (e) {
|
|
f.cancel()
|
|
throw e
|
|
} finally {
|
|
api.disconnect()
|
|
}
|
|
}, 60000)
|
|
perCurrencyId[currency.id] = f
|
|
return f()
|
|
})({})
|
|
|
|
const RippleJSBridge: WalletBridge<Transaction> = {
|
|
scanAccountsOnDevice(currency, deviceId, { next, complete, error }) {
|
|
let finished = false
|
|
const unsubscribe = () => {
|
|
finished = true
|
|
}
|
|
|
|
async function main() {
|
|
const api = apiForCurrency(currency)
|
|
try {
|
|
await api.connect()
|
|
const serverInfo = await getServerInfo(currency)
|
|
const ledgers = serverInfo.completeLedgers.split('-')
|
|
const minLedgerVersion = Number(ledgers[0])
|
|
const maxLedgerVersion = Number(ledgers[1])
|
|
|
|
const derivations = getDerivations(currency)
|
|
for (const derivation of derivations) {
|
|
for (let index = 0; index < 255; index++) {
|
|
const freshAddressPath = derivation({ currency, x: index, segwit: false })
|
|
const { address } = await await getAddress
|
|
.send({ currencyId: currency.id, devicePath: deviceId, path: freshAddressPath })
|
|
.toPromise()
|
|
if (finished) return
|
|
|
|
const accountId = `ripplejs:${currency.id}:${address}`
|
|
|
|
let info
|
|
try {
|
|
info = await api.getAccountInfo(address)
|
|
} catch (e) {
|
|
if (e.message !== 'actNotFound') {
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// fresh address is address. ripple never changes.
|
|
const freshAddress = address
|
|
|
|
if (!info) {
|
|
// account does not exist in Ripple server
|
|
// we are generating a new account locally
|
|
next({
|
|
id: accountId,
|
|
xpub: '',
|
|
name: getNewAccountPlaceholderName(currency, index),
|
|
freshAddress,
|
|
freshAddressPath,
|
|
balance: 0,
|
|
blockHeight: maxLedgerVersion,
|
|
index,
|
|
currency,
|
|
operations: [],
|
|
pendingOperations: [],
|
|
unit: currency.units[0],
|
|
archived: false,
|
|
lastSyncDate: new Date(),
|
|
})
|
|
break
|
|
}
|
|
|
|
if (finished) return
|
|
const balance = parseAPIValue(info.xrpBalance)
|
|
invariant(
|
|
!isNaN(balance) && isFinite(balance),
|
|
`Ripple: invalid balance=${balance} for address ${address}`,
|
|
)
|
|
|
|
const transactions = await api.getTransactions(address, {
|
|
minLedgerVersion,
|
|
maxLedgerVersion,
|
|
})
|
|
if (finished) return
|
|
|
|
const account: $Exact<Account> = {
|
|
id: accountId,
|
|
xpub: '',
|
|
name: getAccountPlaceholderName(currency, index),
|
|
freshAddress,
|
|
freshAddressPath,
|
|
balance,
|
|
blockHeight: maxLedgerVersion,
|
|
index,
|
|
currency,
|
|
operations: [],
|
|
pendingOperations: [],
|
|
unit: currency.units[0],
|
|
lastSyncDate: new Date(),
|
|
}
|
|
account.operations = transactions.map(txToOperation(account))
|
|
next(account)
|
|
}
|
|
}
|
|
complete()
|
|
} catch (e) {
|
|
error(e)
|
|
} finally {
|
|
api.disconnect()
|
|
}
|
|
}
|
|
|
|
main()
|
|
|
|
return { unsubscribe }
|
|
},
|
|
|
|
synchronize: ({ currency, freshAddress, blockHeight }) =>
|
|
Observable.create(o => {
|
|
let finished = false
|
|
const unsubscribe = () => {
|
|
finished = true
|
|
}
|
|
|
|
async function main() {
|
|
const api = apiForCurrency(currency)
|
|
try {
|
|
await api.connect()
|
|
if (finished) return
|
|
const serverInfo = await getServerInfo(currency)
|
|
if (finished) return
|
|
const ledgers = serverInfo.completeLedgers.split('-')
|
|
const minLedgerVersion = Number(ledgers[0])
|
|
const maxLedgerVersion = Number(ledgers[1])
|
|
|
|
let info
|
|
try {
|
|
info = await api.getAccountInfo(freshAddress)
|
|
} catch (e) {
|
|
if (e.message !== 'actNotFound') {
|
|
throw e
|
|
}
|
|
}
|
|
if (finished) return
|
|
|
|
if (!info) {
|
|
// account does not exist, we have nothing to sync
|
|
o.complete()
|
|
return
|
|
}
|
|
|
|
const balance = parseAPIValue(info.xrpBalance)
|
|
invariant(
|
|
!isNaN(balance) && isFinite(balance),
|
|
`Ripple: invalid balance=${balance} for address ${freshAddress}`,
|
|
)
|
|
|
|
o.next(a => ({ ...a, balance }))
|
|
|
|
const transactions = await api.getTransactions(freshAddress, {
|
|
minLedgerVersion: Math.max(blockHeight, minLedgerVersion),
|
|
maxLedgerVersion,
|
|
})
|
|
|
|
if (finished) return
|
|
|
|
o.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(),
|
|
}
|
|
})
|
|
|
|
o.complete()
|
|
} catch (e) {
|
|
o.error(e)
|
|
} finally {
|
|
api.disconnect()
|
|
}
|
|
}
|
|
|
|
main()
|
|
|
|
return unsubscribe
|
|
}),
|
|
|
|
pullMoreOperations: () => Promise.resolve(a => a), // FIXME not implemented
|
|
|
|
isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)),
|
|
|
|
createTransaction: () => ({
|
|
amount: 0,
|
|
recipient: '',
|
|
fee: 0,
|
|
tag: undefined,
|
|
}),
|
|
|
|
editTransactionAmount: (account, t, amount) => ({
|
|
...t,
|
|
amount,
|
|
}),
|
|
|
|
getTransactionAmount: (a, t) => t.amount,
|
|
|
|
editTransactionRecipient: (account, t, recipient) => ({
|
|
...t,
|
|
recipient,
|
|
}),
|
|
|
|
EditFees,
|
|
|
|
EditAdvancedOptions,
|
|
|
|
getTransactionRecipient: (a, t) => t.recipient,
|
|
|
|
isValidTransaction: (a, t) => (t.amount > 0 && t.recipient && true) || false,
|
|
|
|
canBeSpent: async (a, t) => {
|
|
const r = await getServerInfo(a.currency)
|
|
return t.amount + t.fee + parseAPIValue(r.validatedLedger.reserveBaseXRP) <= a.balance
|
|
},
|
|
|
|
getTotalSpent: (a, t) => Promise.resolve(t.amount + t.fee),
|
|
|
|
getMaxAmount: (a, t) => Promise.resolve(a.balance - t.fee),
|
|
|
|
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 RippleJSBridge
|
|
|