Add Monero swap support (#1985)

* Adjust emailTo to desired email addresses

* Prettierize SupportFooter

* Add monero swap support

* Adjust styling and wording for the PaymentInfo screen for XMR

* Only show warning on XMR swaps (duh)

* Replicate comment

* Fix styling for rates and payment info

* Add a Monero donation address and use it as the placeholder for the ReceivingAddress Input
This commit is contained in:
Connor Bryan 2018-07-06 13:29:16 -05:00 committed by Daniel Ternyak
parent 0fa1b7004d
commit 3ae3622ff4
20 changed files with 216 additions and 83 deletions

View File

@ -26,7 +26,7 @@ export const SHAPESHIFT_TOKEN_WHITELIST = [
'TRST', 'TRST',
'GUP' 'GUP'
]; ];
export const SHAPESHIFT_WHITELIST = [...SHAPESHIFT_TOKEN_WHITELIST, 'ETH', 'ETC', 'BTC']; export const SHAPESHIFT_WHITELIST = [...SHAPESHIFT_TOKEN_WHITELIST, 'ETH', 'ETC', 'BTC', 'XMR'];
interface IPairData { interface IPairData {
limit: number; limit: number;

View File

@ -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;
}
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import './Warning.scss';
interface WarningProps {
highlighted?: boolean;
}
const Warning: React.SFC<WarningProps> = ({ highlighted, children }) => {
const className = `Warning ${highlighted ? 'highlighted' : ''}`;
return (
<section className={className}>
<section className="Warning-icon">
<i className="fa fa-exclamation-triangle" />
</section>
<section className="Warning-content">{children}</section>
</section>
);
};
export default Warning;

View File

@ -16,5 +16,6 @@ export { default as TextArea } from './TextArea';
export { default as Address } from './Address'; export { default as Address } from './Address';
export { default as CodeBlock } from './CodeBlock'; export { default as CodeBlock } from './CodeBlock';
export { default as Toggle } from './Toggle'; export { default as Toggle } from './Toggle';
export { default as Warning } from './Warning';
export * from './Expandable'; export * from './Expandable';
export * from './InlineSpinner'; export * from './InlineSpinner';

View File

@ -1,6 +1,6 @@
import { BTCTxExplorer, ETHTxExplorer } from './data'; 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 serverURL = 'https://bity.myetherapi.com';
const bityURL = 'https://bity.com/api'; const bityURL = 'https://bity.com/api';
const BTCMin = 0.01; const BTCMin = 0.01;
@ -11,7 +11,8 @@ const BTCMax = 3;
// value = percent higher/lower than 0.01 BTC worth // value = percent higher/lower than 0.01 BTC worth
const buffers = { const buffers = {
ETH: 0.1, ETH: 0.1,
REP: 0.2 REP: 0.2,
XMR: 0.3
}; };
// rate must be BTC[KIND] // rate must be BTC[KIND]

View File

@ -38,7 +38,9 @@ export const etherChainExplorerInst = makeExplorer({
export const donationAddressMap = { export const donationAddressMap = {
BTC: '32oirLEzZRhi33RCXDF9WHJjEb8RsrSss3', BTC: '32oirLEzZRhi33RCXDF9WHJjEb8RsrSss3',
ETH: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', ETH: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
REP: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520' REP: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
XMR:
'4GdoN7NCTi8a5gZug7PrwZNKjvHFmKeV11L6pNJPgj5QNEHsN6eeX3DaAQFwZ1ufD4LYCZKArktt113W7QjWvQ7CW7F7tDFvS511SNfZV7'
}; };
export const gasEstimateCacheTime = 60000; export const gasEstimateCacheTime = 60000;

View File

@ -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);
}
}
}

View File

