diff --git a/app/locales/en-US/swap.json b/app/locales/en-US/swap.json index 4bbc030afb..8d0bac991c 100644 --- a/app/locales/en-US/swap.json +++ b/app/locales/en-US/swap.json @@ -4,6 +4,8 @@ "timedOut": "Swap timed out", "title": "Swap", "details": { + "aliceclaim": "Alice Claim", + "alicereclaim": "Alice Reclaim", "alicepayment": "Alice Payment", "alicespend": "Alice Spend", "bobdeposit": "Bob Deposit", @@ -35,7 +37,11 @@ "completed": "Completed", "failed": "Failed", "matched": "Matched", + "reverted": "Reverted", "pending": "Pending", "unmatched": "Unmatched" + }, + "statusInformation": { + "reverted": "The swap was reverted due to connectivity issues." } } diff --git a/app/renderer/api.js b/app/renderer/api.js index 881638089c..887899f983 100644 --- a/app/renderer/api.js +++ b/app/renderer/api.js @@ -350,6 +350,17 @@ export default class Api { return getCurrency(opts.symbol).etomic ? this._withdrawEth(opts) : this._withdrawBtcFork(opts); } + kickstart(opts) { + ow(opts.requestId, ow.number.positive.finite.label('requestId')); + ow(opts.quoteId, ow.number.positive.finite.label('quoteId')); + + return this.request({ + method: 'kickstart', + requestid: opts.requestId, + quoteid: opts.quoteId, + }); + } + listUnspent(coin, address) { ow(coin, symbolPredicate.label('coin')); ow(address, ow.string.label('address')); @@ -376,14 +387,4 @@ export default class Api { this.queue.pause(); this.queue.clear(); } - - subscribeToSwap(uuid) { - ow(uuid, uuidPredicate.label('uuid')); - - if (!this.socket) { - throw new Error('Swap subscriptions require the socket to be enabled'); - } - - return this.socket.subscribeToSwap(uuid); - } } diff --git a/app/renderer/components/SwapDetails.js b/app/renderer/components/SwapDetails.js index f0d082096e..0d12f31a46 100644 --- a/app/renderer/components/SwapDetails.js +++ b/app/renderer/components/SwapDetails.js @@ -52,33 +52,32 @@ class SwapDetails extends React.Component { const {swap} = this.props; const {baseCurrency, quoteCurrency} = swap; - let hasTransactions = false; - const transactions = swapTransactions.map(stage => { - const tx = swap.transactions.find(tx => tx.stage === stage); - - if (!tx) { - return ( - -
-
-
{t(`details.${stage}`)}
-
-
- ); - } + const transactions = swap.transactions.map(tx => ( + +
+
+
{t(`details.${tx.stage}`)}
+

{tx.amount}
{tx.coin}

+
+
+ )); - hasTransactions = true; + if (swap.status === 'swapping') { + swapTransactions.forEach(stage => { + const tx = swap.transactions.find(tx => tx.stage === stage); - return ( - -
-
-
{t(`details.${stage}`)}
-

{tx.amount}
{tx.coin}

-
-
- ); - }); + if (!tx) { + transactions.push( + +
+
+
{t(`details.${stage}`)}
+
+
+ ); + } + }); + } const prices = ['requested', 'broadcast', 'executed'].map(value => { if (!swap[value].price) { @@ -136,6 +135,9 @@ class SwapDetails extends React.Component { )}

+ {swap.statusInformation && ( +

{swap.statusInformation}

+ )}
@@ -151,7 +153,7 @@ class SwapDetails extends React.Component {

)}
- {hasTransactions && ( + {(transactions.length > 0) && (

{t('details.transactions')}

diff --git a/app/renderer/containers/Exchange.js b/app/renderer/containers/Exchange.js index e99ce20d4b..e06cc122dd 100644 --- a/app/renderer/containers/Exchange.js +++ b/app/renderer/containers/Exchange.js @@ -1,6 +1,7 @@ /* eslint-disable react/no-access-state-in-setstate */ import {is, api, activeWindow, appLaunchTimestamp} from 'electron-util'; import _ from 'lodash'; +import {isPast, addHours} from 'date-fns'; import SuperContainer from 'containers/SuperContainer'; import appContainer from 'containers/App'; import {translate} from '../translate'; @@ -22,12 +23,23 @@ class ExchangeContainer extends SuperContainer { askdepth: 0, }, isSendingOrder: false, + doneInitialKickstart: false, }; } - componentDidInitialMount() { - this.setSwapHistory(); + async componentDidInitialMount() { + await this.setSwapHistory(); appContainer.swapDB.on('change', this.setSwapHistory); + appContainer.api.socket.on('message', message => { + const uuids = this.state.swapHistory.map(swap => swap.uuid); + if (uuids.includes(message.uuid)) { + appContainer.swapDB.updateSwapData(message); + } + }); + + fireEvery({minutes: 15}, async () => { + await this.kickstartStuckSwaps(); + }); } constructor() { @@ -49,6 +61,23 @@ class ExchangeContainer extends SuperContainer { }); } + async kickstartStuckSwaps() { + const {doneInitialKickstart} = this.state; + this.state.swapHistory + .filter(swap => ( + swap.status === 'swapping' && + (!doneInitialKickstart || isPast(addHours(swap.timeStarted, 4))) + )) + .forEach(async swap => { + const {requestId, quoteId} = swap; + await appContainer.api.kickstart({requestId, quoteId}); + }); + + if (!doneInitialKickstart) { + this.setState({doneInitialKickstart: true}); + } + } + setSwapHistory = async () => { this.setState({swapHistory: await appContainer.swapDB.getSwaps()}); }; diff --git a/app/renderer/marketmaker-socket.js b/app/renderer/marketmaker-socket.js index 08e48beef5..764c62b9e0 100644 --- a/app/renderer/marketmaker-socket.js +++ b/app/renderer/marketmaker-socket.js @@ -1,8 +1,6 @@ import Emittery from 'emittery'; import pEvent from 'p-event'; import readBlob from 'read-blob'; -import pAny from 'p-any'; -import delay from 'delay'; class MarketmakerSocket { constructor(endpoint) { @@ -32,52 +30,6 @@ class MarketmakerSocket { } getResponse = queueId => this._ee.once(`id_${queueId}`); - - // Returns an EventEmitter that will emit events as the status of the swap progresses - // Important events: - // - 'progress': All messages related to the swap. - // - 'connected': The swap has been matched. - // - 'update': The atomic swap has advanced a step, the step flag is in the `update` property. - // - 'finished': The atomic swap level has successfully completed. - // - 'failed': The atomic swap has failed, the error code is in the property `error`. - // - // Any other message with a `method` property will also be emitted via en event of the same name. - subscribeToSwap(uuid) { - if (typeof uuid === 'undefined') { - throw new TypeError(`uuid is required`); - } - - const swapEmitter = new Emittery(); - - const removeListener = this.on('message', message => { - if (message.uuid !== uuid) { - return; - } - - swapEmitter.emit('progress', message); - if (message.method) { - swapEmitter.emit(message.method, message); - } - if (message.method === 'tradestatus' && message.status === 'finished') { - swapEmitter.emit('completed', message); - } - }); - - // We should wait for 10 minutes before removing the listener - // to handle edge cases where swaps can breifly have an incorrect - // status due to communication issues: - // https://github.com/jl777/SuperNET/issues/756 - const TEN_MINUTES = 1000 * 60 * 10; - pAny([ - swapEmitter.once('failed'), - swapEmitter.once('completed'), - ]).then(async () => { - await delay(TEN_MINUTES); - removeListener(); - }); - - return swapEmitter; - } } export default MarketmakerSocket; diff --git a/app/renderer/swap-db.js b/app/renderer/swap-db.js index 47cbce01d6..02ff9615d1 100644 --- a/app/renderer/swap-db.js +++ b/app/renderer/swap-db.js @@ -93,6 +93,7 @@ class SwapDB { }); } + // TODO: We should refactor this into a seperate file _formatSwap(data) { const MATCHED_STEP = 1; const TOTAL_PROGRESS_STEPS = swapTransactions.length + MATCHED_STEP; @@ -108,6 +109,8 @@ class SwapDB { const swap = { uuid, + requestId: undefined, + quoteId: undefined, timeStarted, orderType: isBuyOrder ? 'buy' : 'sell', status: 'pending', @@ -144,6 +147,13 @@ class SwapDB { }; messages.forEach(message => { + if (message.requestid) { + swap.requestId = message.requestid; + } + if (message.quoteid) { + swap.quoteId = message.quoteid; + } + if (message.method === 'connected') { swap.status = 'matched'; swap.progress = MATCHED_STEP / TOTAL_PROGRESS_STEPS; @@ -151,36 +161,130 @@ class SwapDB { if (message.method === 'update') { swap.status = 'swapping'; - swap.transactions.push({ - stage: message.name, - coin: message.coin, - txid: message.txid, - amount: message.amount, - }); + // Don't push duplicate messages + if (!swap.transactions.find(tx => tx.stage === message.name)) { + swap.transactions.push({ + stage: message.name, + coin: message.coin, + txid: message.txid, + amount: message.amount, + }); + } } if (message.method === 'tradestatus' && message.status === 'finished') { swap.status = 'completed'; swap.progress = 1; - swap.transactions.push({ - stage: 'alicespend', - coin: message.bob, - txid: message.paymentspent, - amount: message.srcamount, - }); + // Nuke transaction history and rebuild it from this message. + // This will normally result in the same transaction array but + // if we were offline and missed some messages this will allow us + // to reconstruct what happened. + // + // It also allows us to correctly rebuild the tx chain of swaps that + // didn't quite go to plan like claiming bobdeposit or alicepayment. + const amounts = (() => { + const [alicespend, bobspend, bobpayment, alicepayment, bobdeposit, otherfee, myfee, bobrefund, bobreclaim, alicereclaim, aliceclaim] = message.values; + return {alicespend, bobspend, bobpayment, alicepayment, bobdeposit, otherfee, myfee, bobrefund, bobreclaim, alicereclaim, aliceclaim}; + })(); + + swap.transactions = []; + if (message.sentflags.includes('myfee')) { + swap.transactions.push({ + stage: 'myfee', + coin: message.alice, + txid: message.alicedexfee, + amount: amounts.myfee, + }); + } + if (message.sentflags.includes('bobdeposit')) { + swap.transactions.push({ + stage: 'bobdeposit', + coin: message.bob, + txid: message.bobdeposit, + amount: amounts.bobdeposit, + }); + } + if (message.sentflags.includes('alicepayment')) { + swap.transactions.push({ + stage: 'alicepayment', + coin: message.alice, + txid: message.alicepayment, + amount: amounts.alicepayment, + }); + } + if (message.sentflags.includes('bobpayment')) { + swap.transactions.push({ + stage: 'bobpayment', + coin: message.bob, + txid: message.bobpayment, + amount: amounts.bobpayment, + }); + } - const executedBaseCurrencyAmount = isBuyOrder ? message.srcamount : message.destamount; - const executedQuoteCurrencyAmount = isBuyOrder ? message.destamount : message.srcamount; + // This is the final tx claiming bobpayment for a trade that completed as expected. + if (message.sentflags.includes('alicespend')) { + swap.transactions.push({ + stage: 'alicespend', + coin: message.bob, + txid: message.paymentspent, + amount: amounts.alicespend, + }); + } + + // This is the final tx in the case that bob doesn't send bobpayment. + // We can claim bobdeposit which gives us a 12.5% bonus to punish bob. + if (message.sentflags.includes('aliceclaim')) { + // There is a bug in marketmaker where it doesn't always correctly report + // the values. If aliceclaim is 0 we fallback to bobdeposit as it should + // be very close (same amount minus our claim txfee) + // https://github.com/jl777/SuperNET/issues/920 + swap.transactions.push({ + stage: 'aliceclaim', + coin: message.bob, + txid: message.depositspent, + amount: amounts.aliceclaim || amounts.bobdeposit, + }); + } + + // This is the final tx in the case that bob doesn't send anything after + // we've already sent alicepayment. We don't get any of the currency we + // want, just claim our original payment back. + if (message.sentflags.includes('alicereclaim')) { + swap.transactions.push({ + stage: 'alicereclaim', + coin: message.alice, + txid: message.Apaymentspent, + amount: amounts.alicereclaim, + }); + } + + // Overrride status to failed if we don't have a successful completion transaction + if (!( + message.sentflags.includes('alicespend') || + message.sentflags.includes('aliceclaim') + )) { + swap.status = 'failed'; + } + + const startTx = swap.transactions.find(tx => tx.stage === 'alicepayment'); + const startAmount = startTx ? startTx.amount : 0; + const endTx = swap.transactions.find(tx => ['alicespend', 'aliceclaim'].includes(tx.stage)); + const endAmount = endTx ? endTx.amount : 0; + const executedBaseCurrencyAmount = isBuyOrder ? endAmount : startAmount; + const executedQuoteCurrencyAmount = isBuyOrder ? startAmount : endAmount; swap.baseCurrencyAmount = roundTo(executedBaseCurrencyAmount, 8); swap.quoteCurrencyAmount = roundTo(executedQuoteCurrencyAmount, 8); - swap.price = roundTo(executedQuoteCurrencyAmount / executedBaseCurrencyAmount, 8); swap.executed.baseCurrencyAmount = swap.baseCurrencyAmount; swap.executed.quoteCurrencyAmount = swap.quoteCurrencyAmount; - swap.executed.price = swap.price; - swap.executed.percentCheaperThanRequested = roundTo(100 - ((swap.executed.price / swap.requested.price) * 100), 2); - if (!isBuyOrder) { - swap.executed.percentCheaperThanRequested = -swap.executed.percentCheaperThanRequested; + + if (endAmount > 0 && startAmount > 0) { + swap.price = roundTo(executedQuoteCurrencyAmount / executedBaseCurrencyAmount, 8); + swap.executed.price = swap.price; + swap.executed.percentCheaperThanRequested = roundTo(100 - ((swap.executed.price / swap.requested.price) * 100), 2); + if (!isBuyOrder) { + swap.executed.percentCheaperThanRequested = -swap.executed.percentCheaperThanRequested; + } } } @@ -218,8 +322,16 @@ class SwapDB { swap.statusFormatted = `swap ${swapProgress}/${swapTransactions.length}`; swap.progress = (swapProgress + MATCHED_STEP) / TOTAL_PROGRESS_STEPS; - } else if (swap.status === 'failed' && (swap.error.code === -9999 || timedOut)) { - swap.statusFormatted = t('status.unmatched').toLowerCase(); + } + + if (swap.status === 'failed') { + if (swap.error.code === -9999 || timedOut) { + swap.statusFormatted = t('status.unmatched').toLowerCase(); + } + if (swap.transactions.find(tx => tx.stage === 'alicereclaim')) { + swap.statusFormatted = t('status.reverted').toLowerCase(); + swap.statusInformation = t('statusInformation.reverted'); + } } return swap; diff --git a/app/renderer/views/Exchange/Order.js b/app/renderer/views/Exchange/Order.js index 624a3cf0f4..0a53e67efd 100644 --- a/app/renderer/views/Exchange/Order.js +++ b/app/renderer/views/Exchange/Order.js @@ -141,7 +141,6 @@ class Bottom extends React.Component { const swap = result.pending; const {swapDB} = appContainer; - api.subscribeToSwap(swap.uuid).on('progress', swapDB.updateSwapData); await swapDB.insertSwapData(swap, requestOpts); exchangeContainer.setIsSendingOrder(false); };