mirror of
https://github.com/oceanprotocol/market.git
synced 2024-12-02 05:57:29 +01:00
Token approval split-up (#640)
* Token approval component * check if datatoken approved * display token amount on button, add approve function * approve token based on token type * approve token for trade, remove for pool lequidity remove action * verify approval on amount change * show action button only if amount is approved * catch approve error and stop loadin * display token amount with 2 decimals on trade token approval * infinite approval and UI fixes * fixed alert warning not showing, account id for approve * wip * fixed displayed token amount to approve for swap * token amount text fix * lint error fix * package version update * version fix * downgrade version * fixed error for no wallet connected * update package-lock * display token name, and changed amount precision * removed empty file, fixed token switch error * refactor for better user experience * move content * ExplorerLink console error fixes * UI tweaks * slightly changed button logic * fix Trade form approvals * cleanup * don't block add liquidity button * merge fixes * hook dependency cleanup * dtItem fix, error fixes based on asset network match * disable action button if field is not valid, undefined trade tokens * fix infiniteApproval user preference saving * remove unneccessary string conversion * used Decimal for dtAmount and oceanAmount * changed token spender address * bump ocean.js to vo.17.5 * fix lint * replace Number with Decimal * fix getting to add liquidity screen without wallet connected * fix crash when switching coins after value input Co-authored-by: Norbi <katunanorbert@gmai.com> Co-authored-by: Matthias Kretschmann <m@kretschmann.io> Co-authored-by: mihaisc <mihai@oceanprotocol.com>
This commit is contained in:
parent
9b3cb3963e
commit
4b34e2f347
@ -34,7 +34,9 @@
|
|||||||
"pool": {
|
"pool": {
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"price": "The price is determined by an automated market maker, which is a type of decentralized exchange protocol that relies on a mathematical formula. It is an alternative to a traditional order book.",
|
"price": "The price is determined by an automated market maker, which is a type of decentralized exchange protocol that relies on a mathematical formula. It is an alternative to a traditional order book.",
|
||||||
"liquidity": "Providing liquidity will earn you SWAPFEE% on every transaction in this pool, proportionally to your share of the pool."
|
"liquidity": "Providing liquidity will earn you SWAPFEE% on every transaction in this pool, proportionally to your share of the pool.",
|
||||||
|
"approveSpecific": "Give the smart contract permission to spend your COIN which has to be done for each transaction. You can optionally set this to infinite in your user preferences.",
|
||||||
|
"approveInfinite": "Give the smart contract permission to spend infinte amounts of your COIN so you have to do this only once. You can disable allowing infinite amounts in your user preferences."
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"title": "Add Liquidity",
|
"title": "Add Liquidity",
|
||||||
@ -43,7 +45,7 @@
|
|||||||
"titleIn": "You will receive",
|
"titleIn": "You will receive",
|
||||||
"titleOut": "Pool conversion"
|
"titleOut": "Pool conversion"
|
||||||
},
|
},
|
||||||
"action": "Approve & Supply",
|
"action": "Supply",
|
||||||
"warning": "Use at your own risk. Please familiarize yourself [with the risks](https://blog.oceanprotocol.com/on-staking-on-data-in-ocean-market-3d8e09eb0a13) and the [Terms of Use](/terms)."
|
"warning": "Use at your own risk. Please familiarize yourself [with the risks](https://blog.oceanprotocol.com/on-staking-on-data-in-ocean-market-3d8e09eb0a13) and the [Terms of Use](/terms)."
|
||||||
},
|
},
|
||||||
"remove": {
|
"remove": {
|
||||||
@ -54,11 +56,11 @@
|
|||||||
"titleIn": "You will spend",
|
"titleIn": "You will spend",
|
||||||
"titleOut": "You will receive"
|
"titleOut": "You will receive"
|
||||||
},
|
},
|
||||||
"action": "Approve & Remove"
|
"action": "Remove"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trade": {
|
"trade": {
|
||||||
"action": "Approve & Swap",
|
"action": "Swap",
|
||||||
"warning": "Use at your own risk. Please familiarize yourself [with the risks](https://blog.oceanprotocol.com/on-staking-on-data-in-ocean-market-3d8e09eb0a13) and the [Terms of Use](/terms)."
|
"warning": "Use at your own risk. Please familiarize yourself [with the risks](https://blog.oceanprotocol.com/on-staking-on-data-in-ocean-market-3d8e09eb0a13) and the [Terms of Use](/terms)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -5681,7 +5681,7 @@
|
|||||||
"save-file": "^2.3.1",
|
"save-file": "^2.3.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"web3": "^1.5.2",
|
"web3": "^1.5.2",
|
||||||
"web3-core": "^1.5.3",
|
"web3-core": "^1.5.2",
|
||||||
"web3-eth-contract": "^1.5.2"
|
"web3-eth-contract": "^1.5.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -63142,7 +63142,7 @@
|
|||||||
"save-file": "^2.3.1",
|
"save-file": "^2.3.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"web3": "^1.5.2",
|
"web3": "^1.5.2",
|
||||||
"web3-core": "^1.5.3",
|
"web3-core": "^1.5.2",
|
||||||
"web3-eth-contract": "^1.5.2"
|
"web3-eth-contract": "^1.5.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -8,7 +8,7 @@ export default function DebugOutput({
|
|||||||
output: any
|
output: any
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ marginTop: 'var(--spacer)' }}>
|
||||||
<h5>{title}</h5>
|
<h5>{title}</h5>
|
||||||
<pre>
|
<pre>
|
||||||
<code>{JSON.stringify(output, null, 2)}</code>
|
<code>{JSON.stringify(output, null, 2)}</code>
|
||||||
|
@ -28,6 +28,8 @@ export default function ExplorerLink({
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!networkId) return
|
||||||
|
|
||||||
async function initOcean() {
|
async function initOcean() {
|
||||||
const oceanInitialConfig = getOceanConfig(networkId)
|
const oceanInitialConfig = getOceanConfig(networkId)
|
||||||
setOceanConfig(oceanInitialConfig)
|
setOceanConfig(oceanInitialConfig)
|
||||||
@ -36,7 +38,7 @@ export default function ExplorerLink({
|
|||||||
if (oceanConfig === undefined) {
|
if (oceanConfig === undefined) {
|
||||||
initOcean()
|
initOcean()
|
||||||
}
|
}
|
||||||
}, [config, networkId, ocean])
|
}, [config, oceanConfig, networkId, ocean])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
144
src/components/molecules/TokenApproval.tsx
Normal file
144
src/components/molecules/TokenApproval.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import React, { ReactElement, useCallback, useEffect, useState } from 'react'
|
||||||
|
import Button from '../atoms/Button'
|
||||||
|
import { useOcean } from '../../providers/Ocean'
|
||||||
|
import { useAsset } from '../../providers/Asset'
|
||||||
|
import Loader from '../atoms/Loader'
|
||||||
|
import { useWeb3 } from '../../providers/Web3'
|
||||||
|
import { useUserPreferences } from '../../providers/UserPreferences'
|
||||||
|
import Tooltip from '../atoms/Tooltip'
|
||||||
|
import { graphql, useStaticQuery } from 'gatsby'
|
||||||
|
import Decimal from 'decimal.js'
|
||||||
|
import { getOceanConfig } from '../../utils/ocean'
|
||||||
|
|
||||||
|
const query = graphql`
|
||||||
|
query {
|
||||||
|
content: allFile(filter: { relativePath: { eq: "price.json" } }) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
childContentJson {
|
||||||
|
pool {
|
||||||
|
tooltips {
|
||||||
|
approveSpecific
|
||||||
|
approveInfinite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function ButtonApprove({
|
||||||
|
amount,
|
||||||
|
coin,
|
||||||
|
approveTokens,
|
||||||
|
isLoading
|
||||||
|
}: {
|
||||||
|
amount: string
|
||||||
|
coin: string
|
||||||
|
approveTokens: (amount: string) => void
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
// Get content
|
||||||
|
const data = useStaticQuery(query)
|
||||||
|
const content = data.content.edges[0].node.childContentJson.pool.tooltips
|
||||||
|
|
||||||
|
const { infiniteApproval } = useUserPreferences()
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<Loader message={`Approving ${coin}...`} />
|
||||||
|
) : infiniteApproval ? (
|
||||||
|
<Button
|
||||||
|
style="primary"
|
||||||
|
size="small"
|
||||||
|
disabled={parseInt(amount) < 1}
|
||||||
|
onClick={() => approveTokens(`${2 ** 53 - 1}`)}
|
||||||
|
>
|
||||||
|
Approve {coin}{' '}
|
||||||
|
<Tooltip content={content.approveInfinite.replace('COIN', coin)} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button style="primary" size="small" onClick={() => approveTokens(amount)}>
|
||||||
|
Approve {amount} {coin}
|
||||||
|
<Tooltip content={content.approveSpecific.replace('COIN', coin)} />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TokenApproval({
|
||||||
|
actionButton,
|
||||||
|
disabled,
|
||||||
|
amount,
|
||||||
|
coin
|
||||||
|
}: {
|
||||||
|
actionButton: JSX.Element
|
||||||
|
disabled: boolean
|
||||||
|
amount: string
|
||||||
|
coin: string
|
||||||
|
}): ReactElement {
|
||||||
|
const { ddo, price } = useAsset()
|
||||||
|
const [tokenApproved, setTokenApproved] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { ocean } = useOcean()
|
||||||
|
const { accountId } = useWeb3()
|
||||||
|
|
||||||
|
const config = getOceanConfig(ddo.chainId)
|
||||||
|
|
||||||
|
const tokenAddress =
|
||||||
|
coin === 'OCEAN' ? config.oceanTokenAddress : ddo.dataTokenInfo.address
|
||||||
|
const spender = price.address
|
||||||
|
|
||||||
|
const checkTokenApproval = useCallback(async () => {
|
||||||
|
if (!ocean || !tokenAddress || !spender) return
|
||||||
|
|
||||||
|
const allowance = await ocean.datatokens.allowance(
|
||||||
|
tokenAddress,
|
||||||
|
accountId,
|
||||||
|
spender
|
||||||
|
)
|
||||||
|
|
||||||
|
amount &&
|
||||||
|
new Decimal(amount).greaterThan(new Decimal('0')) &&
|
||||||
|
setTokenApproved(
|
||||||
|
new Decimal(allowance).greaterThanOrEqualTo(new Decimal(amount))
|
||||||
|
)
|
||||||
|
}, [ocean, tokenAddress, spender, accountId, amount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkTokenApproval()
|
||||||
|
}, [checkTokenApproval])
|
||||||
|
|
||||||
|
async function approveTokens(amount: string) {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ocean.datatokens.approve(tokenAddress, spender, amount, accountId)
|
||||||
|
} catch (error) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkTokenApproval()
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tokenApproved ||
|
||||||
|
disabled ||
|
||||||
|
amount === '0' ||
|
||||||
|
amount === '' ||
|
||||||
|
!amount ||
|
||||||
|
typeof amount === 'undefined' ? (
|
||||||
|
actionButton
|
||||||
|
) : (
|
||||||
|
<ButtonApprove
|
||||||
|
amount={amount}
|
||||||
|
coin={coin}
|
||||||
|
approveTokens={approveTokens}
|
||||||
|
isLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
21
src/components/molecules/UserPreferences/TokenApproval.tsx
Normal file
21
src/components/molecules/UserPreferences/TokenApproval.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import { useUserPreferences } from '../../../providers/UserPreferences'
|
||||||
|
import Input from '../../atoms/Input'
|
||||||
|
|
||||||
|
export default function TokenApproval(): ReactElement {
|
||||||
|
const { infiniteApproval, setInfiniteApproval } = useUserPreferences()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Input
|
||||||
|
label="Token Approvals"
|
||||||
|
help="Use infinite amount when approving tokens in _Use_, _Pool_, or _Trade_."
|
||||||
|
name="infiniteApproval"
|
||||||
|
type="checkbox"
|
||||||
|
options={['Allow infinite amount']}
|
||||||
|
defaultChecked={infiniteApproval === true}
|
||||||
|
onChange={() => setInfiniteApproval(!infiniteApproval)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
@ -8,6 +8,7 @@ import { ReactComponent as Caret } from '../../../images/caret.svg'
|
|||||||
import useDarkMode from 'use-dark-mode'
|
import useDarkMode from 'use-dark-mode'
|
||||||
import Appearance from './Appearance'
|
import Appearance from './Appearance'
|
||||||
import { darkModeConfig } from '../../../../app.config'
|
import { darkModeConfig } from '../../../../app.config'
|
||||||
|
import TokenApproval from './TokenApproval'
|
||||||
|
|
||||||
export default function UserPreferences(): ReactElement {
|
export default function UserPreferences(): ReactElement {
|
||||||
// Calling this here because <Style /> is not mounted on first load
|
// Calling this here because <Style /> is not mounted on first load
|
||||||
@ -18,6 +19,7 @@ export default function UserPreferences(): ReactElement {
|
|||||||
content={
|
content={
|
||||||
<ul className={styles.preferencesDetails}>
|
<ul className={styles.preferencesDetails}>
|
||||||
<Currency />
|
<Currency />
|
||||||
|
<TokenApproval />
|
||||||
<Appearance darkMode={darkMode} />
|
<Appearance darkMode={darkMode} />
|
||||||
<Debug />
|
<Debug />
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -79,7 +79,13 @@ export default function Consume({
|
|||||||
}, [ddo, accountId, hasPreviousOrder, isMounted])
|
}, [ddo, accountId, hasPreviousOrder, isMounted])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || !assetTimeout || data.tokenOrders.length === 0 || !accountId)
|
if (
|
||||||
|
!data ||
|
||||||
|
!assetTimeout ||
|
||||||
|
data.tokenOrders.length === 0 ||
|
||||||
|
!accountId ||
|
||||||
|
!isAssetNetwork
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const lastOrder = data.tokenOrders[0]
|
const lastOrder = data.tokenOrders[0]
|
||||||
@ -96,11 +102,11 @@ export default function Consume({
|
|||||||
setHasPreviousOrder(false)
|
setHasPreviousOrder(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [data, assetTimeout, accountId])
|
}, [data, assetTimeout, accountId, isAssetNetwork])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { timeout } = ddo.findServiceByType('access').attributes.main
|
const { timeout } = ddo.findServiceByType('access').attributes.main
|
||||||
setAssetTimeout(timeout.toString())
|
setAssetTimeout(`${timeout}`)
|
||||||
}, [ddo])
|
}, [ddo])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -137,8 +143,8 @@ export default function Consume({
|
|||||||
pricingIsLoading,
|
pricingIsLoading,
|
||||||
isConsumablePrice,
|
isConsumablePrice,
|
||||||
hasDatatoken,
|
hasDatatoken,
|
||||||
accountId,
|
isConsumable,
|
||||||
isConsumable
|
accountId
|
||||||
])
|
])
|
||||||
|
|
||||||
async function handleConsume() {
|
async function handleConsume() {
|
||||||
|
@ -19,3 +19,7 @@
|
|||||||
margin-left: calc(var(--spacer) / 4);
|
margin-left: calc(var(--spacer) / 4);
|
||||||
margin-right: calc(var(--spacer) / 4);
|
margin-right: calc(var(--spacer) / 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions button svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
@ -6,6 +6,7 @@ import ExplorerLink from '../../../atoms/ExplorerLink'
|
|||||||
import SuccessConfetti from '../../../atoms/SuccessConfetti'
|
import SuccessConfetti from '../../../atoms/SuccessConfetti'
|
||||||
import { useOcean } from '../../../../providers/Ocean'
|
import { useOcean } from '../../../../providers/Ocean'
|
||||||
import { useWeb3 } from '../../../../providers/Web3'
|
import { useWeb3 } from '../../../../providers/Web3'
|
||||||
|
import TokenApproval from '../../../molecules/TokenApproval'
|
||||||
|
|
||||||
export default function Actions({
|
export default function Actions({
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -13,6 +14,8 @@ export default function Actions({
|
|||||||
successMessage,
|
successMessage,
|
||||||
txId,
|
txId,
|
||||||
actionName,
|
actionName,
|
||||||
|
amount,
|
||||||
|
coin,
|
||||||
action,
|
action,
|
||||||
isDisabled
|
isDisabled
|
||||||
}: {
|
}: {
|
||||||
@ -21,26 +24,39 @@ export default function Actions({
|
|||||||
successMessage: string
|
successMessage: string
|
||||||
txId: string
|
txId: string
|
||||||
actionName: string
|
actionName: string
|
||||||
|
amount?: string
|
||||||
|
coin?: string
|
||||||
action: () => void
|
action: () => void
|
||||||
isDisabled?: boolean
|
isDisabled?: boolean
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { networkId } = useWeb3()
|
const { networkId } = useWeb3()
|
||||||
const { ocean } = useOcean()
|
const { ocean } = useOcean()
|
||||||
|
|
||||||
|
const actionButton = (
|
||||||
|
<Button
|
||||||
|
style="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => action()}
|
||||||
|
disabled={!ocean || isDisabled}
|
||||||
|
>
|
||||||
|
{actionName}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader message={loaderMessage} />
|
<Loader message={loaderMessage} />
|
||||||
) : (
|
) : actionName === 'Supply' || actionName === 'Swap' ? (
|
||||||
<Button
|
<TokenApproval
|
||||||
style="primary"
|
actionButton={actionButton}
|
||||||
size="small"
|
amount={amount}
|
||||||
onClick={() => action()}
|
coin={coin}
|
||||||
disabled={!ocean || isDisabled}
|
disabled={!ocean || isDisabled}
|
||||||
>
|
/>
|
||||||
{actionName}
|
) : (
|
||||||
</Button>
|
actionButton
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{txId && (
|
{txId && (
|
||||||
|
@ -24,6 +24,7 @@ export default function FormAdd({
|
|||||||
dtSymbol,
|
dtSymbol,
|
||||||
amountMax,
|
amountMax,
|
||||||
setCoin,
|
setCoin,
|
||||||
|
setAmount,
|
||||||
totalPoolTokens,
|
totalPoolTokens,
|
||||||
totalBalance,
|
totalBalance,
|
||||||
poolAddress,
|
poolAddress,
|
||||||
@ -35,6 +36,7 @@ export default function FormAdd({
|
|||||||
dtSymbol: string
|
dtSymbol: string
|
||||||
amountMax: string
|
amountMax: string
|
||||||
setCoin: (value: string) => void
|
setCoin: (value: string) => void
|
||||||
|
setAmount: (value: string) => void
|
||||||
totalPoolTokens: string
|
totalPoolTokens: string
|
||||||
totalBalance: PoolBalance
|
totalBalance: PoolBalance
|
||||||
poolAddress: string
|
poolAddress: string
|
||||||
@ -56,6 +58,7 @@ export default function FormAdd({
|
|||||||
function handleFieldChange(e: ChangeEvent<HTMLInputElement>) {
|
function handleFieldChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
// Workaround so validation kicks in on first touch
|
// Workaround so validation kicks in on first touch
|
||||||
!touched?.amount && setTouched({ amount: true })
|
!touched?.amount && setTouched({ amount: true })
|
||||||
|
setAmount(e.target.value)
|
||||||
|
|
||||||
// Manually handle change events instead of using `handleChange` from Formik.
|
// Manually handle change events instead of using `handleChange` from Formik.
|
||||||
// Solves bug where 0.0 can't be typed.
|
// Solves bug where 0.0 can't be typed.
|
||||||
|
@ -58,7 +58,8 @@ export default function Output({
|
|||||||
const [poolDatatoken, setPoolDatatoken] = useState('0')
|
const [poolDatatoken, setPoolDatatoken] = useState('0')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!values.amount || !totalBalance || !totalPoolTokens) return
|
if (!values.amount || !totalBalance || !totalPoolTokens || !newPoolTokens)
|
||||||
|
return
|
||||||
const newPoolSupply = new Decimal(totalPoolTokens).plus(newPoolTokens)
|
const newPoolSupply = new Decimal(totalPoolTokens).plus(newPoolTokens)
|
||||||
const ratio = new Decimal(newPoolTokens).div(newPoolSupply)
|
const ratio = new Decimal(newPoolTokens).div(newPoolSupply)
|
||||||
const newOceanReserve =
|
const newOceanReserve =
|
||||||
@ -73,7 +74,14 @@ export default function Output({
|
|||||||
const poolDatatoken = newDtReserve.mul(ratio).toString()
|
const poolDatatoken = newDtReserve.mul(ratio).toString()
|
||||||
setPoolOcean(poolOcean)
|
setPoolOcean(poolOcean)
|
||||||
setPoolDatatoken(poolDatatoken)
|
setPoolDatatoken(poolDatatoken)
|
||||||
}, [values.amount, coin, totalBalance, totalPoolTokens, newPoolShare])
|
}, [
|
||||||
|
values.amount,
|
||||||
|
coin,
|
||||||
|
totalBalance,
|
||||||
|
totalPoolTokens,
|
||||||
|
newPoolShare,
|
||||||
|
newPoolTokens
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -72,6 +72,7 @@ export default function Add({
|
|||||||
const [coin, setCoin] = useState('OCEAN')
|
const [coin, setCoin] = useState('OCEAN')
|
||||||
const [dtBalance, setDtBalance] = useState<string>()
|
const [dtBalance, setDtBalance] = useState<string>()
|
||||||
const [amountMax, setAmountMax] = useState<string>()
|
const [amountMax, setAmountMax] = useState<string>()
|
||||||
|
const [amount, setAmount] = useState<string>('0')
|
||||||
const [newPoolTokens, setNewPoolTokens] = useState('0')
|
const [newPoolTokens, setNewPoolTokens] = useState('0')
|
||||||
const [newPoolShare, setNewPoolShare] = useState('0')
|
const [newPoolShare, setNewPoolShare] = useState('0')
|
||||||
const [isWarningAccepted, setIsWarningAccepted] = useState(false)
|
const [isWarningAccepted, setIsWarningAccepted] = useState(false)
|
||||||
@ -146,7 +147,6 @@ export default function Add({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title={content.title} backAction={() => setShowAdd(false)} />
|
<Header title={content.title} backAction={() => setShowAdd(false)} />
|
||||||
|
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={validationSchema}
|
validationSchema={validationSchema}
|
||||||
@ -155,7 +155,7 @@ export default function Add({
|
|||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ isSubmitting, submitForm, values }) => (
|
{({ isSubmitting, submitForm, values, isValid }) => (
|
||||||
<>
|
<>
|
||||||
<div className={styles.addInput}>
|
<div className={styles.addInput}>
|
||||||
{isWarningAccepted ? (
|
{isWarningAccepted ? (
|
||||||
@ -165,6 +165,7 @@ export default function Add({
|
|||||||
dtSymbol={dtSymbol}
|
dtSymbol={dtSymbol}
|
||||||
amountMax={amountMax}
|
amountMax={amountMax}
|
||||||
setCoin={setCoin}
|
setCoin={setCoin}
|
||||||
|
setAmount={setAmount}
|
||||||
totalPoolTokens={totalPoolTokens}
|
totalPoolTokens={totalPoolTokens}
|
||||||
totalBalance={totalBalance}
|
totalBalance={totalBalance}
|
||||||
poolAddress={poolAddress}
|
poolAddress={poolAddress}
|
||||||
@ -172,16 +173,18 @@ export default function Add({
|
|||||||
setNewPoolShare={setNewPoolShare}
|
setNewPoolShare={setNewPoolShare}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
content.warning && (
|
||||||
className={styles.warning}
|
<Alert
|
||||||
text={content.warning}
|
className={styles.warning}
|
||||||
state="info"
|
text={content.warning.toString()}
|
||||||
action={{
|
state="info"
|
||||||
name: 'I understand',
|
action={{
|
||||||
style: 'text',
|
name: 'I understand',
|
||||||
handleAction: () => setIsWarningAccepted(true)
|
style: 'text',
|
||||||
}}
|
handleAction: () => setIsWarningAccepted(true)
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -196,12 +199,19 @@ export default function Add({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Actions
|
<Actions
|
||||||
isDisabled={!isWarningAccepted}
|
isDisabled={
|
||||||
|
!isValid ||
|
||||||
|
!isWarningAccepted ||
|
||||||
|
amount === '' ||
|
||||||
|
amount === '0'
|
||||||
|
}
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
loaderMessage="Adding Liquidity..."
|
loaderMessage="Adding Liquidity..."
|
||||||
successMessage="Successfully added liquidity."
|
successMessage="Successfully added liquidity."
|
||||||
actionName={content.action}
|
actionName={content.action}
|
||||||
action={submitForm}
|
action={submitForm}
|
||||||
|
amount={amount}
|
||||||
|
coin={coin}
|
||||||
txId={txId}
|
txId={txId}
|
||||||
/>
|
/>
|
||||||
{debug && <DebugOutput title="Collected values" output={values} />}
|
{debug && <DebugOutput title="Collected values" output={values} />}
|
||||||
|
@ -487,16 +487,14 @@ export default function Pool(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={stylesActions.actions}>
|
<div className={stylesActions.actions}>
|
||||||
{!isInPurgatory && (
|
<Button
|
||||||
<Button
|
style="primary"
|
||||||
style="primary"
|
size="small"
|
||||||
size="small"
|
onClick={() => setShowAdd(true)}
|
||||||
onClick={() => setShowAdd(true)}
|
disabled={isInPurgatory}
|
||||||
disabled={isInPurgatory}
|
>
|
||||||
>
|
Add Liquidity
|
||||||
Add Liquidity
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasAddedLiquidity && !isRemoveDisabled && (
|
{hasAddedLiquidity && !isRemoveDisabled && (
|
||||||
<Button
|
<Button
|
||||||
|
@ -54,6 +54,7 @@ export default function FormTrade({
|
|||||||
const { isAssetNetwork } = useAsset()
|
const { isAssetNetwork } = useAsset()
|
||||||
const { debug } = useUserPreferences()
|
const { debug } = useUserPreferences()
|
||||||
const [txId, setTxId] = useState<string>()
|
const [txId, setTxId] = useState<string>()
|
||||||
|
const [coinFrom, setCoinFrom] = useState<string>('OCEAN')
|
||||||
|
|
||||||
const [maximumOcean, setMaximumOcean] = useState(maxOcean)
|
const [maximumOcean, setMaximumOcean] = useState(maxOcean)
|
||||||
const [maximumDt, setMaximumDt] = useState(maxDt)
|
const [maximumDt, setMaximumDt] = useState(maxDt)
|
||||||
@ -114,6 +115,7 @@ export default function FormTrade({
|
|||||||
toast.error(error.message)
|
toast.error(error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
@ -124,7 +126,7 @@ export default function FormTrade({
|
|||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ isSubmitting, submitForm, values }) => (
|
{({ isSubmitting, submitForm, values, isValid }) => (
|
||||||
<>
|
<>
|
||||||
{isWarningAccepted ? (
|
{isWarningAccepted ? (
|
||||||
<Swap
|
<Swap
|
||||||
@ -133,6 +135,7 @@ export default function FormTrade({
|
|||||||
maxDt={maxDt}
|
maxDt={maxDt}
|
||||||
maxOcean={maxOcean}
|
maxOcean={maxOcean}
|
||||||
price={price}
|
price={price}
|
||||||
|
setCoin={setCoinFrom}
|
||||||
setMaximumOcean={setMaximumOcean}
|
setMaximumOcean={setMaximumOcean}
|
||||||
setMaximumDt={setMaximumDt}
|
setMaximumDt={setMaximumDt}
|
||||||
/>
|
/>
|
||||||
@ -150,12 +153,22 @@ export default function FormTrade({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Actions
|
<Actions
|
||||||
isDisabled={!isWarningAccepted || !isAssetNetwork}
|
isDisabled={
|
||||||
|
!isValid ||
|
||||||
|
!isWarningAccepted ||
|
||||||
|
!isAssetNetwork ||
|
||||||
|
values.datatoken === undefined ||
|
||||||
|
values.ocean === undefined
|
||||||
|
}
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
loaderMessage="Swapping tokens..."
|
loaderMessage="Swapping tokens..."
|
||||||
successMessage="Successfully swapped tokens."
|
successMessage="Successfully swapped tokens."
|
||||||
actionName={content.action}
|
actionName={content.action}
|
||||||
|
amount={`${
|
||||||
|
values.type === 'sell' ? values.datatoken : values.ocean
|
||||||
|
}`}
|
||||||
action={submitForm}
|
action={submitForm}
|
||||||
|
coin={coinFrom}
|
||||||
txId={txId}
|
txId={txId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { FormikContextType, useFormikContext } from 'formik'
|
import { FormikContextType, useFormikContext } from 'formik'
|
||||||
import React, { ReactElement, useEffect, useState } from 'react'
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
import { FormTradeData } from '../../../../models/FormTrade'
|
import { FormTradeData } from '../../../../models/FormTrade'
|
||||||
|
import { useAsset } from '../../../../providers/Asset'
|
||||||
import { useOcean } from '../../../../providers/Ocean'
|
import { useOcean } from '../../../../providers/Ocean'
|
||||||
import Token from '../Pool/Token'
|
import Token from '../Pool/Token'
|
||||||
import styles from './Output.module.css'
|
import styles from './Output.module.css'
|
||||||
@ -19,6 +20,7 @@ export default function Output({
|
|||||||
oceanSymbol: string
|
oceanSymbol: string
|
||||||
poolAddress: string
|
poolAddress: string
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
|
const { isAssetNetwork } = useAsset()
|
||||||
const { ocean } = useOcean()
|
const { ocean } = useOcean()
|
||||||
const [maxOutput, setMaxOutput] = useState<string>()
|
const [maxOutput, setMaxOutput] = useState<string>()
|
||||||
const [swapFee, setSwapFee] = useState<string>()
|
const [swapFee, setSwapFee] = useState<string>()
|
||||||
@ -28,7 +30,7 @@ export default function Output({
|
|||||||
|
|
||||||
// Get swap fee
|
// Get swap fee
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ocean || !poolAddress) return
|
if (!ocean || !poolAddress || !isAssetNetwork) return
|
||||||
|
|
||||||
async function getSwapFee() {
|
async function getSwapFee() {
|
||||||
const swapFee = await ocean.pool.getSwapFee(poolAddress)
|
const swapFee = await ocean.pool.getSwapFee(poolAddress)
|
||||||
@ -49,11 +51,11 @@ export default function Output({
|
|||||||
setSwapFeeValue(value.toString())
|
setSwapFeeValue(value.toString())
|
||||||
}
|
}
|
||||||
getSwapFee()
|
getSwapFee()
|
||||||
}, [ocean, poolAddress, values])
|
}, [ocean, poolAddress, values, isAssetNetwork])
|
||||||
|
|
||||||
// Get output values
|
// Get output values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ocean || !poolAddress) return
|
if (!ocean || !poolAddress || !isAssetNetwork) return
|
||||||
|
|
||||||
async function getOutput() {
|
async function getOutput() {
|
||||||
// Minimum received
|
// Minimum received
|
||||||
@ -73,7 +75,7 @@ export default function Output({
|
|||||||
setMaxOutput(maxPrice)
|
setMaxOutput(maxPrice)
|
||||||
}
|
}
|
||||||
getOutput()
|
getOutput()
|
||||||
}, [ocean, poolAddress, values])
|
}, [ocean, poolAddress, values, isAssetNetwork])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.output}>
|
<div className={styles.output}>
|
||||||
|
@ -51,7 +51,7 @@ export default function PriceImpact({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.priceImpact}>
|
<div className={styles.priceImpact}>
|
||||||
<strong>Price impact</strong>
|
<strong>Price Impact</strong>
|
||||||
<div>
|
<div>
|
||||||
<span
|
<span
|
||||||
className={`${styles.number} ${
|
className={`${styles.number} ${
|
||||||
|
@ -6,10 +6,10 @@ import Button from '../../../atoms/Button'
|
|||||||
import { ReactComponent as Arrow } from '../../../../images/arrow.svg'
|
import { ReactComponent as Arrow } from '../../../../images/arrow.svg'
|
||||||
import { FormikContextType, useFormikContext } from 'formik'
|
import { FormikContextType, useFormikContext } from 'formik'
|
||||||
import { PoolBalance } from '../../../../@types/TokenBalance'
|
import { PoolBalance } from '../../../../@types/TokenBalance'
|
||||||
import Output from './Output'
|
|
||||||
import Slippage from './Slippage'
|
|
||||||
import { FormTradeData, TradeItem } from '../../../../models/FormTrade'
|
import { FormTradeData, TradeItem } from '../../../../models/FormTrade'
|
||||||
import { useOcean } from '../../../../providers/Ocean'
|
import { useOcean } from '../../../../providers/Ocean'
|
||||||
|
import Output from './Output'
|
||||||
|
import Slippage from './Slippage'
|
||||||
import PriceImpact from './PriceImpact'
|
import PriceImpact from './PriceImpact'
|
||||||
|
|
||||||
import Decimal from 'decimal.js'
|
import Decimal from 'decimal.js'
|
||||||
@ -24,7 +24,8 @@ export default function Swap({
|
|||||||
balance,
|
balance,
|
||||||
price,
|
price,
|
||||||
setMaximumDt,
|
setMaximumDt,
|
||||||
setMaximumOcean
|
setMaximumOcean,
|
||||||
|
setCoin
|
||||||
}: {
|
}: {
|
||||||
ddo: DDO
|
ddo: DDO
|
||||||
maxDt: string
|
maxDt: string
|
||||||
@ -33,6 +34,7 @@ export default function Swap({
|
|||||||
price: BestPrice
|
price: BestPrice
|
||||||
setMaximumDt: (value: string) => void
|
setMaximumDt: (value: string) => void
|
||||||
setMaximumOcean: (value: string) => void
|
setMaximumOcean: (value: string) => void
|
||||||
|
setCoin: (value: string) => void
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { ocean, config } = useOcean()
|
const { ocean, config } = useOcean()
|
||||||
const [oceanItem, setOceanItem] = useState<TradeItem>({
|
const [oceanItem, setOceanItem] = useState<TradeItem>({
|
||||||
@ -53,63 +55,82 @@ export default function Swap({
|
|||||||
validateForm
|
validateForm
|
||||||
}: FormikContextType<FormTradeData> = useFormikContext()
|
}: FormikContextType<FormTradeData> = useFormikContext()
|
||||||
|
|
||||||
/// Values used for calculation of price impact
|
// Values used for calculation of price impact
|
||||||
const [spotPrice, setSpotPrice] = useState<string>()
|
const [spotPrice, setSpotPrice] = useState<string>()
|
||||||
const [totalValue, setTotalValue] = useState<string>()
|
const [totalValue, setTotalValue] = useState<string>()
|
||||||
const [tokenAmount, setTokenAmount] = useState<string>()
|
const [tokenAmount, setTokenAmount] = useState<string>()
|
||||||
///
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ddo || !balance || !values || !price) return
|
if (!ddo || !balance || !values?.type || !price) return
|
||||||
|
|
||||||
async function calculateMaximum() {
|
async function calculateMaximum() {
|
||||||
const dtAmount = values.type === 'buy' ? maxDt : balance.datatoken
|
const dtAmount =
|
||||||
const oceanAmount = values.type === 'buy' ? balance.ocean : maxOcean
|
values.type === 'buy'
|
||||||
|
? new Decimal(maxDt)
|
||||||
|
: new Decimal(balance.datatoken)
|
||||||
|
const oceanAmount =
|
||||||
|
values.type === 'buy'
|
||||||
|
? new Decimal(balance.ocean)
|
||||||
|
: new Decimal(maxOcean)
|
||||||
|
|
||||||
const maxBuyOcean = await ocean.pool.getOceanReceived(
|
const maxBuyOcean = await ocean.pool.getOceanReceived(
|
||||||
price.address,
|
price.address,
|
||||||
dtAmount.toString()
|
`${dtAmount.toString()}`
|
||||||
)
|
)
|
||||||
const maxBuyDt = await ocean.pool.getDTReceived(
|
const maxBuyDt = await ocean.pool.getDTReceived(
|
||||||
price.address,
|
price.address,
|
||||||
oceanAmount.toString()
|
`${oceanAmount.toString()}`
|
||||||
)
|
)
|
||||||
|
|
||||||
const maximumDt =
|
const maximumDt =
|
||||||
values.type === 'buy'
|
values.type === 'buy'
|
||||||
? Number(dtAmount) > Number(maxBuyDt)
|
? dtAmount.greaterThan(new Decimal(maxBuyDt))
|
||||||
? new Decimal(maxBuyDt)
|
? maxBuyDt
|
||||||
: new Decimal(dtAmount)
|
: dtAmount
|
||||||
: Number(dtAmount) > Number(balance.datatoken)
|
: dtAmount.greaterThan(new Decimal(balance.datatoken))
|
||||||
? new Decimal(balance.datatoken)
|
? balance.datatoken
|
||||||
: new Decimal(dtAmount)
|
: dtAmount
|
||||||
|
|
||||||
const maximumOcean =
|
const maximumOcean =
|
||||||
values.type === 'sell'
|
values.type === 'sell'
|
||||||
? Number(oceanAmount) > Number(maxBuyOcean)
|
? oceanAmount.greaterThan(new Decimal(maxBuyOcean))
|
||||||
? new Decimal(maxBuyOcean)
|
? maxBuyOcean
|
||||||
: new Decimal(oceanAmount)
|
: oceanAmount
|
||||||
: Number(oceanAmount) > Number(balance.ocean)
|
: oceanAmount.greaterThan(new Decimal(balance.ocean))
|
||||||
? new Decimal(balance.ocean)
|
? balance.ocean
|
||||||
: new Decimal(oceanAmount)
|
: oceanAmount
|
||||||
|
|
||||||
setMaximumDt(maximumDt.toString())
|
setMaximumDt(maximumDt.toString())
|
||||||
setMaximumOcean(maximumOcean.toString())
|
setMaximumOcean(maximumOcean.toString())
|
||||||
setOceanItem({
|
|
||||||
...oceanItem,
|
setOceanItem((prevState) => ({
|
||||||
|
...prevState,
|
||||||
amount: oceanAmount.toString(),
|
amount: oceanAmount.toString(),
|
||||||
maxAmount: maximumOcean.toString()
|
maxAmount: maximumOcean.toString()
|
||||||
})
|
}))
|
||||||
setDtItem({
|
|
||||||
...dtItem,
|
setDtItem((prevState) => ({
|
||||||
|
...prevState,
|
||||||
amount: dtAmount.toString(),
|
amount: dtAmount.toString(),
|
||||||
maxAmount: maximumDt.toString()
|
maxAmount: maximumDt.toString()
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
calculateMaximum()
|
calculateMaximum()
|
||||||
}, [ddo, maxOcean, maxDt, balance, price?.value, values.type])
|
}, [
|
||||||
|
ddo,
|
||||||
|
maxOcean,
|
||||||
|
maxDt,
|
||||||
|
balance,
|
||||||
|
price,
|
||||||
|
values?.type,
|
||||||
|
ocean,
|
||||||
|
setMaximumDt,
|
||||||
|
setMaximumOcean
|
||||||
|
])
|
||||||
|
|
||||||
const switchTokens = () => {
|
const switchTokens = () => {
|
||||||
setFieldValue('type', values.type === 'buy' ? 'sell' : 'buy')
|
setFieldValue('type', values.type === 'buy' ? 'sell' : 'buy')
|
||||||
|
setCoin(values.type === 'sell' ? 'OCEAN' : ddo.dataTokenInfo.symbol)
|
||||||
// don't reset form because we don't want to reset type
|
// don't reset form because we don't want to reset type
|
||||||
setFieldValue('datatoken', 0)
|
setFieldValue('datatoken', 0)
|
||||||
setFieldValue('ocean', 0)
|
setFieldValue('ocean', 0)
|
||||||
|
@ -12,15 +12,17 @@ import { useSiteMetadata } from '../hooks/useSiteMetadata'
|
|||||||
|
|
||||||
interface UserPreferencesValue {
|
interface UserPreferencesValue {
|
||||||
debug: boolean
|
debug: boolean
|
||||||
currency: string
|
|
||||||
locale: string
|
|
||||||
chainIds: number[]
|
|
||||||
bookmarks: string[]
|
|
||||||
setChainIds: (chainIds: number[]) => void
|
|
||||||
setDebug: (value: boolean) => void
|
setDebug: (value: boolean) => void
|
||||||
|
currency: string
|
||||||
setCurrency: (value: string) => void
|
setCurrency: (value: string) => void
|
||||||
|
chainIds: number[]
|
||||||
|
setChainIds: (chainIds: number[]) => void
|
||||||
|
bookmarks: string[]
|
||||||
addBookmark: (did: string) => void
|
addBookmark: (did: string) => void
|
||||||
removeBookmark: (did: string) => void
|
removeBookmark: (did: string) => void
|
||||||
|
infiniteApproval: boolean
|
||||||
|
setInfiniteApproval: (value: boolean) => void
|
||||||
|
locale: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserPreferencesContext = createContext(null)
|
const UserPreferencesContext = createContext(null)
|
||||||
@ -58,11 +60,14 @@ function UserPreferencesProvider({
|
|||||||
const [chainIds, setChainIds] = useState(
|
const [chainIds, setChainIds] = useState(
|
||||||
localStorage?.chainIds || appConfig.chainIds
|
localStorage?.chainIds || appConfig.chainIds
|
||||||
)
|
)
|
||||||
|
const [infiniteApproval, setInfiniteApproval] = useState(
|
||||||
|
localStorage?.infiniteApproval || false
|
||||||
|
)
|
||||||
|
|
||||||
// Write values to localStorage on change
|
// Write values to localStorage on change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalStorage({ chainIds, debug, currency, bookmarks })
|
setLocalStorage({ chainIds, debug, currency, bookmarks, infiniteApproval })
|
||||||
}, [chainIds, debug, currency, bookmarks])
|
}, [chainIds, debug, currency, bookmarks, infiniteApproval])
|
||||||
|
|
||||||
// Set ocean.js log levels, default: Error
|
// Set ocean.js log levels, default: Error
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -108,6 +113,8 @@ function UserPreferencesProvider({
|
|||||||
locale,
|
locale,
|
||||||
chainIds,
|
chainIds,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
infiniteApproval,
|
||||||
|
setInfiniteApproval,
|
||||||
setChainIds,
|
setChainIds,
|
||||||
setDebug,
|
setDebug,
|
||||||
setCurrency,
|
setCurrency,
|
||||||
|
Loading…
Reference in New Issue
Block a user