@ -2,31 +2,20 @@ import React from 'react';
import translate from 'translations'; import translate from 'translations';
import onboardIconOne from 'assets/images/onboarding/slide-01.svg'; import onboardIconOne from 'assets/images/onboarding/slide-01.svg';
import { Warning } from 'components/ui';
import OnboardSlide from './OnboardSlide'; import OnboardSlide from './OnboardSlide';
import './WelcomeSlide.scss';
const WelcomeSlide = () => { const WelcomeSlide = () => {
const header = translate('ONBOARD_WELCOME_TITLE'); const header = translate('ONBOARD_WELCOME_TITLE');
const subheader = <small>{translate('ONBOARD_WELCOME_CONTENT__3')}</small>; const subheader = <small>{translate('ONBOARD_WELCOME_CONTENT__3')}</small>;
const content = ( const content = (
<div> <div>
<div className="WelcomeSlide-alert"> <Warning>
<div className="WelcomeSlide-alert-icon"> {translate('ONBOARD_WELCOME_CONTENT__1')}
<i className="fa fa-exclamation-triangle" /> {translate('ONBOARD_WELCOME_CONTENT__2')}
</div> </Warning>
<span> <Warning>{translate('ONBOARD_WELCOME_CONTENT__8')}</Warning>
{translate('ONBOARD_WELCOME_CONTENT__1')}
{translate('ONBOARD_WELCOME_CONTENT__2')}
</span>
</div>
<div className="WelcomeSlide-alert">
<div className="WelcomeSlide-alert-icon">
<i className="fa fa-exclamation-triangle" />
</div>
{translate('ONBOARD_WELCOME_CONTENT__8')}
</div>
<h5>{translate('ONBOARD_WELCOME_CONTENT__4')}</h5> <h5>{translate('ONBOARD_WELCOME_CONTENT__4')}</h5>
<ul> <ul>
<li>{translate('ONBOARD_WELCOME_CONTENT__5')}</li> <li>{translate('ONBOARD_WELCOME_CONTENT__5')}</li>

View File

@ -40,12 +40,13 @@
} }
&-input { &-input {
color: #fff;
display: inline-block; display: inline-block;
width: 16%; width: 16%;
min-width: 3.5rem; min-width: 3.5rem;
height: 2rem; height: 2rem;
padding: 1rem; padding: 1rem;
margin: .5rem auto; margin: 0.5rem auto;
font-size: 1rem; font-size: 1rem;
margin-right: $space; margin-right: $space;
text-align: right; text-align: right;

View File

@ -27,6 +27,8 @@ interface ReduxStateProps {
bityOrderStatus: string | null; bityOrderStatus: string | null;
shapeshiftOrderStatus: string | null; shapeshiftOrderStatus: string | null;
outputTx: any; outputTx: any;
paymentId: string | null;
xmrPaymentAddress: string | null;
} }
interface ReduxActionProps { interface ReduxActionProps {
@ -68,6 +70,8 @@ export default class PartThree extends PureComponent<ReduxActionProps & ReduxSta
shapeshiftOrderStatus, shapeshiftOrderStatus,
destinationAddress, destinationAddress,
outputTx, outputTx,
paymentId,
xmrPaymentAddress,
// ACTIONS // ACTIONS
showNotificationWithComponent showNotificationWithComponent
} = this.props; } = this.props;
@ -85,7 +89,9 @@ export default class PartThree extends PureComponent<ReduxActionProps & ReduxSta
const PaymentInfoProps = { const PaymentInfoProps = {
origin, origin,
paymentAddress paymentAddress,
paymentId,
xmrPaymentAddress
}; };
const BitcoinQRProps = { const BitcoinQRProps = {

View File

@ -1,18 +1,31 @@
@import "common/sass/variables"; @import 'common/sass/variables';
@import "common/sass/mixins"; @import 'common/sass/mixins';
.SwapPayment { .SwapPayment {
text-align: center; > * {
margin-bottom: $space; margin: $space auto 0;
width: 100%;
max-width: 620px;
}
h2 {
text-align: center;
& > input {
margin-top: $space;
}
}
h4 {
margin: 0.5rem 0;
}
&-address { &-address {
@include mono;
display: block; display: block;
margin: $space auto 0;
max-width: 620px;
width: 100%;
font-size: $font-size-medium; font-size: $font-size-medium;
text-align: center; text-align: center;
@include mono;
} }
@media screen and (max-width: $screen-sm) { @media screen and (max-width: $screen-sm) {
@ -20,4 +33,16 @@
font-size: $font-size-base; font-size: $font-size-base;
} }
} }
&-payment-id {
h2 {
font-weight: bolder;
text-align: center;
}
&-link {
font-size: $font-size-small;
text-align: right;
}
}
} }

View File

@ -2,19 +2,47 @@ import React, { PureComponent } from 'react';
import translate from 'translations'; import translate from 'translations';
import { SwapInput } from 'features/swap/types'; import { SwapInput } from 'features/swap/types';
import { Input } from 'components/ui'; import { Input, Warning } from 'components/ui';
import './PaymentInfo.scss'; import './PaymentInfo.scss';
export interface Props { export interface Props {
origin: SwapInput; origin: SwapInput;
paymentAddress: string | null; 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<Props, {}> { export default class PaymentInfo extends PureComponent<Props, {}> {
public render() { public render() {
const { origin } = this.props; const { origin, paymentAddress, paymentId, xmrPaymentAddress } = this.props;
const isXMRSwap = origin.label === 'XMR';
const actualPaymentAddress = isXMRSwap ? xmrPaymentAddress : paymentAddress;
return ( return (
<section className="SwapPayment"> <section className="SwapPayment">
{isXMRSwap && (
<section className="SwapPayment-payment-id">
<h2>
{translate('USING_PAYMENT_ID')}
<Input
className="SwapPayment-address"
isValid={!!paymentId}
value={paymentId || undefined}
disabled={true}
/>
</h2>
</section>
)}
<h2> <h2>
{translate('SWAP_SEND_TO', { {translate('SWAP_SEND_TO', {
$origin_amount: origin.amount.toString(), $origin_amount: origin.amount.toString(),
@ -22,11 +50,16 @@ export default class PaymentInfo extends PureComponent<Props, {}> {
})} })}
<Input <Input
className="SwapPayment-address" className="SwapPayment-address"
isValid={!!this.props.paymentAddress} isValid={!!actualPaymentAddress}
value={this.props.paymentAddress || undefined} value={actualPaymentAddress || undefined}
disabled={true} disabled={true}
/> />
</h2> </h2>
{isXMRSwap && (
<Warning highlighted={true}>
<h4>{translate('PAYMENT_ID_WARNING')}</h4>
</Warning>
)}
</section> </section>
); );
} }

View File

@ -1,8 +1,8 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { donationAddressMap } from 'config'; import { donationAddressMap, WhitelistedCoins } from 'config';
import translate, { translateRaw } from 'translations'; import translate, { translateRaw } from 'translations';
import { isValidBTCAddress, isValidETHAddress } from 'libs/validators'; import { isValidBTCAddress, isValidETHAddress, isValidXMRAddress } from 'libs/validators';
import { combineAndUpper } from 'utils/formatters'; import { combineAndUpper } from 'utils/formatters';
import { SwapInput } from 'features/swap/types'; import { SwapInput } from 'features/swap/types';
import { import {
@ -18,7 +18,7 @@ import './ReceivingAddress.scss';
export interface StateProps { export interface StateProps {
origin: SwapInput; origin: SwapInput;
destinationId: keyof typeof donationAddressMap; destinationId: WhitelistedCoins;
isPostingOrder: boolean; isPostingOrder: boolean;
destinationAddress: string; destinationAddress: string;
destinationKind: number; destinationKind: number;
@ -62,13 +62,22 @@ export default class ReceivingAddress extends PureComponent<StateProps & ActionP
public render() { public render() {
const { destinationId, destinationAddress, isPostingOrder } = this.props; const { destinationId, destinationAddress, isPostingOrder } = this.props;
let validAddress;
// TODO - find better pattern here once currencies move beyond BTC, ETH, REP const addressValidators: { [coinOrToken: string]: (address: string) => boolean } = {
if (destinationId === 'BTC') { BTC: isValidBTCAddress,
validAddress = isValidBTCAddress(destinationAddress); XMR: isValidXMRAddress,
} else { ETH: isValidETHAddress
validAddress = isValidETHAddress(destinationAddress); };
} // 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 ( return (
<section className="SwapAddress block"> <section className="SwapAddress block">
@ -85,11 +94,7 @@ export default class ReceivingAddress extends PureComponent<StateProps & ActionP
type="text" type="text"
value={destinationAddress} value={destinationAddress}
onChange={this.onChangeDestinationAddress} onChange={this.onChangeDestinationAddress}
placeholder={ placeholder={placeholder}
destinationId === 'BTC'
? donationAddressMap[destinationId]
: donationAddressMap.ETH
}
/> />
</label> </label>
</div> </div>

View File

@ -34,6 +34,8 @@ interface ReduxStateProps {
bityOrderStatus: string | null; bityOrderStatus: string | null;
shapeshiftOrderStatus: string | null; shapeshiftOrderStatus: string | null;
paymentAddress: string | null; paymentAddress: string | null;
paymentId: string | null;
xmrPaymentAddress: string | null;
isOffline: boolean; isOffline: boolean;
} }
@ -77,6 +79,8 @@ class Swap extends Component<ReduxActionProps & ReduxStateProps & RouteComponent
shapeshiftOrderStatus, shapeshiftOrderStatus,
isPostingOrder, isPostingOrder,
outputTx, outputTx,
paymentId,
xmrPaymentAddress,
// ACTIONS // ACTIONS
initSwap, initSwap,
restartSwap, restartSwap,
@ -159,7 +163,9 @@ class Swap extends Component<ReduxActionProps & ReduxStateProps & RouteComponent
stopPollShapeshiftOrderStatus, stopPollShapeshiftOrderStatus,
showNotificationWithComponent, showNotificationWithComponent,
destinationAddress, destinationAddress,
outputTx outputTx,
paymentId,
xmrPaymentAddress
}; };
const SupportProps = { const SupportProps = {
@ -220,6 +226,8 @@ function mapStateToProps(state: AppState) {
bityOrderStatus: state.swap.bityOrderStatus, bityOrderStatus: state.swap.bityOrderStatus,
shapeshiftOrderStatus: state.swap.shapeshiftOrderStatus, shapeshiftOrderStatus: state.swap.shapeshiftOrderStatus,
paymentAddress: state.swap.paymentAddress, paymentAddress: state.swap.paymentAddress,
paymentId: state.swap.paymentId,
xmrPaymentAddress: state.swap.xmrPaymentAddress,
isOffline: getOffline(state) isOffline: getOffline(state)
}; };
} }

View File

@ -234,7 +234,8 @@ describe('swap reducer', () => {
maxLimit: 7.04575258, maxLimit: 7.04575258,
apiPubKey: apiPubKey:
'0ca1ccd50b708a3f8c02327f0caeeece06d3ddc1b0ac749a987b453ee0f4a29bdb5da2e53bc35e57fb4bb7ae1f43c93bb098c3c4716375fc1001c55d8c94c160', '0ca1ccd50b708a3f8c02327f0caeeece06d3ddc1b0ac749a987b453ee0f4a29bdb5da2e53bc35e57fb4bb7ae1f43c93bb098c3c4716375fc1001c55d8c94c160',
minerFee: '1.05' minerFee: '1.05',
sAddress: '0x055ed77933388642fdn4px9v73j4fa3582d10c4'
}; };
const swapState = reducer.swapReducer( const swapState = reducer.swapReducer(
@ -254,8 +255,10 @@ describe('swap reducer', () => {
validFor: swapState.validFor, validFor: swapState.validFor,
orderTimestampCreatedISOString: swapState.orderTimestampCreatedISOString, orderTimestampCreatedISOString: swapState.orderTimestampCreatedISOString,
paymentAddress: mockedShapeshiftOrder.deposit, paymentAddress: mockedShapeshiftOrder.deposit,
paymentId: mockedShapeshiftOrder.deposit,
shapeshiftOrderStatus: 'no_deposits', shapeshiftOrderStatus: 'no_deposits',
orderId: mockedShapeshiftOrder.orderId orderId: mockedShapeshiftOrder.orderId,
xmrPaymentAddress: mockedShapeshiftOrder.sAddress
}); });
}); });

View File

@ -42,7 +42,9 @@ export const INITIAL_STATE: types.SwapState = {
paymentAddress: null, paymentAddress: null,
validFor: null, validFor: null,
orderId: null, orderId: null,
showLiteSend: false showLiteSend: false,
paymentId: null,
xmrPaymentAddress: null
}; };
export function swapReducer(state: types.SwapState = INITIAL_STATE, action: types.SwapAction) { export function swapReducer(state: types.SwapState = INITIAL_STATE, action: types.SwapAction) {
@ -151,8 +153,8 @@ export function swapReducer(state: types.SwapState = INITIAL_STATE, action: type
}; };
case types.SwapActions.SHAPESHIFT_ORDER_CREATE_SUCCEEDED: case types.SwapActions.SHAPESHIFT_ORDER_CREATE_SUCCEEDED:
const currDate = Date.now(); const currDate = Date.now();
const secondsRemaining = Math.floor((+new Date(action.payload.expiration) - currDate) / 1000); const secondsRemaining = Math.floor((+new Date(action.payload.expiration) - currDate) / 1000);
return { return {
...state, ...state,
shapeshiftOrder: { shapeshiftOrder: {
@ -166,7 +168,10 @@ export function swapReducer(state: types.SwapState = INITIAL_STATE, action: type
orderTimestampCreatedISOString: new Date(currDate).toISOString(), orderTimestampCreatedISOString: new Date(currDate).toISOString(),
paymentAddress: action.payload.deposit, paymentAddress: action.payload.deposit,
shapeshiftOrderStatus: 'no_deposits', 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: case types.SwapActions.BITY_ORDER_STATUS_SUCCEEDED:
return { return {

View File

@ -24,6 +24,18 @@ export interface SwapState {
validFor: number | null; validFor: number | null;
orderId: string | null; orderId: string | null;
showLiteSend: boolean; 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 { export enum SwapActions {
@ -208,6 +220,7 @@ export interface ShapeshiftOrderResponse {
quotedRate: string; quotedRate: string;
withdrawal: string; withdrawal: string;
withdrawalAmount: string; withdrawalAmount: string;
sAddress?: string;
} }
export interface ShapeshiftStatusResponse { export interface ShapeshiftStatusResponse {

View File

@ -59,6 +59,12 @@ export function isValidBTCAddress(address: string): boolean {
return WalletAddressValidator.validate(address, 'BTC'); 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 { export function isValidHex(str: string): boolean {
if (str === '') { if (str === '') {
return true; return true;

View File

@ -660,6 +660,9 @@
"NETWORK_2": "network", "NETWORK_2": "network",
"PROVIDED_BY": "provided by", "PROVIDED_BY": "provided by",
"YOU_ARE_INTERACTING": "You are interacting with the", "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)", "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." "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."
} }

View File

@ -76,6 +76,7 @@ exports[`render snapshot 1`] = `
} }
outputTx={null} outputTx={null}
paymentAddress={null} paymentAddress={null}
paymentId={null}
provider="shapeshift" provider="shapeshift"
restartSwap={[Function]} restartSwap={[Function]}
secondsRemaining={null} secondsRemaining={null}
@ -100,5 +101,6 @@ exports[`render snapshot 1`] = `
stopPollBityOrderStatus={[Function]} stopPollBityOrderStatus={[Function]}
stopPollShapeshiftOrderStatus={[Function]} stopPollShapeshiftOrderStatus={[Function]}
swapProvider={[Function]} swapProvider={[Function]}
xmrPaymentAddress={null}
/> />
`; `;