Skip to content

Instantly share code, notes, and snippets.

@mqklin
Created March 18, 2019 16:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mqklin/a791ea57e3da9beb999e329e521c2395 to your computer and use it in GitHub Desktop.
Save mqklin/a791ea57e3da9beb999e329e521c2395 to your computer and use it in GitHub Desktop.
import React, {
useEffect,
} from 'react';
import styled, {css} from 'styled-components';
import {forbidExtraProps} from 'airbnb-prop-types';
import {ButtonContainer, SelectAssetModal} from 'App/dumb';
import {func, string, object} from 'prop-types';
import {withContext} from 'withContext';
import Values from './Values';
import payFeeAssetsRaw from './payFeeAssetsRaw';
import {
useInterval,
useStateReducer,
FetchTokenaryService,
} from 'App/utils';
import {cdpPortalProxy} from 'src/Root/methods/node/services/cdp/contracts';
const Wr = styled.div`
background: #2D368A;
border-radius: 0px 8px 8px 0px;
height: 100%;
padding: 24px 16px 0;
position: relative;
@media (max-width: 768px) {
border-radius: 0;
}
`;
const Header = styled.div`
line-height: 24px;
font-size: 20px;
color: #FFFFFF;
position: relative;
`;
const CrossButton = styled(ButtonContainer)`
position: absolute;
width: 24px;
height: 24px;
background-image: url(${require('./images/cross.svg')});
top: -16px;
right: 0;
transform: translateX(50%);
@media (max-width: 768px) {
top: 0;
right: 20px;
transform: none;
}
`;
const CancelButton = styled(ButtonContainer)`
position: absolute;
top: 0;
right: 0;
line-height: 24px;
font-size: 14px;
color: #EB5757;
width: auto;
`;
const InputPlace = styled.div`
height: 66px;
position: relative;
width: 100%;
margin-top: ${props => props.theSecondOne ? 24 : 32}px;
`;
const InputWr = styled.div`
position: absolute;
width: 100%;
`;
const InputLabel = styled.div`
line-height: 16px;
font-size: 12px;
color: #FFFFFF;
position: relative;
`;
const InputActionButton = styled(
({withCheck, isChecked, isDisabled, ...props}) => <ButtonContainer {...props}/>,
)`
${css`
width: auto;
line-height: 16px;
font-size: 12px;
text-align: right;
text-decoration-line: underline;
color: #31B5FF;
position: absolute;
right: 0;
${props => props.isDisabled && css`
pointer-events: none;
opacity: 0.5;
`};
${props => props.withCheck && css`
padding-right: 24px;
&::before, &::after {
content: '';
position: absolute;
}
&::before {
background-image: url(${require('./images/check-placeholder.svg')});
width: 16px;
height: 16px;
right: 0;
}
&::after {
display: ${props => !props.isChecked && 'none'};
background-image: url(${require('./images/check.svg')});
width: 12px;
height: 9px;
right: 2px;
top: 50%;
transform: translateY(-50%);
}
`};
`}
`;
const Input = styled.input`
background: #FFFFFF;
border: 1px solid #DFE4E8;
border-radius: 4px;
font-family: Roboto !important;
font-weight: 500;
line-height: 24px;
font-size: 16px;
color: #232F55;
padding: 7px 16px;
width: 100%;
margin-top: 8px;
outline: none;
${props => props.isDisabled && css`
pointer-events: none;
color: rgba(35, 47, 85, 0.6);
`};
${props => props.withTokenSelect && css`
border-top-left-radius: 0;
border-bottom-left-radius: 0;
margin-left: 73px;
padding-left: 12px;
width: calc(100% - 74px);
`};
`;
const InputTokenSelectButton = styled(
({icon_url, ...props}) => <ButtonContainer {...props}/>,
)`
${css`
position: absolute;
line-height: 24px;
font-size: 14px;
color: #010101;
background: #EFF0F9;
border: 1px solid rgba(223, 228, 232, 0.6);
box-sizing: border-box;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
bottom: 0;
left: 0;
height: 40px;
width: 74px;
padding-left: 28px;
padding-right: 17px;
&::before, &::after {
content: '';
position: absolute;
}
&::before {
left: 5px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
background-image: url(${props => props.icon_url});
background-size: cover;
}
&::after {
right: 4px;
top: 50%;
transform: translateY(-50%);
width: 9px;
height: 5px;
background-image: url(${require('./images/select-token-arrow.svg')});
}
`}
`;
const EnableButton = styled(
({showSpinner, ...props}) => <ButtonContainer {...props}/>,
)`
${css`
position: absolute;
bottom: 8px;
right: 8px;
background: #f0f1fa;
border: 1px solid rgba(45, 54, 138, 0.1);
box-sizing: border-box;
border-radius: 16px;
width: 76px;
height: 24px;
&::before, &::after {
position: absolute;
}
&::before {
content: '';
background-image: url(${require('./images/lock.svg')});
width: 8px;
height: 10px;
left: 14px;
top: 6px;
${props => props.showSpinner && css`
left: 13px;
top: 5px;
width: 12px;
height: 12px;
background: url(${require('./images/spinner.png')}) no-repeat;
background-size: cover;
@keyframes spinner {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
animation: spinner .6s linear infinite;
`};
}
&::after {
content: 'Enable';
font-weight: 500;
line-height: 16px;
font-size: 11px;
color: #2D368A;
top: 4px;
right: 11px;
}
`}
`;
const ValuesWr = styled.div`
margin-top: 28px;
`;
const SubmitButton = styled(
({showSpinner, ...props}) => <ButtonContainer {...props}/>,
)`
${css`
background: #31B5FF;
border-radius: 6px;
text-align: center;
padding: 8px 0;
margin-top: 24px;
&:disabled {
opacity: 0.8;
}
span {
line-height: 24px;
font-size: 18px;
color: #FFFFFF;
position: relative;
&::before {
display: ${props => !props.showSpinner && 'none'};
content: '';
position: absolute;
top: 50%;
margin-top: -8px;
left: -28px;
width: 16px;
height: 16px;
background: url(${require('./images/spinner.png')}) no-repeat;
background-size: cover;
@keyframes spinner {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
animation: spinner .6s linear infinite;
}
}
`};
`;
const TransactionFee = styled.div`
margin-top: 16px;
text-align: center;
.label {
line-height: 16px;
font-size: 14px;
color: #C0C3DC;
}
.value {
margin-left: 9px;
font-family: Roboto !important;
font-weight: 500;
line-height: 16px;
font-size: 14px;
color: #FFFFFF;
}
`;
const BottomError = styled.div`
background: #F27D6D;
border-radius: 4px;
line-height: 16px;
font-size: 12px;
padding: 4px 0 4px 33px;
color: #FFFFFF;
position: relative;
margin-top: 16px;
&::before {
content: '';
position: absolute;
width: 12px;
height: 12px;
top: 50%;
transform: translateY(-50%);
left: 17px;
background-image: url(${require('./images/error.svg')});
}
`;
const FIRST_TIME_BORROWING_ERROR = 'first-time-borrowing-error';
export default withContext(
Form,
[
'swCdpId',
'selectedWalletAssets',
'assetsPrices',
'swDsProxyAddress',
'cdpService',
'sendTransaction',
'swCdpInfo',
'getAverageGasPrice',
'estimateGas',
'setSwCdpTx',
'swCdpTx',
'setSwCdpUnlockTx',
'swCdpUnlockTxs',
'getTxReceipt',
'showNotification',
'erc20Service',
'getNonce',
],
);
Form.propTypes = forbidExtraProps({
onClose: func.isRequired,
sendTransaction: func.isRequired,
swCdpId: string,
selectedWalletAssets: object,
assetsPrices: object.isRequired,
selectedButtonName: string,
swDsProxyAddress: string,
cdpService: object.isRequired,
swCdpInfo: object,
getAverageGasPrice: func.isRequired,
estimateGas: func.isRequired,
setSwCdpUnlockTx: func.isRequired,
getTxReceipt: func.isRequired,
getNonce: func.isRequired,
showNotification: func.isRequired,
setSwCdpTx: func.isRequired,
swCdpTx: object,
swCdpUnlockTxs: object,
erc20Service: object.isRequired,
});
const estimatingTimesRef = {value: 0};
function Form({
getTxReceipt,
onClose,
swCdpId,
selectedWalletAssets,
assetsPrices,
swDsProxyAddress,
selectedButtonName,
sendTransaction,
swCdpInfo,
getAverageGasPrice,
estimateGas,
showNotification,
setSwCdpTx,
swCdpTx,
setSwCdpUnlockTx,
swCdpUnlockTxs,
getNonce,
cdpService: {
createOpen,
createOpenDeposit,
createOpenDepositBorrow,
open,
openDeposit,
openDepositBorrow,
deposit,
borrow,
repay,
close,
withdraw,
repay_feeDAI,
close_feeDAI,
},
erc20Service,
}) {
const [{
isAllowancesFetched = false,
tokensAllowances = Object.keys(payFeeAssetsRaw).reduce(
(acc, code) => {
acc[code] = false;
return acc;
},
{[w.DAI_CODE]: false},
),
tokensPendingAllowances = Object.keys(swCdpUnlockTxs),
isCloseCdpChecked = swCdpTx ? swCdpTx.isCloseCdpChecked : false,
fInputValue = swCdpTx ? swCdpTx.fInputValue : '0',
sInputValue = swCdpTx ? swCdpTx.sInputValue : '0',
isSelectAssetModalShown = false,
selectedAssetCode = Object.keys(payFeeAssetsRaw)[0],
tokensAreFetched = false,
cannotEstimateGasError = false,
isGasEstimating = false,
gasEstimation = swCdpTx ? swCdpTx.gasEstimation : 0,
gasPrice = swCdpTx ? swCdpTx.gasPrice : 0,
throwFetchError = null,
payFeeAssets = {},
}, ss] = useStateReducer();
selectedButtonName = swCdpTx ? swCdpTx.selectedButtonName : selectedButtonName;
const isFormSubmitting = Boolean(swCdpTx);
if (throwFetchError) {
throw throwFetchError;
}
const CREATING_FOR_THE_FIRST_TIME = Number(swDsProxyAddress) === 0;
const USER_HAS_NO_CDP = swCdpId === null;
const USER_HAS_CDP = !USER_HAS_NO_CDP;
const [DEPOSIT, WITHDRAW, BORROW] = ['deposit', 'withdraw', 'borrow'].map(s => s === selectedButtonName);
const [REPAY, CLOSE] = [!isCloseCdpChecked, isCloseCdpChecked].map(b => selectedButtonName === 'repay' && b);
const getTxParams = async () => {
try {
const [value, jam, wad] = (() => {
if (USER_HAS_NO_CDP) {
return [fInputValue, undefined, sInputValue];
}
return {
[DEPOSIT]: [fInputValue],
[WITHDRAW]: [undefined, fInputValue],
[BORROW || REPAY || CLOSE]: [undefined, undefined, fInputValue],
}[true];
})();
const data = (() => {
const inputsType = [Number(fInputValue) !== 0, Number(sInputValue) !== 0].join('|');
if (CREATING_FOR_THE_FIRST_TIME) {
if (inputsType === 'false|true') {
throw FIRST_TIME_BORROWING_ERROR;
}
return {
'false|false': createOpen(),
'true|false': createOpenDeposit(),
'true|true': createOpenDepositBorrow(wad),
}[inputsType];
}
if (USER_HAS_NO_CDP) {
if (inputsType === 'false|true') {
throw FIRST_TIME_BORROWING_ERROR;
}
return {
'false|false': open(),
'true|false': openDeposit(),
'true|true': openDepositBorrow(wad),
}[inputsType];
}
return {
[DEPOSIT]: deposit(),
[BORROW]: borrow(wad),
[REPAY]: repay(wad),
[CLOSE]: close(),
[REPAY && selectedAssetCode === w.DAI_CODE]: repay_feeDAI(wad),
[CLOSE && selectedAssetCode === w.DAI_CODE]: close_feeDAI(),
[WITHDRAW]: withdraw(jam),
}[true];
})();
const params = {
to: CREATING_FOR_THE_FIRST_TIME ? cdpPortalProxy.address : swDsProxyAddress,
value: web3Utils.toWei(value),
data,
};
return params;
}
catch (e) {
if (e === FIRST_TIME_BORROWING_ERROR) {
ss({cannotEstimateGasError: true});
}
else {
console.error(e); // eslint-disable-line no-console
}
throw e;
}
};
const handleInputChange = (inputNumber, {target: {value}}) => {
if (!/^$|^\d+\.?\d{0,18}$/g.test(value)) {
return;
}
if (/^0+$/.test(value)) {
value = '0';
}
else if (/^0\d+$/.test(value)) {
value = value.slice(1);
}
ss({[`${inputNumber}InputValue`]: value});
};
const handleSubmitButtonClick = async () => {
try {
const params = await getTxParams();
params.gas = web3Utils.toWei(gasEstimation);
params.gasPrice = gasPrice;
const txHash = await sendTransaction(params);
const nonce = await getNonce();
setSwCdpTx({
hash: txHash,
nonce,
selectedButtonName,
isCloseCdpChecked,
fInputValue,
sInputValue,
gasEstimation,
gasPrice,
});
}
catch (e) {
if (e === 'USER_CANCELLED_CONNECTING_SELECTED_WALLET' || e.message === 'Error: MetaMask Tx Signature: User denied transaction signature.') {
return;
}
console.error(e); // eslint-disable-line no-console
ss({throwFetchError: e});
}
};
const formHeader = !swCdpId
? 'Open CDP'
: {
borrow: 'Borrow DAI',
repay: 'Repay DAI',
deposit: 'Deposit ETH',
withdraw: 'Withdraw ETH',
}[selectedButtonName]
;
const {
header,
fInput,
sInput,
} = {
header: formHeader,
fInput: (() => {
const label = (
<InputLabel>
{(() => {
if (USER_HAS_NO_CDP) {
return 'Deposit ETH';
}
return formHeader;
})()}
<InputActionButton
isDisabled={isFormSubmitting}
{...(() => {
if (REPAY || CLOSE) {
return {
children: 'Close CDP',
onClick: () => ss({
isCloseCdpChecked: !isCloseCdpChecked,
fInputValue: isCloseCdpChecked ? '0' : String(web3Utils.fromWei(swCdpInfo.debt)),
sInputValue: '0',
gasEstimation: 0,
gasPrice: 0,
cannotEstimateGasError: false,
}),
isChecked: isCloseCdpChecked,
withCheck: true,
};
}
return {
children: null,
};
})()}
/>
</InputLabel>
);
return {
label,
isDisabled: CLOSE || isFormSubmitting,
};
})(),
sInput: (() => {
if (USER_HAS_NO_CDP) {
return {
label: (
<InputLabel>
Borrow DAI
</InputLabel>
),
isDisabled: isFormSubmitting,
};
}
if (REPAY || CLOSE) {
return {
label: (
<InputLabel>
Stability Fee
</InputLabel>
),
isDisabled: true,
};
}
return null;
})(),
};
const handleUnlockClick = async code => {
try {
ss({tokensPendingAllowances: [...tokensPendingAllowances, code]});
const params = erc20Service.getApproveParams(code, swDsProxyAddress);
const gas = await estimateGas(params);
const gasPrice = await getAverageGasPrice();
params.gas = gas;
params.gasPrice = gasPrice;
const txHash = await sendTransaction(params);
const nonce = await getNonce();
setSwCdpUnlockTx(code, {
hash: txHash,
nonce,
});
}
catch (e) {
ss({tokensPendingAllowances: tokensPendingAllowances.filter(_code => _code !== code)});
if (e === 'USER_CANCELLED_CONNECTING_SELECTED_WALLET' || e.message === 'Error: MetaMask Tx Signature: User denied transaction signature.') {
return;
}
console.error(e); // eslint-disable-line no-console
ss({throwFetchError: e});
}
};
const fetchAllowances = async () => {
try {
const codes = Object.keys(payFeeAssetsRaw);
const promises = codes.map(code => erc20Service.getIsAllowed(code, swDsProxyAddress));
const allowances = await Promise.all(promises);
const isDaiAllowed = await erc20Service.getIsAllowed(w.DAI_CODE, swDsProxyAddress);
ss({
tokensAllowances: allowances.reduce(
(acc, isAllowed, idx) => {
acc[codes[idx]] = isAllowed;
return acc;
},
{[w.DAI_CODE]: isDaiAllowed},
),
isAllowancesFetched: true,
});
}
catch (e) {
console.error(e); // eslint-disable-line no-console
ss({throwFetchError: e});
}
};
useInterval(
() => {
Object.entries(swCdpUnlockTxs).forEach(
async ([code, txHash]) => {
try {
const tx = await getTxReceipt(txHash);
if (!tx) {
const currentNonce = await getNonce();
if (currentNonce > swCdpTx.nonce) {
setSwCdpUnlockTx(code, null);
ss({tokensPendingAllowances: tokensPendingAllowances.filter(_code => _code !== code)});
fetchAllowances();
}
return;
}
const {status} = tx;
if (status === '0x0') {
showNotification({status: 'error', message: 'CDP unlock tx failed. Please, try again.'});
}
if (status === '0x1') {
setSwCdpUnlockTx(code, null);
ss({tokensPendingAllowances: tokensPendingAllowances.filter(_code => _code !== code)});
ss({tokensAllowances: {...tokensAllowances, [code]: true}});
}
}
catch (e) {
console.error(e); // eslint-disable-line no-console
if (e.message.includes('JsonRpcEngine - response has no error or result for request')) {
return;
}
ss({throwFetchError: e});
}
},
);
},
3000,
);
useEffect(() => {
fetchAllowances();
}, []);
useEffect(() => {
if (swCdpTx) {
return;
}
ss({
fInputValue: '0',
sInputValue: '0',
isCloseCdpChecked: false,
gasEstimation: 0,
gasPrice: 0,
cannotEstimateGasError: false,
});
}, [selectedButtonName]);
useEffect(() => {
if (swCdpTx) {
return;
}
estimatingTimesRef.value++;
if (USER_HAS_CDP && !CLOSE && Number(fInputValue) === 0) {
ss({
cannotEstimateGasError: false,
isGasEstimating: false,
gasEstimation: 0,
gasPrice: 0,
});
return;
}
(async () => {
try {
ss({isGasEstimating: true});
const params = await getTxParams();
const estimatingTimes = estimatingTimesRef.value;
try {
const gas = await estimateGas(params);
const gasPrice = await getAverageGasPrice();
if (estimatingTimes === estimatingTimesRef.value) {
ss({
gasEstimation: web3Utils.fromWei((gas * 1.2).toFixed(0)),
gasPrice: (gasPrice * 1.2).toFixed(0),
cannotEstimateGasError: false,
isGasEstimating: false,
});
}
}
catch (e) {
if (estimatingTimes === estimatingTimesRef.value) {
ss({
cannotEstimateGasError: true,
isGasEstimating: false,
});
}
}
}
catch (e) {
if (e !== FIRST_TIME_BORROWING_ERROR) {
console.error(e); // eslint-disable-line no-console
}
}
})();
}, [fInputValue, sInputValue, isCloseCdpChecked, tokensAllowances]);
useEffect(() => {
if (selectedWalletAssets !== undefined && assetsPrices.eth !== undefined && !tokensAreFetched ) {
ss({tokensAreFetched: true});
FetchTokenaryService.tokens(Object.keys(payFeeAssetsRaw))
.then(tokens => {
ss({
payFeeAssets: Object.keys(tokens).reduce(
(acc, code) => {
acc[code] = {
decimals: tokens[code].decimals,
icon_url: payFeeAssetsRaw[code].icon_url,
price_usd: tokens[code].price_usd,
symbol: payFeeAssetsRaw[code].symbol,
name: tokens[code].title,
quantity: selectedWalletAssets[code] && selectedWalletAssets[code].quantity || 0,
};
acc[code].decimaledQuantity = web3Utils.toBigNumber(10 ** -acc[code].decimals).mul(String(acc[code].quantity));
return acc;
},
{...payFeeAssets},
),
});
})
.catch(e => {
console.error(e); // eslint-disable-line no-console
ss({throwFetchError: e});
})
;
}
});
return (
<>
{isSelectAssetModalShown &&
<SelectAssetModal
title="Pay with"
onClose={() => ss({isSelectAssetModalShown: false})}
assets={payFeeAssets}
onSelect={code => ss({selectedAssetCode: code, isSelectAssetModalShown: false})}
selectedAssetCode={selectedAssetCode}
/>
}
<Wr>
<Header>
{header}
{swCdpTx
? null
: USER_HAS_NO_CDP
? <CancelButton onClick={onClose}>Cancel</CancelButton>
: <CrossButton onClick={onClose}/>
}
</Header>
<InputPlace>
<InputWr>
{fInput.label}
<Input
value={swCdpTx ? swCdpTx.fInputValue : fInputValue}
onChange={(...args) => handleInputChange('f', ...args)}
isDisabled={fInput.isDisabled}
onBlur={() => fInputValue === '' && ss({fInputValue: '0'})}
/>
{(REPAY || CLOSE) && Number(fInputValue) !== 0 && isAllowancesFetched && !tokensAllowances[w.DAI_CODE] &&
<EnableButton
disabled={tokensPendingAllowances.includes(w.DAI_CODE)}
showSpinner={tokensPendingAllowances.includes(w.DAI_CODE)}
onClick={() => handleUnlockClick(w.DAI_CODE)}
/>
}
</InputWr>
</InputPlace>
<InputPlace theSecondOne>
{sInput && (
<InputWr>
{sInput.label}
{(REPAY || CLOSE) && tokensAreFetched &&
<InputTokenSelectButton
onClick={() => ss({isSelectAssetModalShown: true})}
icon_url={payFeeAssetsRaw[selectedAssetCode].icon_url}
>
{payFeeAssetsRaw[selectedAssetCode].symbol}
</InputTokenSelectButton>
}
<Input
value={(REPAY || CLOSE) ? '3.5%' : swCdpTx ? swCdpTx.sInputValue : sInputValue}
onChange={(...args) => handleInputChange('s', ...args)}
isDisabled={sInput.isDisabled}
onBlur={() => sInputValue === '' && ss({sInputValue: '0'})}
withTokenSelect={REPAY || CLOSE}
/>
{(REPAY || CLOSE) && isAllowancesFetched && !tokensAllowances[selectedAssetCode] &&
<EnableButton
disabled={tokensPendingAllowances.includes(selectedAssetCode)}
showSpinner={tokensPendingAllowances.includes(selectedAssetCode)}
onClick={() => handleUnlockClick(selectedAssetCode)}
/>
}
</InputWr>
)}
</InputPlace>
<ValuesWr>
<Values
debt={
USER_HAS_NO_CDP
? sInputValue || 0
: {
[true]: String(web3Utils.fromWei(swCdpInfo.debt)),
[BORROW]: String(web3Utils.toBigNumber(web3Utils.fromWei(swCdpInfo.debt)).add(fInputValue || 0)),
[REPAY]: String(web3Utils.toBigNumber(web3Utils.fromWei(swCdpInfo.debt)).sub(fInputValue || 0)),
}[true]
}
collateral={
USER_HAS_NO_CDP
? fInputValue || 0
: {
[true]: String(web3Utils.fromWei(swCdpInfo.collateral.mul(String(swCdpInfo.makerPer)))),
[DEPOSIT]: String(web3Utils.toBigNumber(web3Utils.fromWei(swCdpInfo.collateral.mul(String(swCdpInfo.makerPer)))).add(fInputValue || 0)),
[WITHDRAW]: String(web3Utils.toBigNumber(web3Utils.fromWei(swCdpInfo.collateral.mul(String(swCdpInfo.makerPer)))).sub(fInputValue || 0)),
}[true]
}
/>
</ValuesWr>
<SubmitButton
onClick={handleSubmitButtonClick}
showSpinner={isFormSubmitting}
disabled={swCdpId && (!isAllowancesFetched || !tokensAllowances[selectedAssetCode]) || isFormSubmitting || gasEstimation === 0 || isGasEstimating || cannotEstimateGasError || (USER_HAS_CDP && !CLOSE && Number(fInputValue) === 0)}
>
<span>{CLOSE ? 'Close CDP' : formHeader}</span>
</SubmitButton>
{!cannotEstimateGasError &&
<TransactionFee>
<span className="label">
Transaction fee
</span>
<span className="value">
{gasEstimation * gasPrice} ETH (${(gasEstimation * gasPrice * assetsPrices.eth.value.usd ).toFixed(2)})
</span>
</TransactionFee>
}
{cannotEstimateGasError &&
<BottomError>
Cannot estimate gas
</BottomError>
}
</Wr>
</>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment