diff --git a/common/api/shapeshift.spec.ts b/common/api/shapeshift.spec.ts new file mode 100644 index 00000000..8df9ceb4 --- /dev/null +++ b/common/api/shapeshift.spec.ts @@ -0,0 +1,125 @@ +import shapeshift, { SHAPESHIFT_BASE_URL } from './shapeshift'; + +describe('ShapeShift service', () => { + beforeEach(() => { + (global as any).fetch = jest.fn().mockImplementation( + (url: string) => + new Promise(resolve => { + const returnValues = { + [`${SHAPESHIFT_BASE_URL}/marketinfo`]: { + status: 200, + json: () => [ + { + limit: 1, + maxLimit: 2, + min: 1, + minerFee: 2, + pair: 'BTC_ETH', + rate: '1.0' + }, + { + limit: 1, + maxLimit: 2, + min: 1, + minerFee: 2, + pair: 'ETH_BTC', + rate: '1.0' + } + ] + }, + [`${SHAPESHIFT_BASE_URL}/getcoins`]: { + status: 200, + json: () => ({ + BTC: { + name: 'Bitcoin', + symbol: 'BTC', + image: '', + imageSmall: '', + status: 'available', + minerFee: 1 + }, + ETH: { + name: 'Ethereum', + symbol: 'ETH', + image: '', + imageSmall: '', + status: 'available', + minerFee: 1 + }, + XMR: { + name: 'Monero', + symbol: 'XMR', + image: '', + imageSmall: '', + status: 'unavailable', + minerFee: 1 + } + }) + } + }; + + resolve(returnValues[url]); + }) + ); + }); + it('provides a collection of all available and unavailable coins and tokens', async done => { + const rates = await shapeshift.getAllRates(); + + expect(rates).toEqual({ + BTCETH: { + id: 'BTCETH', + rate: '1.0', + limit: 1, + min: 1, + options: [ + { + id: 'BTC', + image: '', + name: 'Bitcoin', + status: 'available' + }, + { + id: 'ETH', + image: '', + name: 'Ethereum', + status: 'available' + } + ] + }, + ETHBTC: { + id: 'ETHBTC', + rate: '1.0', + limit: 1, + min: 1, + options: [ + { + id: 'ETH', + image: '', + name: 'Ethereum', + status: 'available' + }, + { + id: 'BTC', + image: '', + name: 'Bitcoin', + status: 'available' + } + ] + }, + __XMR: { + id: '__XMR', + limit: 0, + min: 0, + options: [ + { + id: 'XMR', + image: '', + name: 'Monero', + status: 'unavailable' + } + ] + } + }); + done(); + }); +}); diff --git a/common/api/shapeshift.ts b/common/api/shapeshift.ts index 3cd255fa..1f1d040c 100644 --- a/common/api/shapeshift.ts +++ b/common/api/shapeshift.ts @@ -1,9 +1,12 @@ +import flatten from 'lodash/flatten'; +import uniqBy from 'lodash/uniqBy'; + import { checkHttpStatus, parseJSON } from 'api/utils'; -const SHAPESHIFT_API_KEY = +export const SHAPESHIFT_API_KEY = '8abde0f70ca69d5851702d57b10305705d7333e93263124cc2a2649dab7ff9cf86401fc8de7677e8edcd0e7f1eed5270b1b49be8806937ef95d64839e319e6d9'; -const SHAPESHIFT_BASE_URL = 'https://shapeshift.io'; +export const SHAPESHIFT_BASE_URL = 'https://shapeshift.io'; export const SHAPESHIFT_TOKEN_WHITELIST = [ 'OMG', @@ -26,7 +29,7 @@ export const SHAPESHIFT_TOKEN_WHITELIST = [ 'TRST', 'GUP' ]; -export const SHAPESHIFT_WHITELIST = [...SHAPESHIFT_TOKEN_WHITELIST, 'ETH', 'ETC', 'BTC']; +export const SHAPESHIFT_WHITELIST = [...SHAPESHIFT_TOKEN_WHITELIST, 'ETH', 'ETC', 'BTC', 'XMR']; interface IPairData { limit: number; @@ -66,6 +69,29 @@ interface TokenMap { }; } +interface ShapeshiftCoinInfo { + image: string; + imageSmall: string; + minerFee: number; + name: string; + status: string; + symbol: string; +} + +interface ShapeshiftCoinInfoMap { + [id: string]: ShapeshiftCoinInfo; +} + +interface ShapeshiftOption { + id?: string; + status?: string; + image?: string; +} + +interface ShapeshiftOptionMap { + [symbol: string]: ShapeshiftOption; +} + class ShapeshiftService { public whitelist = SHAPESHIFT_WHITELIST; private url = SHAPESHIFT_BASE_URL; @@ -73,6 +99,8 @@ class ShapeshiftService { private postHeaders = { 'Content-Type': 'application/json' }; + private supportedCoinsAndTokens: ShapeshiftCoinInfoMap = {}; + private fetchedSupportedCoinsAndTokens = false; public checkStatus(address: string) { return fetch(`${this.url}/txStat/${address}`) @@ -118,19 +146,76 @@ class ShapeshiftService { public getAllRates = async () => { const marketInfo = await this.getMarketInfo(); - const pairRates = await this.filterPairs(marketInfo); + const pairRates = this.filterPairs(marketInfo); const checkAvl = await this.checkAvl(pairRates); const mappedRates = this.mapMarketInfo(checkAvl); - return mappedRates; + const allRates = this.addUnavailableCoinsAndTokens(mappedRates); + + return allRates; + }; + + public addUnavailableCoinsAndTokens = (availableCoinsAndTokens: TokenMap) => { + if (this.fetchedSupportedCoinsAndTokens) { + /** @desc Create a hash for efficiently checking which tokens are currently available. */ + const allOptions = flatten( + Object.values(availableCoinsAndTokens).map(({ options }) => options) + ); + const availableOptions: ShapeshiftOptionMap = uniqBy(allOptions, 'id').reduce( + (prev: ShapeshiftOptionMap, next) => { + prev[next.id] = next; + return prev; + }, + {} + ); + + const unavailableCoinsAndTokens = this.whitelist + .map(token => { + /** @desc ShapeShift claims support for the token and it is available. */ + const availableCoinOrToken = availableOptions[token]; + + if (availableCoinOrToken) { + return null; + } + + /** @desc ShapeShift claims support for the token, but it is unavailable. */ + const supportedCoinOrToken = this.supportedCoinsAndTokens[token]; + + if (supportedCoinOrToken) { + const { symbol: id, image, name, status } = supportedCoinOrToken; + + return { + /** @desc Preface the false id with '__' to differentiate from actual pairs. */ + id: `__${id}`, + limit: 0, + min: 0, + options: [{ id, image, name, status }] + }; + } + + /** @desc We claim support for the coin or token, but ShapeShift doesn't. */ + return null; + }) + .reduce((prev: ShapeshiftOptionMap, next) => { + if (next) { + prev[next.id] = next; + + return prev; + } + + return prev; + }, {}); + + return { ...availableCoinsAndTokens, ...unavailableCoinsAndTokens }; + } + + return availableCoinsAndTokens; }; private filterPairs(marketInfo: ShapeshiftMarketInfo[]) { return marketInfo.filter(obj => { const { pair } = obj; const pairArr = pair.split('_'); - return this.whitelist.includes(pairArr[0]) && this.whitelist.includes(pairArr[1]) - ? true - : false; + return this.whitelist.includes(pairArr[0]) && this.whitelist.includes(pairArr[1]); }); } @@ -165,7 +250,13 @@ class ShapeshiftService { private getAvlCoins() { return fetch(`${this.url}/getcoins`) .then(checkHttpStatus) - .then(parseJSON); + .then(parseJSON) + .then(supportedCoinsAndTokens => { + this.supportedCoinsAndTokens = supportedCoinsAndTokens; + this.fetchedSupportedCoinsAndTokens = true; + + return supportedCoinsAndTokens; + }); } private getMarketInfo() { diff --git a/common/components/ui/SwapDropdown.tsx b/common/components/ui/SwapDropdown.tsx index b044ac65..94aa1b8e 100644 --- a/common/components/ui/SwapDropdown.tsx +++ b/common/components/ui/SwapDropdown.tsx @@ -87,7 +87,7 @@ class SwapDropdown extends PureComponent { key={opt.name} option={opt} isMain={false} - isDisabled={opt.name === disabledOption} + isDisabled={opt.name === disabledOption || opt.status === 'unavailable'} onChange={this.handleChange} /> ))} @@ -140,6 +140,23 @@ class SwapDropdown extends PureComponent { (opt1, opt2) => (opt1.id.toLowerCase() > opt2.id.toLowerCase() ? 1 : -1) ); + // Sort unavailable options last + otherOptions = otherOptions.sort((opt1, opt2) => { + if (opt1.status === 'available' && opt2.status === 'unavailable') { + return -1; + } + + if (opt1.status === 'available' && opt2.status === 'available') { + return 0; + } + + if (opt1.status === 'unavailable' && opt2.status === 'available') { + return 1; + } + + return 0; + }); + this.setState({ mainOptions, otherOptions }); } } diff --git a/common/components/ui/Warning.scss b/common/components/ui/Warning.scss new file mode 100644 index 00000000..83f6a420 --- /dev/null +++ b/common/components/ui/Warning.scss @@ -0,0 +1,35 @@ +@import 'common/sass/variables'; +@import 'common/sass/mixins'; + +.Warning { + display: flex; + align-items: center; + border-top: 2px solid $brand-danger; + padding: $space-sm; + font-size: $font-size-base; + line-height: 1.5; + font-weight: 500; + box-shadow: 0 1px 1px 1px rgba(#000, 0.12); + margin-bottom: $space; + + &.highlighted { + background-color: lighten($brand-danger, 30%); + } + + &-icon { + display: flex; + flex-direction: column; + justify-content: center; + width: 32px; + margin-left: $space * 0.4; + margin-right: $space * 0.8; + text-align: center; + font-size: 32px; + color: $brand-danger; + } + &-content { + display: flex; + flex-direction: column; + padding: 0 $space; + } +} diff --git a/common/components/ui/Warning.tsx b/common/components/ui/Warning.tsx new file mode 100644 index 00000000..c0c0c10c --- /dev/null +++ b/common/components/ui/Warning.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import './Warning.scss'; + +interface WarningProps { + highlighted?: boolean; +} + +const Warning: React.SFC = ({ highlighted, children }) => { + const className = `Warning ${highlighted ? 'highlighted' : ''}`; + + return ( +
+
+ +
+
{children}
+
+ ); +}; + +export default Warning; diff --git a/common/components/ui/index.ts b/common/components/ui/index.ts index 602e8cb5..8f0a4ce5 100644 --- a/common/components/ui/index.ts +++ b/common/components/ui/index.ts @@ -16,5 +16,6 @@ export { default as TextArea } from './TextArea'; export { default as Address } from './Address'; export { default as CodeBlock } from './CodeBlock'; export { default as Toggle } from './Toggle'; +export { default as Warning } from './Warning'; export * from './Expandable'; export * from './InlineSpinner'; diff --git a/common/config/bity.ts b/common/config/bity.ts index cdb9ce21..1ff1e6e4 100644 --- a/common/config/bity.ts +++ b/common/config/bity.ts @@ -1,6 +1,6 @@ import { BTCTxExplorer, ETHTxExplorer } from './data'; -export type WhitelistedCoins = 'BTC' | 'REP' | 'ETH'; +export type WhitelistedCoins = 'BTC' | 'REP' | 'ETH' | 'XMR'; const serverURL = 'https://bity.myetherapi.com'; const bityURL = 'https://bity.com/api'; const BTCMin = 0.01; @@ -11,7 +11,8 @@ const BTCMax = 3; // value = percent higher/lower than 0.01 BTC worth const buffers = { ETH: 0.1, - REP: 0.2 + REP: 0.2, + XMR: 0.3 }; // rate must be BTC[KIND] diff --git a/common/config/data.tsx b/common/config/data.tsx index f7a3d575..aa9820fe 100644 --- a/common/config/data.tsx +++ b/common/config/data.tsx @@ -38,7 +38,9 @@ export const etherChainExplorerInst = makeExplorer({ export const donationAddressMap = { BTC: '32oirLEzZRhi33RCXDF9WHJjEb8RsrSss3', ETH: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', - REP: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520' + REP: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', + XMR: + '4GdoN7NCTi8a5gZug7PrwZNKjvHFmKeV11L6pNJPgj5QNEHsN6eeX3DaAQFwZ1ufD4LYCZKArktt113W7QjWvQ7CW7F7tDFvS511SNfZV7' }; export const gasEstimateCacheTime = 60000; diff --git a/common/containers/OnboardModal/components/WelcomeSlide.scss b/common/containers/OnboardModal/components/WelcomeSlide.scss deleted file mode 100644 index 724c01cd..00000000 --- a/common/containers/OnboardModal/components/WelcomeSlide.scss +++ /dev/null @@ -1,27 +0,0 @@ -@import 'common/sass/variables'; -@import 'common/sass/mixins'; - -.WelcomeSlide { - &-alert { - display: flex; - border-top: 2px solid color(brand-danger); - padding: $space-sm; - font-size: $font-size-base; - line-height: 1.5; - font-weight: 500; - box-shadow: 0 1px 1px 1px rgba(#000, 0.12); - margin-bottom: $space; - - &-icon { - display: flex; - flex-direction: column; - justify-content: center; - width: 32px; - margin-left: $space * 0.4; - margin-right: $space * 0.8; - text-align: center; - font-size: 32px; - color: color(brand-danger); - } - } -} diff --git a/common/containers/OnboardModal/components/WelcomeSlide.tsx b/common/containers/OnboardModal/components/WelcomeSlide.tsx index 31df841b..cd2afd33 100644 --- a/common/containers/OnboardModal/components/WelcomeSlide.tsx +++ b/common/containers/OnboardModal/components/WelcomeSlide.tsx @@ -2,31 +2,20 @@ import React from 'react'; import translate from 'translations'; import onboardIconOne from 'assets/images/onboarding/slide-01.svg'; +import { Warning } from 'components/ui'; import OnboardSlide from './OnboardSlide'; -import './WelcomeSlide.scss'; - const WelcomeSlide = () => { const header = translate('ONBOARD_WELCOME_TITLE'); const subheader = {translate('ONBOARD_WELCOME_CONTENT__3')}; const content = (
-
-
- -
- - {translate('ONBOARD_WELCOME_CONTENT__1')} - {translate('ONBOARD_WELCOME_CONTENT__2')} - -
-
-
- -
- {translate('ONBOARD_WELCOME_CONTENT__8')} -
+ + {translate('ONBOARD_WELCOME_CONTENT__1')} + {translate('ONBOARD_WELCOME_CONTENT__2')} + + {translate('ONBOARD_WELCOME_CONTENT__8')}
{translate('ONBOARD_WELCOME_CONTENT__4')}
  • {translate('ONBOARD_WELCOME_CONTENT__5')}
  • diff --git a/common/containers/Tabs/Swap/components/CurrentRates.scss b/common/containers/Tabs/Swap/components/CurrentRates.scss index 434ca4eb..fd54b24e 100644 --- a/common/containers/Tabs/Swap/components/CurrentRates.scss +++ b/common/containers/Tabs/Swap/components/CurrentRates.scss @@ -40,12 +40,13 @@ } &-input { + color: #fff; display: inline-block; width: 16%; min-width: 3.5rem; height: 2rem; padding: 1rem; - margin: .5rem auto; + margin: 0.5rem auto; font-size: 1rem; margin-right: $space; text-align: right; diff --git a/common/containers/Tabs/Swap/components/PartThree.tsx b/common/containers/Tabs/Swap/components/PartThree.tsx index 9dafe63c..9877c928 100644 --- a/common/containers/Tabs/Swap/components/PartThree.tsx +++ b/common/containers/Tabs/Swap/components/PartThree.tsx @@ -27,6 +27,8 @@ interface ReduxStateProps { bityOrderStatus: string | null; shapeshiftOrderStatus: string | null; outputTx: any; + paymentId: string | null; + xmrPaymentAddress: string | null; } interface ReduxActionProps { @@ -68,6 +70,8 @@ export default class PartThree extends PureComponent * { + margin: $space auto 0; + width: 100%; + max-width: 620px; + } + + h2 { + text-align: center; + + & > input { + margin-top: $space; + } + } + + h4 { + margin: 0.5rem 0; + } &-address { + @include mono; + display: block; - margin: $space auto 0; - max-width: 620px; - width: 100%; font-size: $font-size-medium; text-align: center; - @include mono; } @media screen and (max-width: $screen-sm) { @@ -20,4 +33,16 @@ font-size: $font-size-base; } } + + &-payment-id { + h2 { + font-weight: bolder; + text-align: center; + } + + &-link { + font-size: $font-size-small; + text-align: right; + } + } } diff --git a/common/containers/Tabs/Swap/components/PaymentInfo.tsx b/common/containers/Tabs/Swap/components/PaymentInfo.tsx index eaa3d490..fe4fc70c 100644 --- a/common/containers/Tabs/Swap/components/PaymentInfo.tsx +++ b/common/containers/Tabs/Swap/components/PaymentInfo.tsx @@ -2,19 +2,47 @@ import React, { PureComponent } from 'react'; import translate from 'translations'; import { SwapInput } from 'features/swap/types'; -import { Input } from 'components/ui'; +import { Input, Warning } from 'components/ui'; import './PaymentInfo.scss'; export interface Props { origin: SwapInput; paymentAddress: string | null; + /** + * @desc + * For XMR swaps, the "deposit" property in the response + * actually refers to the "paymentId", not the payment address. + */ + paymentId: string | null; + /** + * @desc + * For XMR swap, the actual payment address is the "sAddress" + * property in the response. + */ + xmrPaymentAddress: string | null; } export default class PaymentInfo extends PureComponent { public render() { - const { origin } = this.props; + const { origin, paymentAddress, paymentId, xmrPaymentAddress } = this.props; + const isXMRSwap = origin.label === 'XMR'; + const actualPaymentAddress = isXMRSwap ? xmrPaymentAddress : paymentAddress; + return (
    + {isXMRSwap && ( +
    +

    + {translate('USING_PAYMENT_ID')} + +

    +
    + )}

    {translate('SWAP_SEND_TO', { $origin_amount: origin.amount.toString(), @@ -22,11 +50,16 @@ export default class PaymentInfo extends PureComponent { })}

    + {isXMRSwap && ( + +

    {translate('PAYMENT_ID_WARNING')}

    +
    + )}
    ); } diff --git a/common/containers/Tabs/Swap/components/ReceivingAddress.tsx b/common/containers/Tabs/Swap/components/ReceivingAddress.tsx index 5d173608..fa514a42 100644 --- a/common/containers/Tabs/Swap/components/ReceivingAddress.tsx +++ b/common/containers/Tabs/Swap/components/ReceivingAddress.tsx @@ -1,8 +1,8 @@ import React, { PureComponent } from 'react'; -import { donationAddressMap } from 'config'; +import { donationAddressMap, WhitelistedCoins } from 'config'; import translate, { translateRaw } from 'translations'; -import { isValidBTCAddress, isValidETHAddress } from 'libs/validators'; +import { isValidBTCAddress, isValidETHAddress, isValidXMRAddress } from 'libs/validators'; import { combineAndUpper } from 'utils/formatters'; import { SwapInput } from 'features/swap/types'; import { @@ -18,7 +18,7 @@ import './ReceivingAddress.scss'; export interface StateProps { origin: SwapInput; - destinationId: keyof typeof donationAddressMap; + destinationId: WhitelistedCoins; isPostingOrder: boolean; destinationAddress: string; destinationKind: number; @@ -62,13 +62,22 @@ export default class ReceivingAddress extends PureComponent boolean } = { + BTC: isValidBTCAddress, + XMR: isValidXMRAddress, + ETH: isValidETHAddress + }; + // If there is no matching validator for the ID, assume it's a token and use ETH. + const addressValidator = addressValidators[destinationId] || addressValidators.ETH; + const validAddress = addressValidator(destinationAddress); + + const placeholders: { [coinOrToken: string]: string } = { + BTC: donationAddressMap.BTC, + XMR: donationAddressMap.XMR, + ETH: donationAddressMap.ETH + }; + const placeholder = placeholders[destinationId] || donationAddressMap.ETH; return (
    @@ -85,11 +94,7 @@ export default class ReceivingAddress extends PureComponent
diff --git a/common/containers/Tabs/Swap/index.tsx b/common/containers/Tabs/Swap/index.tsx index a9ad299a..fbf302e5 100644 --- a/common/containers/Tabs/Swap/index.tsx +++ b/common/containers/Tabs/Swap/index.tsx @@ -34,6 +34,8 @@ interface ReduxStateProps { bityOrderStatus: string | null; shapeshiftOrderStatus: string | null; paymentAddress: string | null; + paymentId: string | null; + xmrPaymentAddress: string | null; isOffline: boolean; } @@ -77,6 +79,8 @@ class Swap extends Component { maxLimit: 7.04575258, apiPubKey: '0ca1ccd50b708a3f8c02327f0caeeece06d3ddc1b0ac749a987b453ee0f4a29bdb5da2e53bc35e57fb4bb7ae1f43c93bb098c3c4716375fc1001c55d8c94c160', - minerFee: '1.05' + minerFee: '1.05', + sAddress: '0x055ed77933388642fdn4px9v73j4fa3582d10c4' }; const swapState = reducer.swapReducer( @@ -254,8 +255,10 @@ describe('swap reducer', () => { validFor: swapState.validFor, orderTimestampCreatedISOString: swapState.orderTimestampCreatedISOString, paymentAddress: mockedShapeshiftOrder.deposit, + paymentId: mockedShapeshiftOrder.deposit, shapeshiftOrderStatus: 'no_deposits', - orderId: mockedShapeshiftOrder.orderId + orderId: mockedShapeshiftOrder.orderId, + xmrPaymentAddress: mockedShapeshiftOrder.sAddress }); }); diff --git a/common/features/swap/reducer.ts b/common/features/swap/reducer.ts index 5f8b229e..e75ef7cc 100644 --- a/common/features/swap/reducer.ts +++ b/common/features/swap/reducer.ts @@ -42,7 +42,9 @@ export const INITIAL_STATE: types.SwapState = { paymentAddress: null, validFor: null, orderId: null, - showLiteSend: false + showLiteSend: false, + paymentId: null, + xmrPaymentAddress: null }; export function swapReducer(state: types.SwapState = INITIAL_STATE, action: types.SwapAction) { @@ -69,22 +71,19 @@ export function swapReducer(state: types.SwapState = INITIAL_STATE, action: type isFetchingRates: false }; case types.SwapActions.LOAD_SHAPESHIFT_RATES_SUCCEEDED: + const { + entities: { providerRates: normalizedProviderRates, options: normalizedOptions } + } = normalize(action.payload, [providerRate]); + return { ...state, shapeshiftRates: { - byId: normalize(action.payload, [providerRate]).entities.providerRates, - allIds: allIds(normalize(action.payload, [providerRate]).entities.providerRates) + byId: normalizedProviderRates, + allIds: allIds(normalizedProviderRates) }, options: { - byId: Object.assign( - {}, - normalize(action.payload, [providerRate]).entities.options, - state.options.byId - ), - allIds: [ - ...allIds(normalize(action.payload, [providerRate]).entities.options), - ...state.options.allIds - ] + byId: { ...normalizedOptions, ...state.options.byId }, + allIds: [...allIds(normalizedOptions), ...state.options.allIds] }, isFetchingRates: false }; @@ -151,8 +150,8 @@ export function swapReducer(state: types.SwapState = INITIAL_STATE, action: type }; case types.SwapActions.SHAPESHIFT_ORDER_CREATE_SUCCEEDED: const currDate = Date.now(); - const secondsRemaining = Math.floor((+new Date(action.payload.expiration) - currDate) / 1000); + return { ...state, shapeshiftOrder: { @@ -166,7 +165,10 @@ export function swapReducer(state: types.SwapState = INITIAL_STATE, action: type orderTimestampCreatedISOString: new Date(currDate).toISOString(), paymentAddress: action.payload.deposit, shapeshiftOrderStatus: 'no_deposits', - orderId: action.payload.orderId + orderId: action.payload.orderId, + // For XMR swaps + paymentId: action.payload.deposit, + xmrPaymentAddress: action.payload.sAddress }; case types.SwapActions.BITY_ORDER_STATUS_SUCCEEDED: return { diff --git a/common/features/swap/types.ts b/common/features/swap/types.ts index 4ec51e31..4fe0bf9d 100644 --- a/common/features/swap/types.ts +++ b/common/features/swap/types.ts @@ -24,6 +24,18 @@ export interface SwapState { validFor: number | null; orderId: string | null; showLiteSend: boolean; + /** + * @desc + * For XMR swaps, the "deposit" property in the response + * actually refers to the "paymentId", not the payment address. + */ + paymentId: string | null; + /** + * @desc + * For XMR swap, the actual payment address is the "sAddress" + * property in the response. + */ + xmrPaymentAddress: string | null; } export enum SwapActions { @@ -208,6 +220,7 @@ export interface ShapeshiftOrderResponse { quotedRate: string; withdrawal: string; withdrawalAmount: string; + sAddress?: string; } export interface ShapeshiftStatusResponse { diff --git a/common/libs/validators.ts b/common/libs/validators.ts index 41981e4f..8a0fbf89 100644 --- a/common/libs/validators.ts +++ b/common/libs/validators.ts @@ -59,6 +59,12 @@ export function isValidBTCAddress(address: string): boolean { return WalletAddressValidator.validate(address, 'BTC'); } +export function isValidXMRAddress(address: string): boolean { + return !!address.match( + /4[0-9AB][123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{93}/ + ); +} + export function isValidHex(str: string): boolean { if (str === '') { return true; diff --git a/common/translations/lang/en.json b/common/translations/lang/en.json index 5ff09533..e61c14d9 100644 --- a/common/translations/lang/en.json +++ b/common/translations/lang/en.json @@ -660,6 +660,9 @@ "NETWORK_2": "network", "PROVIDED_BY": "provided by", "YOU_ARE_INTERACTING": "You are interacting with the", + "USING_PAYMENT_ID": "Using the required Payment ID of:", + "PAYMENT_ID_WARNING": "Don't forget to send your XMR with the payment ID [[?]](https://getmonero.org/resources/moneropedia/paymentid.html) above, or you WILL lose your funds.", + "WHAT_IS_PAYMENT_ID": "what's a payment ID?", "ANNOUNCEMENT_MESSAGE": "Welcome to the new MyCrypto. We hope you like it! If it's urgent and you need the old site, you can still use [MyCrypto Legacy](https://legacy.mycrypto.com)", "U2F_NOT_SUPPORTED": "The U2F standard that hardware wallets use does not seem to be supported by your browser. Please try again using Google Chrome." } diff --git a/spec/pages/__snapshots__/Swap.spec.tsx.snap b/spec/pages/__snapshots__/Swap.spec.tsx.snap index 5e224002..700e713f 100644 --- a/spec/pages/__snapshots__/Swap.spec.tsx.snap +++ b/spec/pages/__snapshots__/Swap.spec.tsx.snap @@ -76,6 +76,7 @@ exports[`render snapshot 1`] = ` } outputTx={null} paymentAddress={null} + paymentId={null} provider="shapeshift" restartSwap={[Function]} secondsRemaining={null} @@ -100,5 +101,6 @@ exports[`render snapshot 1`] = ` stopPollBityOrderStatus={[Function]} stopPollShapeshiftOrderStatus={[Function]} swapProvider={[Function]} + xmrPaymentAddress={null} /> `;