diff --git a/flow-typed/CoinSwap.js b/flow-typed/CoinSwap.js deleted file mode 100644 index 06bca44d0..000000000 --- a/flow-typed/CoinSwap.js +++ /dev/null @@ -1,27 +0,0 @@ -declare type CoinSwapInfo = { - coin: string, - sendAddress: string, - sendAmount: number, - lbcAmount: number, -} - -declare type CoinSwapState = { - coinSwaps: Array -}; - -declare type CoinSwapAction = { - type: string, - data: { - coin: string, - sendAddress: string, - sendAmount: number, - lbcAmount: number, - }, -}; - -declare type CoinSwapRemoveAction = { - type: string, - data: { - sendAddress: string, - }, -}; diff --git a/static/app-strings.json b/static/app-strings.json index 4b2758652..0c737917f 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1730,39 +1730,46 @@ "lbry.tv is being retired in favor of %odysee%": "lbry.tv is being retired in favor of %odysee%", "You will have to switch to the %desktop_app% or %odysee% in the near future. Your existing login details will work on %odysee% and all of your %credits% and other settings will be there.": "You will have to switch to the %desktop_app% or %odysee% in the near future. Your existing login details will work on %odysee% and all of your %credits% and other settings will be there.", "Swap": "Swap", - "Swap Bitcoin": "Swap Bitcoin", + "Swap Crypto": "Swap Crypto", "Bitcoin": "Bitcoin", "Credits": "Credits", "Start Swap": "Start Swap", "To": "To", "Receiving": "Receiving", - "Send bitcoin to the address provided and you will be sent an equivalent amount of Credits.": "Send bitcoin to the address provided and you will be sent an equivalent amount of Credits.", - "Swap Bitcoin for %lbc%": "Swap Bitcoin for %lbc%", + "Send crypto to the address provided and you will be sent an equivalent amount of Credits.": "Send crypto to the address provided and you will be sent an equivalent amount of Credits.", + "Swap Crypto for %lbc%": "Swap Crypto for %lbc%", "Processing...": "Processing...", "View Past Swaps": "View Past Swaps", - "Remove address": "Remove address", - "Waiting %sendAmount% BTC": "Waiting %sendAmount% BTC", - "Waiting to receive your bitcoin.": "Waiting to receive your bitcoin.", - "Confirming %sendAmount% BTC": "Confirming %sendAmount% BTC", - "Confirming BTC transaction.": "Confirming BTC transaction.", - "Sending LBC": "Sending LBC", - "Bitcoin received. Sending your LBC.": "Bitcoin received. Sending your LBC.", + "Remove swap": "Remove swap", + "Waiting": "Waiting", + "Waiting to receive your crypto.": "Waiting to receive your crypto.", + "Confirming transaction.": "Confirming transaction.", + "Sending Credits": "Sending Credits", + "Crypto received. Sending your Credits.": "Crypto received. Sending your Credits.", "Completed": "Completed", - "LBC sent. You should see it in your wallet.": "LBC sent. You should see it in your wallet.", + "Credits sent. You should see it in your wallet.": "Credits sent. You should see it in your wallet.", + "Expired": "Expired", + "Swap expired.": "Swap expired.", "Failed": "Failed", "An error occurred on the previous swap.": "An error occurred on the previous swap.", + "Alternative coins": "Alternative coins", "Failed to initiate swap.": "Failed to initiate swap.", "Failed to query swap status.": "Failed to query swap status.", "The system is currently down. Come back later.": "The system is currently down. Come back later.", "Unable to obtain exchange rate. Try again later.": "Unable to obtain exchange rate. Try again later.", "The BTC amount needs to be higher": "The BTC amount needs to be higher", "The BTC amount is too high": "The BTC amount is too high", - "Address": "Address", - "Confirm Address Removal": "Confirm Address Removal", - "Remove BTC Swap Address": "Remove BTC Swap Address", + "Confirm Swap Removal": "Confirm Swap Removal", + "Remove Swap": "Remove Swap", + "Remove %address%?": "Remove %address%?", "This process cannot be reversed.": "This process cannot be reversed.", - "Remove %btc_address%?": "Remove %btc_address%?", "View transaction": "View transaction", + "Amount copied.": "Amount copied.", + "Transaction ID copied.": "Transaction ID copied.", + "This page can be closed while the transactions are in progress.\nYou can view the status later from:\n • Wallet » Swap » View Past Swaps": "This page can be closed while the transactions are in progress.\nYou can view the status later from:\n • Wallet » Swap » View Past Swaps", + "Credits sent": "Credits sent", + "Unresolved": "Unresolved", + "Received amount did not match order code %chargeCode%. Contact hello@lbry.com to resolve the payment.": "Received amount did not match order code %chargeCode%. Contact hello@lbry.com to resolve the payment.", "We apologize for this inconvenience, but have added this additional step to prevent abuse. Users on VPN or shared connections will continue to see this message and are not eligible for Rewards.": "We apologize for this inconvenience, but have added this additional step to prevent abuse. Users on VPN or shared connections will continue to see this message and are not eligible for Rewards.", "Help LBRY Save Crypto": "Help LBRY Save Crypto", "The US government is attempting to destroy the cryptocurrency industry. Can you help?": "The US government is attempting to destroy the cryptocurrency industry. Can you help?", diff --git a/ui/component/walletSwap/index.js b/ui/component/walletSwap/index.js index cefc62a3e..e9f520d73 100644 --- a/ui/component/walletSwap/index.js +++ b/ui/component/walletSwap/index.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import WalletSwap from './view'; import { doOpenModal } from 'redux/actions/app'; -import { doAddCoinSwap } from 'redux/actions/coinSwap'; +import { doAddCoinSwap, doQueryCoinSwapStatus } from 'redux/actions/coinSwap'; import { doToast } from 'redux/actions/notifications'; import { selectCoinSwaps } from 'redux/selectors/coinSwap'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; @@ -20,6 +20,7 @@ const perform = (dispatch) => ({ addCoinSwap: (coinSwap) => dispatch(doAddCoinSwap(coinSwap)), getNewAddress: () => dispatch(doGetNewAddress()), checkAddressIsMine: (address) => dispatch(doCheckAddressIsMine(address)), + queryCoinSwapStatus: (sendAddress) => dispatch(doQueryCoinSwapStatus(sendAddress)), }); export default withRouter(connect(select, perform)(WalletSwap)); diff --git a/ui/component/walletSwap/view.jsx b/ui/component/walletSwap/view.jsx index f3a5aca44..8de0b914f 100644 --- a/ui/component/walletSwap/view.jsx +++ b/ui/component/walletSwap/view.jsx @@ -8,6 +8,7 @@ import LbcSymbol from 'component/common/lbc-symbol'; import Spinner from 'component/spinner'; import Nag from 'component/common/nag'; import CopyableText from 'component/copyableText'; +import Icon from 'component/common/icon'; import QRCode from 'component/common/qr-code'; import usePersistedState from 'effects/use-persisted-state'; import * as ICONS from 'constants/icons'; @@ -17,20 +18,21 @@ import { clipboard } from 'electron'; import I18nMessage from 'component/i18nMessage'; import { Redirect, useHistory } from 'react-router'; -const BTC_SATOSHIS = 100000000; -const BTC_MAX = 21000000; -const BTC_MIN = 1 / BTC_SATOSHIS; +const ENABLE_ALTERNATIVE_COINS = true; -const STATUS_FETCH_INTERVAL_MS = 60000; +const BTC_SATOSHIS = 100000000; +const LBC_MAX = 21000000; +const LBC_MIN = 1; const IS_DEV = process.env.NODE_ENV !== 'production'; const DEBOUNCE_BTC_CHANGE_MS = 400; const INTERNAL_APIS_DOWN = 'internal_apis_down'; -const BTC_API_STATUS_PENDING = 'Pending'; -const BTC_API_STATUS_PROCESSING = 'Processing'; -const BTC_API_STATUS_CONFIRMING = 'Confirming'; -const BTC_API_STATUS_SUCCESS = 'Success'; +const BTC_API_STATUS_PENDING = 'NEW'; // Started swap, waiting for coin. +const BTC_API_STATUS_CONFIRMING = 'PENDING'; // Coin receiving, waiting confirmation. +const BTC_API_STATUS_PROCESSING = 'COMPLETED'; // Coin confirmed. Sending LBC. +const BTC_API_STATUS_UNRESOLVED = 'UNRESOLVED'; // Underpaid, overpaid, etc. +const BTC_API_STATUS_EXPIRED = 'EXPIRED'; // Charge expired (60 minutes). const BTC_API_STATUS_ERROR = 'Error'; const ACTION_MAIN = 'action_main'; @@ -40,15 +42,16 @@ const ACTION_STATUS_PROCESSING = 'action_processing'; const ACTION_STATUS_SUCCESS = 'action_success'; const ACTION_PAST_SWAPS = 'action_past_swaps'; -const NAG_API_STATUS_PENDING = 'Waiting to receive your bitcoin.'; -const NAG_API_STATUS_CONFIRMING = 'Confirming BTC transaction.'; -const NAG_API_STATUS_PROCESSING = 'Bitcoin received. Sending your LBC.'; -const NAG_API_STATUS_SUCCESS = 'LBC sent. You should see it in your wallet.'; +const NAG_API_STATUS_PENDING = 'Waiting to receive your crypto.'; +const NAG_API_STATUS_CONFIRMING = 'Confirming transaction.'; +const NAG_API_STATUS_PROCESSING = 'Crypto received. Sending your Credits.'; +const NAG_API_STATUS_SUCCESS = 'Credits sent. You should see it in your wallet.'; const NAG_API_STATUS_ERROR = 'An error occurred on the previous swap.'; const NAG_SWAP_CALL_FAILED = 'Failed to initiate swap.'; // const NAG_STATUS_CALL_FAILED = 'Failed to query swap status.'; const NAG_SERVER_DOWN = 'The system is currently down. Come back later.'; const NAG_RATE_CALL_FAILED = 'Unable to obtain exchange rate. Try again later.'; +const NAG_EXPIRED = 'Swap expired.'; type Props = { receiveAddress: string, @@ -59,6 +62,7 @@ type Props = { getNewAddress: () => void, checkAddressIsMine: (string) => void, openModal: (string, {}) => void, + queryCoinSwapStatus: (string) => void, }; function WalletSwap(props: Props) { @@ -71,24 +75,23 @@ function WalletSwap(props: Props) { getNewAddress, checkAddressIsMine, openModal, + queryCoinSwapStatus, } = props; const [btc, setBtc] = usePersistedState('swap-btc-amount', 0.001); const [btcError, setBtcError] = React.useState(); - const [btcAddress, setBtcAddress] = React.useState(); const [lbc, setLbc] = React.useState(0); const [action, setAction] = React.useState(ACTION_MAIN); const [nag, setNag] = React.useState(null); const [showQr, setShowQr] = React.useState(false); const [isFetchingRate, setIsFetchingRate] = React.useState(false); const [isSwapping, setIsSwapping] = React.useState(false); - const [statusMap, setStatusMap] = React.useState({}); const [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false); const { location } = useHistory(); - - const status = btcAddress ? statusMap[btcAddress] : null; - const btcTxId = status && status.receipt_txid ? status.receipt_txid : null; - const lbcTxId = status && status.lbc_txid ? status.lbc_txid : null; + const [swap, setSwap] = React.useState({}); + const [coin, setCoin] = React.useState('bitcoin'); + const [lastStatusQuery, setLastStatusQuery] = React.useState(); + const { goBack } = useHistory(); function formatLbcString(lbc) { return lbc === 0 ? '---' : lbc.toLocaleString(undefined, { minimumFractionDigits: 8 }); @@ -97,12 +100,12 @@ function WalletSwap(props: Props) { function returnToMainAction() { setIsSwapping(false); setAction(ACTION_MAIN); - setBtcAddress(null); + setSwap(null); } - function removeCoinSwap(sendAddress) { + function removeCoinSwap(chargeCode) { openModal(MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS, { - sendAddress: sendAddress, + chargeCode: chargeCode, }); } @@ -115,7 +118,7 @@ function WalletSwap(props: Props) { } }, [receiveAddress, getNewAddress, checkAddressIsMine]); - // Get 'btc::rate' + // Get 'btc/rate' React.useEffect(() => { if (isNaN(btc) || btc === 0) { setLbc(0); @@ -126,11 +129,11 @@ function WalletSwap(props: Props) { const timer = setTimeout(() => { Lbryio.call('btc', 'rate', { satoshi: BTC_SATOSHIS }) - .then((result) => { + .then((rate) => { setIsFetchingRate(false); - setLbc(btc / result); + setLbc((btc * BTC_SATOSHIS) / Math.round(BTC_SATOSHIS * rate)); }) - .catch((e) => { + .catch(() => { setIsFetchingRate(false); setLbc(0); setNag({ msg: NAG_RATE_CALL_FAILED, type: 'error' }); @@ -140,78 +143,71 @@ function WalletSwap(props: Props) { return () => clearTimeout(timer); }, [btc]); - function queryStatus(btcAddress, successCb, failureCb) { - Lbryio.call('btc', 'status', { pay_to_address: btcAddress }) - .then((result) => { - setStatusMap((statusMap) => { - const tmpMap = { ...statusMap }; - if (btcAddress) { - tmpMap[btcAddress] = result; - } - return tmpMap; - }); - if (successCb) successCb(result); - }) - .catch((err) => { - if (failureCb) failureCb(err); - }); - } - - // Poll 'btc::status' + // Resolve 'swap' with the latest info from 'coinSwaps' React.useEffect(() => { - function fetchBtcStatus() { - queryStatus( - btcAddress, - (result) => { - switch (result.Status) { - case BTC_API_STATUS_PENDING: - setAction(ACTION_STATUS_PENDING); - setNag({ msg: NAG_API_STATUS_PENDING, type: 'helpful' }); - break; - case BTC_API_STATUS_CONFIRMING: - setAction(ACTION_STATUS_CONFIRMING); - setNag({ msg: NAG_API_STATUS_CONFIRMING, type: 'helpful' }); - break; - case BTC_API_STATUS_PROCESSING: - setAction(ACTION_STATUS_PROCESSING); - setNag({ msg: NAG_API_STATUS_PROCESSING, type: 'helpful' }); - break; - case BTC_API_STATUS_SUCCESS: - setAction(ACTION_STATUS_SUCCESS); - setNag({ msg: NAG_API_STATUS_SUCCESS, type: 'helpful' }); - setIsSwapping(false); - break; - case BTC_API_STATUS_ERROR: - setNag({ msg: NAG_API_STATUS_ERROR, type: 'error' }); - returnToMainAction(); - break; - default: - if (IS_DEV) throw new Error('Unhandled status: "' + result.Status + '"'); - break; - } - }, - (err) => { - returnToMainAction(); - setNag({ - msg: err === INTERNAL_APIS_DOWN ? NAG_SERVER_DOWN : err.message /* NAG_STATUS_CALL_FAILED */, - type: 'error', - }); + const swapInfo = swap && coinSwaps.find((x) => x.chargeCode === swap.chargeCode); + if (!swapInfo) { + return; + } + + const jsonSwap = JSON.stringify(swap); + const jsonSwapInfo = JSON.stringify(swapInfo); + if (jsonSwap !== jsonSwapInfo) { + setSwap({ ...swapInfo }); + } + + if (!swapInfo.status) { + return; + } + + switch (swapInfo.status.status) { + case BTC_API_STATUS_PENDING: + setAction(ACTION_STATUS_PENDING); + setNag({ msg: NAG_API_STATUS_PENDING, type: 'helpful' }); + break; + case BTC_API_STATUS_CONFIRMING: + setAction(ACTION_STATUS_CONFIRMING); + setNag({ msg: NAG_API_STATUS_CONFIRMING, type: 'helpful' }); + break; + case BTC_API_STATUS_PROCESSING: + if (swapInfo.status.lbcTxid) { + setAction(ACTION_STATUS_SUCCESS); + setNag({ msg: NAG_API_STATUS_SUCCESS, type: 'helpful' }); + setIsSwapping(false); + } else { + setAction(ACTION_STATUS_PROCESSING); + setNag({ msg: NAG_API_STATUS_PROCESSING, type: 'helpful' }); } - ); + break; + case BTC_API_STATUS_ERROR: + setNag({ msg: NAG_API_STATUS_ERROR, type: 'error' }); + break; + case INTERNAL_APIS_DOWN: + setNag({ msg: NAG_SERVER_DOWN, type: 'error' }); + break; + case BTC_API_STATUS_EXPIRED: + setNag({ msg: NAG_EXPIRED, type: 'error' }); + if (action === ACTION_PAST_SWAPS) { + setAction(ACTION_STATUS_PENDING); + } + break; + case BTC_API_STATUS_UNRESOLVED: + setNag({ + msg: __( + 'Received amount did not match order code %chargeCode%. Contact hello@lbry.com to resolve the payment.', + { chargeCode: swapInfo.chargeCode } + ), + type: 'error', + }); + if (action === ACTION_PAST_SWAPS) { + setAction(ACTION_STATUS_PENDING); + } + break; + default: + setNag({ msg: swapInfo.status.status, type: 'error' }); + break; } - - let fetchInterval; - if (btcAddress && isSwapping) { - fetchBtcStatus(); - fetchInterval = setInterval(fetchBtcStatus, STATUS_FETCH_INTERVAL_MS); - } - - return () => { - if (fetchInterval) { - clearInterval(fetchInterval); - } - }; - }, [btcAddress, isSwapping]); + }, [swap, coinSwaps]); // Validate entered BTC React.useEffect(() => { @@ -236,9 +232,64 @@ function WalletSwap(props: Props) { return () => clearTimeout(timer); }, [isRefreshingStatus]); + function getCoinAddress(coin) { + if (swap && swap.sendAddresses) { + return swap.sendAddresses[coin]; + } + return ''; + } + + function getCoinSendAmountStr(coin) { + if (swap && swap.sendAmounts && swap.sendAmounts[coin]) { + return `${swap.sendAmounts[coin].amount} ${swap.sendAmounts[coin].currency}`; + } + return ''; + } + + function currencyToCoin(currency) { + const MAP = { + DAI: 'dai', + USDC: 'usdc', + BTC: 'bitcoin', + ETH: 'ethereum', + LTC: 'litecoin', + BCH: 'bitcoincash', + }; + return MAP[currency] || 'bitcoin'; + } + + function getSentAmountStr(swapInfo) { + if (swapInfo && swapInfo.status) { + const currency = swapInfo.status.receiptCurrency; + const coin = currencyToCoin(currency); + return getCoinSendAmountStr(coin); + } + return ''; + } + + function getCoinLabel(coin) { + const COIN_LABEL = { + dai: 'Dai', + usdc: 'USD Coin', + bitcoin: 'Bitcoin', + ethereum: 'Ethereum', + litecoin: 'Litecoin', + bitcoincash: 'Bitcoin Cash', + }; + + return COIN_LABEL[coin] || coin; + } + + function getLbcAmountStrForSwap(swap) { + if (swap && swap.lbcAmount) { + return formatLbcString(swap.lbcAmount); + } + return '---'; + } + function handleStartSwap() { setIsSwapping(true); - setBtcAddress(null); + setSwap(null); setNag(null); Lbryio.call('btc', 'swap', { @@ -246,14 +297,17 @@ function WalletSwap(props: Props) { btc_satoshi_provided: parseInt(btc * BTC_SATOSHIS), pay_to_wallet_address: receiveAddress, }) - .then((result) => { - setBtcAddress(result); - addCoinSwap({ - coin: 'btc', - sendAddress: result, - sendAmount: btc, + .then((response) => { + const swap = { + chargeCode: response.Exchange.charge_code, + coins: Object.keys(response.Charge.data.addresses), + sendAddresses: response.Charge.data.addresses, + sendAmounts: response.Charge.data.pricing, lbcAmount: lbc, - }); + }; + + setSwap({ ...swap }); + addCoinSwap({ ...swap }); }) .catch((err) => { setNag({ msg: err === INTERNAL_APIS_DOWN ? NAG_SWAP_CALL_FAILED : err.message, type: 'error' }); @@ -261,11 +315,6 @@ function WalletSwap(props: Props) { }); } - function handleCancelPending() { - returnToMainAction(); - setNag(null); - } - function handleBtcChange(event: SyntheticInputEvent<*>) { const btc = parseFloat(event.target.value); setBtc(btc); @@ -276,54 +325,131 @@ function WalletSwap(props: Props) { setNag(null); setIsRefreshingStatus(true); - coinSwaps.forEach((x) => { - queryStatus(x.sendAddress, null, null); - }); + const now = Date.now(); + if (!lastStatusQuery || now - lastStatusQuery > 30000) { + // There is a '200/minute' limit in the commerce API. If the history is + // long, or if the user goes trigger-happy, the limit could be reached + // easily. Statuses don't change often, so just limit it to every 30s. + setLastStatusQuery(now); + coinSwaps.forEach((x) => { + queryCoinSwapStatus(x.chargeCode); + }); + } } function getShortStatusStr(coinSwap: CoinSwapInfo) { - const status = statusMap[coinSwap.sendAddress]; - if (!status) { + const swapInfo = coinSwaps.find((x) => x.chargeCode === coinSwap.chargeCode); + if (!swapInfo || !swapInfo.status) { return '---'; } let msg; - switch (status.Status) { + switch (swapInfo.status.status) { case BTC_API_STATUS_PENDING: - msg = __('Waiting %sendAmount% BTC', { sendAmount: coinSwap.sendAmount }); + msg = __('Waiting'); break; case BTC_API_STATUS_CONFIRMING: - msg = __('Confirming %sendAmount% BTC', { sendAmount: coinSwap.sendAmount }); + msg = __('Confirming'); break; case BTC_API_STATUS_PROCESSING: - msg = __('Sending LBC'); - break; - case BTC_API_STATUS_SUCCESS: - msg = __('Completed'); + if (swapInfo.status.lbcTxid) { + msg = __('Credits sent'); + } else { + msg = __('Sending Credits'); + } break; case BTC_API_STATUS_ERROR: msg = __('Failed'); break; + case BTC_API_STATUS_EXPIRED: + msg = __('Expired'); + break; + case BTC_API_STATUS_UNRESOLVED: + msg = __('Unresolved'); + break; default: - msg = '?'; + msg = swapInfo.status.status; // if (IS_DEV) throw new Error('Unhandled "status": ' + status.Status); break; } return msg; } - function getViewTransactionElement(isSend) { + function getViewTransactionElement(swap, isSend) { + if (!swap || !swap.status) { + return ''; + } + + const explorerUrl = (coin, txid) => { + if (!txid) { + return ''; + } + switch (coin) { + case 'DAI': + case 'USDC': + default: + return ''; + case 'BTC': + return `https://www.blockchain.com/btc/tx/${txid}`; + case 'ETH': + return `https://www.blockchain.com/eth/tx/${txid}`; + case 'LTC': + return `https://live.blockcypher.com/ltc/tx/${txid}/`; + case 'BCH': + return `https://www.blockchain.com/bch/tx/${txid}`; + } + }; + if (isSend) { - return btcTxId ? ( -