// @flow import React from 'react'; import Button from 'component/button'; import { FormField, Form } from 'component/common/form'; import { Lbryio } from 'lbryinc'; import Card from 'component/common/card'; 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'; import * as MODALS from 'constants/modal_types'; import * as PAGES from 'constants/pages'; import { clipboard } from 'electron'; import I18nMessage from 'component/i18nMessage'; import { Redirect, useHistory } from 'react-router'; const ENABLE_ALTERNATIVE_COINS = true; 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 = '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'; const ACTION_STATUS_PENDING = 'action_pending'; const ACTION_STATUS_CONFIRMING = 'action_confirming'; 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 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, coinSwaps: Array, isAuthenticated: boolean, doToast: ({ message: string }) => void, addCoinSwap: (CoinSwapInfo) => void, getNewAddress: () => void, checkAddressIsMine: (string) => void, openModal: (string, {}) => void, queryCoinSwapStatus: (string) => void, }; function WalletSwap(props: Props) { const { receiveAddress, doToast, coinSwaps, isAuthenticated, addCoinSwap, getNewAddress, checkAddressIsMine, openModal, queryCoinSwapStatus, } = props; const [btc, setBtc] = React.useState(0); const [lbcError, setLbcError] = React.useState(); const [lbc, setLbc] = usePersistedState('swap-desired-lbc', LBC_MIN); 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 [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false); const { location } = useHistory(); const [swap, setSwap] = React.useState({}); const [coin, setCoin] = React.useState('bitcoin'); const [lastStatusQuery, setLastStatusQuery] = React.useState(); const { goBack } = useHistory(); function formatCoinAmountString(amount) { return amount === 0 ? '---' : amount.toLocaleString(undefined, { minimumFractionDigits: 8 }); } function returnToMainAction() { setIsSwapping(false); setAction(ACTION_MAIN); setSwap(null); } function removeCoinSwap(chargeCode) { openModal(MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS, { chargeCode: chargeCode, }); } // Ensure 'receiveAddress' is populated React.useEffect(() => { if (!receiveAddress) { getNewAddress(); } else { checkAddressIsMine(receiveAddress); } }, [receiveAddress, getNewAddress, checkAddressIsMine]); // Get 'btc/rate' and calculate required BTC. React.useEffect(() => { if (isNaN(lbc) || lbc === 0) { setBtc(0); return; } setIsFetchingRate(true); const timer = setTimeout(() => { Lbryio.call('btc', 'rate', { satoshi: BTC_SATOSHIS }) .then((rate) => { setIsFetchingRate(false); setBtc((lbc * Math.round(BTC_SATOSHIS * rate)) / BTC_SATOSHIS); }) .catch(() => { setIsFetchingRate(false); setBtc(0); setNag({ msg: NAG_RATE_CALL_FAILED, type: 'error' }); }); }, DEBOUNCE_BTC_CHANGE_MS); return () => clearTimeout(timer); }, [lbc]); // Resolve 'swap' with the latest info from 'coinSwaps' React.useEffect(() => { 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; } }, [swap, coinSwaps]); // Validate entered LBC React.useEffect(() => { let msg; if (lbc < LBC_MIN) { msg = __('The amount needs to be higher'); } else if (lbc > LBC_MAX) { msg = __('The amount is too high'); } setLbcError(msg); }, [lbc]); // 'Refresh' button feedback React.useEffect(() => { let timer; if (isRefreshingStatus) { timer = setTimeout(() => { setIsRefreshingStatus(false); }, 1000); } 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 formatCoinAmountString(swap.lbcAmount); } return '---'; } function handleStartSwap() { setIsSwapping(true); setSwap(null); setNag(null); Lbryio.call('btc', 'swap', { lbc_satoshi_requested: parseInt(lbc * BTC_SATOSHIS + 0.5), btc_satoshi_provided: parseInt(btc * BTC_SATOSHIS + 0.5), pay_to_wallet_address: receiveAddress, }) .then((response) => { const btcAmount = response.Charge.data.pricing['bitcoin'].amount; const rate = response.Exchange.rate; const timeline = response.Charge.data.timeline; const lastTimeline = timeline[timeline.length - 1]; const newSwap = { chargeCode: response.Exchange.charge_code, coins: Object.keys(response.Charge.data.addresses), sendAddresses: response.Charge.data.addresses, sendAmounts: response.Charge.data.pricing, lbcAmount: (btcAmount * BTC_SATOSHIS) / rate, status: { status: lastTimeline.status, receiptCurrency: lastTimeline.payment.value.currency, receiptTxid: lastTimeline.payment.transaction_id, lbcTxid: response.Exchange.lbc_txid || '', }, }; setSwap({ ...newSwap }); addCoinSwap({ ...newSwap }); }) .catch((err) => { const translateError = (err) => { // TODO: https://github.com/lbryio/lbry.go/issues/87 // Translate error codes instead of strings when it is available. if (err === 'users are currently limited to 4 transactions per month') { return __('Users are currently limited to 4 completed swaps per month or 5 pending swaps.'); } return err; }; setNag({ msg: err === INTERNAL_APIS_DOWN ? NAG_SWAP_CALL_FAILED : translateError(err.message), type: 'error' }); returnToMainAction(); }); } function handleViewPastSwaps() { setAction(ACTION_PAST_SWAPS); setNag(null); setIsRefreshingStatus(true); 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 swapInfo = coinSwaps.find((x) => x.chargeCode === coinSwap.chargeCode); if (!swapInfo || !swapInfo.status) { return '---'; } let msg; switch (swapInfo.status.status) { case BTC_API_STATUS_PENDING: msg = __('Waiting'); break; case BTC_API_STATUS_CONFIRMING: msg = __('Confirming'); break; case BTC_API_STATUS_PROCESSING: 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 = swapInfo.status.status; // if (IS_DEV) throw new Error('Unhandled "status": ' + status.Status); break; } return msg; } function getViewTransactionElement(swap, isSend) { if (!swap || !swap.status) { return ''; } const explorerUrl = (coin, txid) => { // It's unclear whether we can link to sites like blockchain.com. // Don't do it for now. return ''; }; if (isSend) { const sendTxId = swap.status.receiptTxid; const url = explorerUrl(swap.status.receiptCurrency, sendTxId); return sendTxId ? ( <> {url &&