diff --git a/src/bridge/EthereumJSBridge.js b/src/bridge/EthereumJSBridge.js index 04844351..20be763d 100644 --- a/src/bridge/EthereumJSBridge.js +++ b/src/bridge/EthereumJSBridge.js @@ -1,4 +1,5 @@ // @flow +import { Observable } from 'rxjs' import React from 'react' import FeesField from 'components/FeesField/EthereumKind' import AdvancedOptions from 'components/AdvancedOptions/EthereumKind' @@ -93,6 +94,49 @@ function mergeOps(existing: Operation[], newFetched: Operation[]) { return uniqBy(all.sort((a, b) => b.date - a.date), 'id') } +const signAndBroadcast = async ({ + a, + t, + deviceId, + isCancelled, + onSigned, + onOperationBroadcasted, +}) => { + 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: { ...t, nonce }, + }) + .toPromise() + + if (!isCancelled()) { + onSigned() + + const hash = await api.broadcastTransaction(transaction) + + onOperationBroadcasted({ + id: `${a.id}-${hash}-OUT`, + hash, + type: 'OUT', + value: t.amount, + fee: t.gasPrice * t.gasLimit, + blockHeight: null, + blockHash: null, + accountId: a.id, + senders: [a.freshAddress], + recipients: [t.recipient], + transactionSequenceNumber: nonce, + date: new Date(), + }) + } +} + const SAFE_REORG_THRESHOLD = 80 const fetchCurrentBlock = (perCurrencyId => currency => { @@ -324,37 +368,28 @@ const EthereumBridge: WalletBridge = { getTotalSpent: (a, t) => Promise.resolve(t.amount + t.gasPrice * t.gasLimit), getMaxAmount: (a, t) => Promise.resolve(a.balance - t.gasPrice * t.gasLimit), - signAndBroadcast: async (a, t, deviceId) => { - 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: { ...t, nonce }, - }) - .toPromise() - - const hash = await api.broadcastTransaction(transaction) - - return { - id: `${a.id}-${hash}-OUT`, - hash, - type: 'OUT', - value: t.amount, - fee: t.gasPrice * t.gasLimit, - blockHeight: null, - blockHash: null, - accountId: a.id, - senders: [a.freshAddress], - recipients: [t.recipient], - transactionSequenceNumber: nonce, - date: new Date(), - } - }, + 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, diff --git a/src/bridge/LibcoreBridge.js b/src/bridge/LibcoreBridge.js index 960ef946..30b4102a 100644 --- a/src/bridge/LibcoreBridge.js +++ b/src/bridge/LibcoreBridge.js @@ -17,6 +17,9 @@ type Transaction = { recipient: string, } +const decodeOperation = (encodedAccount, rawOp) => + decodeAccount({ ...encodedAccount, operations: [rawOp] }).operations[0] + const EditFees = ({ account, onChange, value }: EditProps) => ( { @@ -135,20 +138,27 @@ const LibcoreBridge: WalletBridge = { getMaxAmount: (a, _t) => Promise.resolve(a.balance), // FIXME - signAndBroadcast: async (account, transaction, deviceId) => { + signAndBroadcast: (account, transaction, deviceId) => { const encodedAccount = encodeAccount(account) - const rawOp = await libcoreSignAndBroadcast + return libcoreSignAndBroadcast .send({ account: encodedAccount, transaction, deviceId, }) - .toPromise() - - // quick HACK - const [op] = decodeAccount({ ...encodedAccount, operations: [rawOp] }).operations - - return op + .pipe( + map(e => { + switch (e.type) { + case 'broadcasted': + return { + type: 'broadcasted', + operation: decodeOperation(encodedAccount, e.operation), + } + default: + return e + } + }), + ) }, addPendingOperation: (account, operation) => ({ diff --git a/src/bridge/RippleJSBridge.js b/src/bridge/RippleJSBridge.js index 4fad0371..57463f2b 100644 --- a/src/bridge/RippleJSBridge.js +++ b/src/bridge/RippleJSBridge.js @@ -1,4 +1,5 @@ // @flow +import { Observable } from 'rxjs' import React from 'react' import bs58check from 'ripple-bs58check' import { computeBinaryTransactionHash } from 'ripple-hashes' @@ -43,6 +44,70 @@ const EditAdvancedOptions = ({ onChange, value }: EditProps) => ( /> ) +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) @@ -394,66 +459,28 @@ const RippleJSBridge: WalletBridge = { getMaxAmount: (a, t) => Promise.resolve(a.balance - t.fee), - signAndBroadcast: async (a, t, deviceId) => { - 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, - }, + signAndBroadcast: (a, t, deviceId) => + Observable.create(o => { + let cancelled = false + const isCancelled = () => cancelled + const onSigned = () => { + o.next({ type: 'signed' }) } - 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() - - const submittedPayment = await api.submit(transaction) - - if (submittedPayment.resultCode !== 'tesSUCCESS') { - throw new Error(submittedPayment.resultMessage) + const onOperationBroadcasted = operation => { + o.next({ type: 'broadcasted', operation }) } - - const hash = computeBinaryTransactionHash(transaction) - - return { - 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, + signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }).then( + () => { + o.complete() + }, + e => { + o.error(e) + }, + ) + return () => { + cancelled = true } - } finally { - api.disconnect() - } - }, + }), addPendingOperation: (account, operation) => ({ ...account, diff --git a/src/bridge/UnsupportedBridge.js b/src/bridge/UnsupportedBridge.js index 0a6b26b9..2f1bbce5 100644 --- a/src/bridge/UnsupportedBridge.js +++ b/src/bridge/UnsupportedBridge.js @@ -1,4 +1,5 @@ // @flow +import { Observable } from 'rxjs' import type { WalletBridge } from './types' const genericError = new Error('UnsupportedBridge') @@ -36,7 +37,10 @@ const UnsupportedBridge: WalletBridge<*> = { getMaxAmount: () => Promise.resolve(0), - signAndBroadcast: () => Promise.reject(genericError), + signAndBroadcast: () => + Observable.create(o => { + o.error(genericError) + }), } export default UnsupportedBridge diff --git a/src/bridge/makeMockBridge.js b/src/bridge/makeMockBridge.js index eb41396d..1358e01f 100644 --- a/src/bridge/makeMockBridge.js +++ b/src/bridge/makeMockBridge.js @@ -1,4 +1,5 @@ // @flow +import { Observable } from 'rxjs' import { genAccount, genAddingOperationsInAccount, @@ -149,20 +150,22 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> { getMaxAmount, - signAndBroadcast: async (account, t) => { - const rng = new Prando() - 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 } - }, + signAndBroadcast: (account, t) => + Observable.create(o => { + const rng = new Prando() + 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) + o.next({ type: 'signed' }) + o.next({ type: 'broadcasted', operation: { ...op } }) + }), } } diff --git a/src/bridge/types.js b/src/bridge/types.js index 3b9d5e0c..c84758de 100644 --- a/src/bridge/types.js +++ b/src/bridge/types.js @@ -1,5 +1,6 @@ // @flow +import type { Observable } from 'rxjs' import type { Account, Operation, Currency } from '@ledgerhq/live-common/lib/types' // a WalletBridge is implemented on renderer side. @@ -104,7 +105,7 @@ export interface WalletBridge { account: Account, transaction: Transaction, deviceId: DeviceId, - ): Promise; + ): Observable<{ type: 'signed' } | { type: 'broadcasted', operation: Operation }>; // 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?) diff --git a/src/commands/libcoreSignAndBroadcast.js b/src/commands/libcoreSignAndBroadcast.js index 79ef35ec..b5a92aad 100644 --- a/src/commands/libcoreSignAndBroadcast.js +++ b/src/commands/libcoreSignAndBroadcast.js @@ -2,9 +2,8 @@ import type { AccountRaw, OperationRaw } from '@ledgerhq/live-common/lib/types' import Btc from '@ledgerhq/hw-app-btc' -import { fromPromise } from 'rxjs/observable/fromPromise' +import { Observable } from 'rxjs' import { getCryptoCurrencyById } from '@ledgerhq/live-common/lib/helpers/currencies' -import type Transport from '@ledgerhq/hw-transport' import withLibcore from 'helpers/withLibcore' import { createCommand, Command } from 'helpers/ipc' @@ -23,24 +22,37 @@ type Input = { deviceId: string, } -type Result = $Exact +type Result = { type: 'signed' } | { type: 'broadcasted', operation: OperationRaw } const cmd: Command = createCommand( 'libcoreSignAndBroadcast', ({ account, transaction, deviceId }) => - fromPromise( - withDevice(deviceId)(transport => - withLibcore(core => - doSignAndBroadcast({ - account, - transaction, - deviceId, - core, - transport, - }), - ), - ), - ), + Observable.create(o => { + let unsubscribed = false + const isCancelled = () => unsubscribed + withLibcore(core => + doSignAndBroadcast({ + account, + transaction, + deviceId, + core, + isCancelled, + onSigned: () => { + o.next({ type: 'signed' }) + }, + onOperationBroadcasted: operation => { + o.next({ + type: 'broadcasted', + operation, + }) + }, + }), + ).then(() => o.complete(), e => o.error(e)) + + return () => { + unsubscribed = true + } + }), ) export async function doSignAndBroadcast({ @@ -48,60 +60,74 @@ export async function doSignAndBroadcast({ transaction, deviceId, core, - transport, + isCancelled, + onSigned, + onOperationBroadcasted, }: { account: AccountRaw, transaction: BitcoinLikeTransaction, deviceId: string, core: *, - transport: Transport<*>, -}) { - const hwApp = new Btc(transport) - - const WALLET_IDENTIFIER = await getWalletIdentifier({ - hwApp, - isSegwit: !!account.isSegwit, - currencyId: account.currencyId, - devicePath: deviceId, + isCancelled: () => boolean, + onSigned: () => void, + onOperationBroadcasted: (optimisticOp: $Exact) => void, +}): Promise { + let njsAccount + + const signedTransaction: ?string = await withDevice(deviceId)(async transport => { + const hwApp = new Btc(transport) + + const WALLET_IDENTIFIER = await getWalletIdentifier({ + hwApp, + isSegwit: !!account.isSegwit, + currencyId: account.currencyId, + devicePath: deviceId, + }) + + const njsWallet = await core.getWallet(WALLET_IDENTIFIER) + if (isCancelled()) return null + njsAccount = await njsWallet.getAccount(account.index) + if (isCancelled()) return null + const bitcoinLikeAccount = njsAccount.asBitcoinLikeAccount() + const njsWalletCurrency = njsWallet.getCurrency() + const amount = core.createAmount(njsWalletCurrency, transaction.amount) + const fees = core.createAmount(njsWalletCurrency, transaction.feePerByte) + const transactionBuilder = bitcoinLikeAccount.buildTransaction() + + // TODO: check if is valid address. if not, it will fail silently on invalid + + transactionBuilder.sendToAddress(amount, transaction.recipient) + // TODO: don't use hardcoded value for sequence (and first also maybe) + transactionBuilder.pickInputs(0, 0xffffff) + transactionBuilder.setFeesPerByte(fees) + + const builded = await transactionBuilder.build() + if (isCancelled()) return null + const sigHashType = core.helpers.bytesToHex( + njsWalletCurrency.bitcoinLikeNetworkParameters.SigHash, + ) + + const hasTimestamp = njsWalletCurrency.bitcoinLikeNetworkParameters.UsesTimestampedTransaction + // TODO: const timestampDelay = njsWalletCurrency.bitcoinLikeNetworkParameters.TimestampDelay + + const currency = getCryptoCurrencyById(account.currencyId) + + return core.signTransaction({ + hwApp, + transaction: builded, + sigHashType: parseInt(sigHashType, 16).toString(), + supportsSegwit: !!currency.supportsSegwit, + isSegwit: account.isSegwit, + hasTimestamp, + }) }) - const njsWallet = await core.getWallet(WALLET_IDENTIFIER) - const njsAccount = await njsWallet.getAccount(account.index) - const bitcoinLikeAccount = njsAccount.asBitcoinLikeAccount() - const njsWalletCurrency = njsWallet.getCurrency() - const amount = core.createAmount(njsWalletCurrency, transaction.amount) - const fees = core.createAmount(njsWalletCurrency, transaction.feePerByte) - const transactionBuilder = bitcoinLikeAccount.buildTransaction() - - // TODO: check if is valid address. if not, it will fail silently on invalid - - transactionBuilder.sendToAddress(amount, transaction.recipient) - // TODO: don't use hardcoded value for sequence (and first also maybe) - transactionBuilder.pickInputs(0, 0xffffff) - transactionBuilder.setFeesPerByte(fees) - - const builded = await transactionBuilder.build() - const sigHashType = core.helpers.bytesToHex( - njsWalletCurrency.bitcoinLikeNetworkParameters.SigHash, - ) - - const hasTimestamp = njsWalletCurrency.bitcoinLikeNetworkParameters.UsesTimestampedTransaction - // TODO: const timestampDelay = njsWalletCurrency.bitcoinLikeNetworkParameters.TimestampDelay - - const currency = getCryptoCurrencyById(account.currencyId) - const signedTransaction = await core.signTransaction({ - hwApp, - transaction: builded, - sigHashType: parseInt(sigHashType, 16).toString(), - supportsSegwit: !!currency.supportsSegwit, - isSegwit: account.isSegwit, - hasTimestamp, - }) + if (!signedTransaction || isCancelled() || !njsAccount) return + onSigned() const txHash = await njsAccount.asBitcoinLikeAccount().broadcastRawTransaction(signedTransaction) - - // optimistic operation - return { + // NB we don't check isCancelled() because the broadcast is not cancellable now! + onOperationBroadcasted({ id: txHash, hash: txHash, type: 'OUT', @@ -113,7 +139,7 @@ export async function doSignAndBroadcast({ recipients: [transaction.recipient], accountId: account.id, date: new Date().toISOString(), - } + }) } export default cmd diff --git a/src/components/DeviceSignTransaction.js b/src/components/DeviceSignTransaction.js deleted file mode 100644 index fe1f35e0..00000000 --- a/src/components/DeviceSignTransaction.js +++ /dev/null @@ -1,42 +0,0 @@ -// @flow -import { PureComponent } from 'react' -import type { Account, Operation } from '@ledgerhq/live-common/lib/types' -import type { Device } from 'types/common' -import type { WalletBridge } from 'bridge/types' - -type Props = { - children: *, - onOperationBroadcasted: (op: Operation) => void, - onError: Error => void, - device: Device, - account: Account, - bridge: WalletBridge<*>, - transaction: *, -} - -class DeviceSignTransaction extends PureComponent { - componentDidMount() { - this.sign() - } - - componentWillUnmount() { - this.unmount = true - } - unmount = false - - sign = async () => { - const { device, account, transaction, bridge, onOperationBroadcasted, onError } = this.props - try { - const optimisticOperation = await bridge.signAndBroadcast(account, transaction, device.path) - onOperationBroadcasted(optimisticOperation) - } catch (error) { - onError(error) - } - } - - render() { - return this.props.children - } -} - -export default DeviceSignTransaction diff --git a/src/components/SelectAccount/index.js b/src/components/SelectAccount/index.js index 28584255..b041ffd7 100644 --- a/src/components/SelectAccount/index.js +++ b/src/components/SelectAccount/index.js @@ -57,9 +57,7 @@ type Props = { } const RawSelectAccount = ({ accounts, onChange, value, t, ...props }: Props) => { - const options = accounts - .sort((a, b) => (a.name < b.name ? -1 : 1)) - .map(a => ({ ...a, value: a.id, label: a.name })) + const options = accounts.map(a => ({ ...a, value: a.id, label: a.name })) const selectedOption = value ? options.find(o => o.value === value.id) : null return (