Implement Add Liquidity (#77)
* CSS fixes * Add Liquidity UI and Validation * Finish Add Liquidity
This commit is contained in:
parent
a4e0d11cef
commit
509ddaeaa0
@ -2,25 +2,26 @@
|
||||
|
||||
.currency-input-panel {
|
||||
@extend %col-nowrap;
|
||||
box-shadow: 0 4px 8px 0 rgba($royal-blue, 0.1);
|
||||
position: relative;
|
||||
border-radius: 1.25rem;
|
||||
z-index: 200;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
z-index: 200;
|
||||
border-radius: 1.25rem;
|
||||
border: 0.5px solid $mercury-gray;
|
||||
box-shadow: 0 0 0 .5px $mercury-gray;
|
||||
background-color: $white;
|
||||
box-shadow: 0px 4px 4px 2px rgba($royal-blue, 0.05);
|
||||
|
||||
&--error {
|
||||
border: 0.5px solid $salmon-red;
|
||||
box-shadow: 0 0 0 .5px $salmon-red;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: $royal-blue;
|
||||
box-shadow: 0 0 .5px .5px $malibu-blue;
|
||||
}
|
||||
|
||||
&--error:focus-within {
|
||||
border-color: $salmon-red;
|
||||
box-shadow: 0 0 .5px .5px $salmon-red;
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +94,12 @@
|
||||
background-image: url(../../assets/images/dropdown.svg);
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
.currency-input-panel__dropdown-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__sub-currency-select {
|
||||
|
@ -45,10 +45,13 @@ class CurrencyInputPanel extends Component {
|
||||
selectedTokens: PropTypes.array.isRequired,
|
||||
errorMessage: PropTypes.string,
|
||||
selectedTokenAddress: PropTypes.string,
|
||||
disableTokenSelect: PropTypes.bool,
|
||||
filteredTokens: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
selectedTokens: [],
|
||||
filteredTokens: [],
|
||||
onCurrencySelected() {},
|
||||
onValueChange() {},
|
||||
selectedTokenAddress: '',
|
||||
@ -64,6 +67,7 @@ class CurrencyInputPanel extends Component {
|
||||
};
|
||||
|
||||
createTokenList = () => {
|
||||
const { filteredTokens } = this.props;
|
||||
let tokens = this.props.tokenAddresses.addresses;
|
||||
let tokenList = [ { value: 'ETH', label: 'ETH', address: 'ETH' } ];
|
||||
|
||||
@ -76,7 +80,7 @@ class CurrencyInputPanel extends Component {
|
||||
TOKEN_ADDRESS_TO_LABEL[tokens[i][1]] = tokens[i][0];
|
||||
}
|
||||
|
||||
return tokenList;
|
||||
return tokenList.filter(({ address }) => !filteredTokens.includes(address));
|
||||
};
|
||||
|
||||
onTokenSelect = (address) => {
|
||||
@ -122,7 +126,12 @@ class CurrencyInputPanel extends Component {
|
||||
renderTokenList() {
|
||||
const tokens = this.createTokenList();
|
||||
const { searchQuery } = this.state;
|
||||
const { selectedTokens } = this.props;
|
||||
const { selectedTokens, disableTokenSelect } = this.props;
|
||||
|
||||
if (disableTokenSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let results;
|
||||
|
||||
if (!searchQuery) {
|
||||
@ -196,6 +205,7 @@ class CurrencyInputPanel extends Component {
|
||||
value,
|
||||
onValueChange,
|
||||
selectedTokenAddress,
|
||||
disableTokenSelect,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@ -232,8 +242,13 @@ class CurrencyInputPanel extends Component {
|
||||
<button
|
||||
className={classnames("currency-input-panel__currency-select", {
|
||||
'currency-input-panel__currency-select--selected': selectedTokenAddress,
|
||||
'currency-input-panel__currency-select--disabled': disableTokenSelect,
|
||||
})}
|
||||
onClick={() => this.setState({ isShowingModal: true })}
|
||||
onClick={() => {
|
||||
if (!disableTokenSelect) {
|
||||
this.setState({ isShowingModal: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{
|
||||
selectedTokenAddress
|
||||
|
@ -6,14 +6,14 @@
|
||||
height: 3rem;
|
||||
background-color: $concrete-gray;
|
||||
border-radius: 3rem;
|
||||
border: 1px solid $mercury-gray;
|
||||
box-shadow: 0 0 0 .5px darken($concrete-gray, 5);
|
||||
|
||||
.tab:first-child {
|
||||
margin-left: -1px;
|
||||
//margin-left: -1px;
|
||||
}
|
||||
|
||||
.tab:last-child {
|
||||
margin-right: -1px;
|
||||
//margin-right: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,6 @@
|
||||
height: 3rem;
|
||||
flex: 1 0 auto;
|
||||
border-radius: 3rem;
|
||||
border: 1px solid transparent;
|
||||
transition: 300ms ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
@ -36,7 +35,7 @@
|
||||
&--selected {
|
||||
background-color: $white;
|
||||
border-radius: 3rem;
|
||||
border: 1px solid $mercury-gray;
|
||||
box-shadow: 0 0 .5px .5px $mercury-gray;
|
||||
font-weight: 500;
|
||||
|
||||
span {
|
||||
|
@ -17,6 +17,7 @@ export const ADD_CONTRACT = 'web3connect/addContract';
|
||||
|
||||
const initialState = {
|
||||
web3: null,
|
||||
initialized: false,
|
||||
account: '',
|
||||
balances: {
|
||||
ethereum: {},
|
||||
@ -39,7 +40,7 @@ export const selectors = () => (dispatch, getState) => {
|
||||
return {
|
||||
getBalance: address => {
|
||||
const balance = state.balances.ethereum[address];
|
||||
console.log({balance})
|
||||
|
||||
if (!balance) {
|
||||
dispatch(watchBalance({ balanceOf: address }));
|
||||
return Balance(0, 'ETH');
|
||||
@ -98,7 +99,6 @@ export const initialize = () => (dispatch, getState) => {
|
||||
|
||||
if (typeof window.web3 !== 'undefined') {
|
||||
const web3 = new Web3(window.web3.currentProvider);
|
||||
await window.ethereum.enable();
|
||||
dispatch({
|
||||
type: INITIALIZE,
|
||||
payload: web3,
|
||||
@ -108,28 +108,38 @@ export const initialize = () => (dispatch, getState) => {
|
||||
})
|
||||
};
|
||||
|
||||
export const watchBalance = ({ balanceOf, tokenAddress }) => {
|
||||
export const watchBalance = ({ balanceOf, tokenAddress }) => (dispatch, getState) => {
|
||||
if (!balanceOf) {
|
||||
return { type: '' };
|
||||
return;
|
||||
}
|
||||
|
||||
const { web3connect } = getState();
|
||||
const { watched } = web3connect;
|
||||
|
||||
if (!tokenAddress) {
|
||||
return {
|
||||
if (watched.balances.ethereum.includes(balanceOf)) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: WATCH_ETH_BALANCE,
|
||||
payload: balanceOf,
|
||||
};
|
||||
});
|
||||
} else if (tokenAddress) {
|
||||
return {
|
||||
if (watched.balances[tokenAddress] && watched.balances[tokenAddress].includes(balanceOf)) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: WATCH_TOKEN_BALANCE,
|
||||
payload: {
|
||||
tokenAddress,
|
||||
balanceOf,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const sync = () => async (dispatch, getState) => {
|
||||
const { getBalance, getTokenBalance } = dispatch(selectors());
|
||||
const web3 = await dispatch(initialize());
|
||||
const {
|
||||
account,
|
||||
@ -142,12 +152,17 @@ export const sync = () => async (dispatch, getState) => {
|
||||
if (account !== accounts[0]) {
|
||||
dispatch({ type: UPDATE_ACCOUNT, payload: accounts[0] });
|
||||
dispatch(watchBalance({ balanceOf: accounts[0] }));
|
||||
// dispatch(watchBalance({ balanceOf: accounts[0], tokenAddress: '0xDA5B056Cfb861282B4b59d29c9B395bcC238D29B' }));
|
||||
}
|
||||
|
||||
// Sync Ethereum Balances
|
||||
watched.balances.ethereum.forEach(async address => {
|
||||
const balance = await web3.eth.getBalance(address);
|
||||
const { value } = getBalance(address);
|
||||
|
||||
|
||||
if (value.isEqualTo(BN(balance))) {
|
||||
return;
|
||||
}
|
||||
dispatch({
|
||||
type: UPDATE_ETH_BALANCE,
|
||||
payload: {
|
||||
@ -178,9 +193,16 @@ export const sync = () => async (dispatch, getState) => {
|
||||
|
||||
const watchlist = watched.balances[tokenAddress] || [];
|
||||
watchlist.forEach(async address => {
|
||||
const tokenBalance = getTokenBalance(tokenAddress, address);
|
||||
const balance = await contract.methods.balanceOf(address).call();
|
||||
const decimals = await contract.methods.decimals().call();
|
||||
const symbol = await contract.methods.symbol().call();
|
||||
const decimals = tokenBalance.decimals || await contract.methods.decimals().call();
|
||||
const symbol = tokenBalance.label || await contract.methods.symbol().call();
|
||||
|
||||
if (tokenBalance.value.isEqualTo(BN(balance))) {
|
||||
console.log('block');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_TOKEN_BALANCE,
|
||||
payload: {
|
||||
@ -200,7 +222,6 @@ export const startWatching = () => async (dispatch, getState) => {
|
||||
: 5000;
|
||||
|
||||
dispatch(sync());
|
||||
|
||||
setTimeout(() => dispatch(startWatching()), timeout);
|
||||
};
|
||||
|
||||
|
@ -11,7 +11,7 @@ window.addEventListener('load', function() {
|
||||
<DrizzleProvider options={{
|
||||
contracts: [],
|
||||
events: [],
|
||||
polls: { accounts: 3000, blocks: 3000 },
|
||||
polls: { accounts: 60000, blocks: 60000 },
|
||||
}} store={store}>
|
||||
<App />
|
||||
</DrizzleProvider>
|
||||
|
@ -8,7 +8,9 @@ import { selectors, sync } from '../../ducks/web3connect';
|
||||
import ArrowDown from '../../assets/images/arrow-down-blue.svg';
|
||||
import ModeSelector from './ModeSelector';
|
||||
import {BigNumber as BN} from 'bignumber.js';
|
||||
import EXCHANGE_ABI from '../../abi/exchange';
|
||||
import "./pool.scss";
|
||||
import promisify from "../../helpers/web3-promisfy";
|
||||
|
||||
const INPUT = 0;
|
||||
const OUTPUT = 1;
|
||||
@ -18,6 +20,7 @@ class AddLiquidity extends Component {
|
||||
isConnected: PropTypes.bool.isRequired,
|
||||
account: PropTypes.string.isRequired,
|
||||
selectors: PropTypes.func.isRequired,
|
||||
balances: PropTypes.object.isRequired,
|
||||
exchangeAddresses: PropTypes.shape({
|
||||
fromToken: PropTypes.object.isRequired,
|
||||
}).isRequired,
|
||||
@ -26,11 +29,27 @@ class AddLiquidity extends Component {
|
||||
state = {
|
||||
inputValue: '',
|
||||
outputValue: '',
|
||||
inputCurrency: '',
|
||||
inputCurrency: 'ETH',
|
||||
outputCurrency: '',
|
||||
lastEditedField: '',
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const { isConnected, account, exchangeAddresses, balances, web3 } = this.props;
|
||||
const { inputValue, outputValue, inputCurrency, outputCurrency, lastEditedField } = this.state;
|
||||
|
||||
return isConnected !== nextProps.isConnected ||
|
||||
account !== nextProps.account ||
|
||||
exchangeAddresses !== nextProps.exchangeAddresses ||
|
||||
web3 !== nextProps.web3 ||
|
||||
balances !== nextProps.balances ||
|
||||
inputValue !== nextState.inputValue ||
|
||||
outputValue !== nextState.outputValue ||
|
||||
inputCurrency !== nextState.inputCurrency ||
|
||||
outputCurrency !== nextState.outputCurrency ||
|
||||
lastEditedField !== nextState.lastEditedField;
|
||||
}
|
||||
|
||||
getBalance(currency) {
|
||||
const { selectors, account } = this.props;
|
||||
|
||||
@ -47,6 +66,34 @@ class AddLiquidity extends Component {
|
||||
return `Balance: ${value.dividedBy(10 ** decimals).toFixed(4)}`;
|
||||
}
|
||||
|
||||
onAddLiquidity = async () => {
|
||||
const { account, web3, exchangeAddresses: { fromToken }, selectors } = this.props;
|
||||
const { inputValue, outputValue, outputCurrency } = this.state;
|
||||
const exchange = new web3.eth.Contract(EXCHANGE_ABI, fromToken[outputCurrency]);
|
||||
|
||||
const ethAmount = BN(inputValue).multipliedBy(10 ** 18);
|
||||
const { decimals } = selectors().getTokenBalance(outputCurrency, fromToken[outputCurrency]);
|
||||
const tokenAmount = BN(outputValue).multipliedBy(10 ** decimals);
|
||||
const { value: ethReserve } = selectors().getBalance(fromToken[outputCurrency]);
|
||||
const totalLiquidity = await exchange.methods.totalSupply().call();
|
||||
const liquidityMinted = BN(totalLiquidity).multipliedBy(ethAmount.dividedBy(ethReserve));
|
||||
const blockNumber = await promisify(web3, 'getBlockNumber');
|
||||
const block = await promisify(web3, 'getBlock', blockNumber);
|
||||
const deadline = block.timestamp + 300;
|
||||
const MAX_LIQUIDITY_SLIPPAGE = 0.025;
|
||||
const minLiquidity = liquidityMinted.multipliedBy(1 - MAX_LIQUIDITY_SLIPPAGE);
|
||||
const maxTokens = tokenAmount.multipliedBy(1 + MAX_LIQUIDITY_SLIPPAGE);
|
||||
|
||||
try {
|
||||
const tx = await exchange.methods.addLiquidity(minLiquidity.toFixed(0), maxTokens.toFixed(0), deadline).send({
|
||||
from: account,
|
||||
value: ethAmount.toFixed(0)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
onInputChange = value => {
|
||||
const { inputCurrency, outputCurrency } = this.state;
|
||||
const exchangeRate = this.getExchangeRate();
|
||||
@ -103,6 +150,39 @@ class AddLiquidity extends Component {
|
||||
return tokenValue.dividedBy(ethValue);
|
||||
}
|
||||
|
||||
validate() {
|
||||
const { selectors, account } = this.props;
|
||||
const {
|
||||
inputValue, outputValue,
|
||||
inputCurrency, outputCurrency,
|
||||
} = this.state;
|
||||
|
||||
let inputError;
|
||||
let outputError;
|
||||
let isValid = true;
|
||||
|
||||
if (!inputValue || !outputValue || !inputCurrency || !outputCurrency) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
const { value: ethValue } = selectors().getBalance(account);
|
||||
const { value: tokenValue, decimals } = selectors().getTokenBalance(outputCurrency, account);
|
||||
|
||||
if (ethValue.isLessThan(BN(inputValue * 10 ** 18))) {
|
||||
inputError = 'Insufficient Balance';
|
||||
}
|
||||
|
||||
if (tokenValue.isLessThan(BN(outputValue * 10 ** decimals))) {
|
||||
outputError = 'Insufficient Balance';
|
||||
}
|
||||
|
||||
return {
|
||||
inputError,
|
||||
outputError,
|
||||
isValid: isValid && !inputError && !outputError,
|
||||
};
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
const { selectors, exchangeAddresses: { fromToken } } = this.props;
|
||||
const { inputCurrency, outputCurrency } = this.state;
|
||||
@ -136,11 +216,71 @@ class AddLiquidity extends Component {
|
||||
<div className="pool__summary-panel">
|
||||
<div className="pool__exchange-rate-wrapper">
|
||||
<span className="pool__exchange-rate">Exchange Rate</span>
|
||||
<span>{`1 ETH = ${tokenValue.dividedBy(ethValue).toFixed(4)} BAT`}</span>
|
||||
<span>{`1 ETH = ${tokenValue.dividedBy(ethValue).toFixed(4)} ${label}`}</span>
|
||||
</div>
|
||||
<div className="pool__exchange-rate-wrapper">
|
||||
<span className="swap__exchange-rate">Current Pool Size</span>
|
||||
<span>{` ${ethValue.dividedBy(10 ** 18).toFixed(2)} ${eth} / ${tokenValue.dividedBy(10 ** decimals).toFixed(2)} ${label}`}</span>
|
||||
<span>{` ${ethValue.dividedBy(10 ** 18).toFixed(2)} ${eth} + ${tokenValue.dividedBy(10 ** decimals).toFixed(2)} ${label}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderSummary() {
|
||||
const { selectors, exchangeAddresses: { fromToken } } = this.props;
|
||||
const {
|
||||
inputValue,
|
||||
outputValue,
|
||||
inputCurrency,
|
||||
outputCurrency,
|
||||
} = this.state;
|
||||
|
||||
if (!inputCurrency || !outputCurrency) {
|
||||
return (
|
||||
<div className="swap__summary-wrapper">
|
||||
<div>Select a token to continue.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (inputCurrency === outputCurrency) {
|
||||
return (
|
||||
<div className="swap__summary-wrapper">
|
||||
<div>Must be different token.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (![inputCurrency, outputCurrency].includes('ETH')) {
|
||||
return (
|
||||
<div className="swap__summary-wrapper">
|
||||
<div>One of the input must be ETH.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { value, decimals, label } = selectors().getTokenBalance(outputCurrency, fromToken[outputCurrency]);
|
||||
|
||||
if (!inputValue || !outputValue) {
|
||||
return (
|
||||
<div className="swap__summary-wrapper">
|
||||
<div>{`Enter a ${inputCurrency} or ${label} value to continue.`}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SLIPPAGE = 0.025;
|
||||
const minOutput = BN(outputValue).multipliedBy(1 - SLIPPAGE);
|
||||
const maxOutput = BN(outputValue).multipliedBy(1 + SLIPPAGE);
|
||||
const tokenReserve = value.dividedBy(10 ** decimals);
|
||||
const minPercentage = minOutput.dividedBy(minOutput.plus(tokenReserve)).multipliedBy(100);
|
||||
const maxPercentage = maxOutput.dividedBy(maxOutput.plus(tokenReserve)).multipliedBy(100);
|
||||
|
||||
return (
|
||||
<div className="swap__summary-wrapper">
|
||||
<div>You are adding between {b(`${minOutput.toFixed(2)} - ${maxOutput.toFixed(2)} ${label}`)} + {b(`${BN(inputValue).toFixed(2)} ETH`)} into the liquidity pool.</div>
|
||||
<div className="pool__last-summary-text">
|
||||
You will receive between {b(`${minPercentage.toFixed(2)}%`)} and {b(`${maxPercentage.toFixed(2)}%`)} of the {`${label}/ETH`} pool tokens.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -159,19 +299,23 @@ class AddLiquidity extends Component {
|
||||
lastEditedField,
|
||||
} = this.state;
|
||||
|
||||
const { inputError, outputError, isValid } = this.validate();
|
||||
|
||||
return (
|
||||
<div className={classnames('swap__content', { 'swap--inactive': !isConnected })}>
|
||||
<div
|
||||
className={classnames('swap__content', {
|
||||
'swap--inactive': !isConnected,
|
||||
})}
|
||||
>
|
||||
<ModeSelector />
|
||||
<CurrencyInputPanel
|
||||
title="Deposit"
|
||||
description={lastEditedField === OUTPUT ? '(estimated)' : ''}
|
||||
extraText={this.getBalance(inputCurrency)}
|
||||
onCurrencySelected={currency => {
|
||||
this.setState({ inputCurrency: currency });
|
||||
this.props.sync();
|
||||
}}
|
||||
onValueChange={this.onInputChange}
|
||||
selectedTokenAddress="ETH"
|
||||
value={inputValue}
|
||||
errorMessage={inputError}
|
||||
disableTokenSelect
|
||||
/>
|
||||
<OversizedPanel>
|
||||
<div className="swap__down-arrow-background">
|
||||
@ -180,32 +324,32 @@ class AddLiquidity extends Component {
|
||||
</OversizedPanel>
|
||||
<CurrencyInputPanel
|
||||
title="Deposit"
|
||||
description={lastEditedField === INPUT ? '(estimated)' : ''}
|
||||
description="(estimated)"
|
||||
extraText={this.getBalance(outputCurrency)}
|
||||
selectedTokenAddress={outputCurrency}
|
||||
onCurrencySelected={currency => {
|
||||
this.setState({ outputCurrency: currency });
|
||||
this.props.sync();
|
||||
}}
|
||||
onValueChange={this.onOutputChange}
|
||||
value={outputValue}
|
||||
errorMessage={outputError}
|
||||
filteredTokens={[ 'ETH' ]}
|
||||
/>
|
||||
<OversizedPanel hideBottom>
|
||||
{ this.renderInfo() }
|
||||
</OversizedPanel>
|
||||
<div className="swap__summary-wrapper">
|
||||
<div>You are adding between {b`212000.00 - 216000.00 BAT`} + {b`166.683543 ETH`} into the liquidity pool.</div>
|
||||
<div className="pool__last-summary-text">You will receive between {b`66%`} and {b`67%`} of the BAT/ETH pool tokens.</div>
|
||||
</div>
|
||||
{ this.renderSummary() }
|
||||
<div className="pool__cta-container">
|
||||
<button
|
||||
className={classnames('pool__cta-btn', {
|
||||
'swap--inactive': !this.props.isConnected,
|
||||
'pool__cta-btn--inactive': !this.props.isValid,
|
||||
'pool__cta-btn--inactive': !isValid,
|
||||
})}
|
||||
disabled={!this.props.isValid}
|
||||
onClick={this.onSwap}
|
||||
disabled={!isValid}
|
||||
onClick={this.onAddLiquidity}
|
||||
>
|
||||
Swap
|
||||
Add Liquidity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -219,6 +363,7 @@ export default drizzleConnect(
|
||||
isConnected: Boolean(state.web3connect.account),
|
||||
account: state.web3connect.account,
|
||||
balances: state.web3connect.balances,
|
||||
web3: state.web3connect.web3,
|
||||
exchangeAddresses: state.addresses.exchangeAddresses,
|
||||
}),
|
||||
dispatch => ({
|
||||
|
@ -11,6 +11,7 @@ $mine-shaft-gray: #2B2B2B;
|
||||
|
||||
// Blue
|
||||
$zumthor-blue: #EBF4FF;
|
||||
$malibu-blue: #5CA2FF;
|
||||
$royal-blue: #2F80ED;
|
||||
|
||||
// Purple
|
||||
|
Loading…
Reference in New Issue
Block a user