1
0
mirror of https://github.com/oceanprotocol/market.git synced 2024-12-02 05:57:29 +01:00

Merge pull request #109 from oceanprotocol/feature/add-liquidity

Add liquidity checks
This commit is contained in:
Matthias Kretschmann 2020-10-16 10:11:34 +02:00 committed by GitHub
commit e5563c88b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 313 additions and 179 deletions

View File

@ -10,7 +10,7 @@
"titleIn": "You will receive",
"titleOut": "You will earn"
},
"action": "Supply"
"action": "Approve & Supply"
},
"remove": {
"title": "Remove Liquidity",
@ -20,7 +20,7 @@
"titleIn": "You will spend",
"titleOut": "You will receive"
},
"action": "Remove"
"action": "Approve & Remove"
}
}
}

View File

@ -11,7 +11,7 @@
margin: 0;
border-radius: var(--border-radius);
transition: 0.2s ease-out;
min-height: 43px;
height: 43px;
min-width: 0;
appearance: none;
display: block;
@ -49,6 +49,11 @@
display: none;
}
.textarea {
composes: input;
height: auto;
}
.select {
composes: input;
height: 43px;
@ -179,15 +184,16 @@
.prefix,
.postfix {
border: 1px solid var(--brand-grey-lighter);
min-height: 43px;
height: 43px;
display: flex;
align-items: center;
padding-left: calc(var(--spacer) / 4);
padding-right: calc(var(--spacer) / 4);
color: var(--color-secondary);
color: var(--brand-grey);
font-size: var(--font-size-small);
transition: border 0.2s ease-out;
white-space: nowrap;
position: relative;
}
.prefix {

View File

@ -57,7 +57,12 @@ export default function InputElement({
)
case 'textarea':
return (
<textarea name={name} id={name} className={styles.input} {...props} />
<textarea
name={name}
id={name}
className={styles.textarea}
{...props}
/>
)
case 'radio':
case 'checkbox':

View File

@ -13,12 +13,18 @@
}
.error {
font-size: var(--font-size-small);
color: var(--brand-alert-red);
display: inline-block;
font-size: var(--font-size-mini);
line-height: 1.2;
font-weight: var(--font-weight-bold);
color: var(--brand-white);
background: var(--brand-alert-red);
border-radius: var(--border-radius);
padding: 0.2rem 0.4rem;
position: absolute;
text-align: right;
right: 0;
top: 0;
top: 85%;
z-index: 1;
}
.hasError label {
@ -26,7 +32,10 @@
}
.hasError input,
.hasError input:focus,
.hasError select,
.hasError textarea {
.hasError textarea,
.hasError [class*='prefix'],
.hasError [class*='postfix'] {
border-color: var(--brand-alert-red);
}

View File

@ -3,7 +3,7 @@ import InputElement from './InputElement'
import Help from './Help'
import Label from './Label'
import styles from './index.module.css'
import { ErrorMessage } from 'formik'
import { ErrorMessage, FieldInputProps } from 'formik'
import classNames from 'classnames/bind'
const cx = classNames.bind(styles)
@ -33,7 +33,7 @@ export interface InputProps {
max?: string
disabled?: boolean
readOnly?: boolean
field?: any
field?: FieldInputProps<any>
form?: any
prefix?: string | ReactElement
postfix?: string | ReactElement
@ -71,7 +71,7 @@ export default function Input(props: Partial<InputProps>): ReactElement {
</Label>
<InputElement small={small} {...field} {...props} />
{field && field.name !== 'price' && (
{field && field.name !== 'price' && hasError && (
<div className={styles.error}>
<ErrorMessage name={field.name} />
</div>

View File

@ -0,0 +1,4 @@
.action {
text-align: center;
display: block;
}

View File

@ -1,8 +1,7 @@
import Alert from '../../atoms/Alert'
import Button from '../../atoms/Button'
import React, { ReactElement, useEffect } from 'react'
import Alert from './Alert'
import React, { ReactElement, ReactNode, useEffect } from 'react'
import { confetti } from 'dom-confetti'
import styles from './Success.module.css'
import styles from './SuccessConfetti.module.css'
const confettiConfig = {
angle: 90,
@ -24,33 +23,29 @@ const confettiConfig = {
]
}
export default function Success({
export default function SuccessConfetti({
success,
did
action
}: {
success: string
did: string
action: ReactNode
}): ReactElement {
// Have some confetti upon success
useEffect(() => {
if (!success || typeof window === 'undefined') return
const startElement: HTMLElement = document.querySelector('a[data-confetti]')
const startElement: HTMLElement = document.querySelector(
'span[data-confetti]'
)
confetti(startElement, confettiConfig)
}, [success])
return (
<>
<Alert text={success} state="success" />
<Button
style="primary"
size="small"
href={`/asset/${did}`}
className={styles.action}
data-confetti
>
Go to data set
</Button>
<span className={styles.action} data-confetti>
{action}
</span>
</>
)
}

View File

@ -11,18 +11,18 @@
.balance {
text-align: center;
font-size: var(--font-size-small);
font-size: var(--font-size-small) !important;
border: 1px solid var(--brand-grey-lighter);
border-right: 0;
margin-right: -3px;
padding: calc(var(--spacer) / 4.5) calc(var(--spacer) / 2);
height: 35px;
padding: calc(var(--spacer) / 3) calc(var(--spacer) / 2)
calc(var(--spacer) / 4.5) calc(var(--spacer) / 2);
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
color: var(--color-secondary);
}
.balance strong {
color: var(--brand-grey);
display: flex;
align-items: center;
}
.title {

View File

@ -1,4 +1,5 @@
import { DataTokenOptions, useOcean } from '@oceanprotocol/react'
import PriceUnit from '../../../atoms/Price/PriceUnit'
import React, { ReactElement, useEffect, useState } from 'react'
import { PriceOptionsMarket } from '../../../../@types/MetaData'
import { useSiteMetadata } from '../../../../hooks/useSiteMetadata'
@ -66,9 +67,12 @@ export default function Dynamic({
<aside className={styles.wallet}>
{balance?.ocean && (
<div className={styles.balance}>
OCEAN <strong>{balance.ocean}</strong>
</div>
<PriceUnit
className={styles.balance}
price={balance.ocean}
symbol="OCEAN"
small
/>
)}
<Wallet />
</aside>

View File

@ -1,9 +1,9 @@
import React, { ReactElement } from 'react'
import Loader from '../../../atoms/Loader'
import Button from '../../../atoms/Button'
import Alert from '../../../atoms/Alert'
import styles from './Actions.module.css'
import EtherscanLink from '../../../atoms/EtherscanLink'
import SuccessConfetti from '../../../atoms/SuccessConfetti'
export default function Actions({
isLoading,
@ -30,15 +30,14 @@ export default function Actions({
)}
</div>
{txId && (
<>
<Alert
text={`Successfully added liquidity. Transaction ID: ${txId}`}
state="success"
/>
<EtherscanLink network="rinkeby" path={`/tx/${txId}`}>
Etherscan
</EtherscanLink>
</>
<SuccessConfetti
success="Successfully added liquidity."
action={
<EtherscanLink network="rinkeby" path={`/tx/${txId}`}>
See on Etherscan
</EtherscanLink>
}
/>
)}
</>
)

View File

@ -1,8 +1,8 @@
.addInput {
margin: 0 auto calc(var(--spacer) / 1.5) auto;
background: var(--brand-grey-dimmed);
padding: var(--spacer) calc(var(--spacer) * 3) calc(var(--spacer) * 1.2)
calc(var(--spacer) * 3);
padding: var(--spacer) calc(var(--spacer) * 2.5) calc(var(--spacer) * 1.2)
calc(var(--spacer) * 2.5);
border-bottom: 1px solid var(--brand-grey-lighter);
margin-top: -2rem;
margin-left: -2rem;
@ -14,45 +14,34 @@
text-align: center;
}
.addInput div[class*='field'] {
margin-bottom: 0;
}
.buttonMax {
position: absolute;
font-size: var(--font-size-mini);
bottom: calc(var(--spacer) / 2);
right: calc(var(--spacer) * 3);
right: calc(var(--spacer) * 2.5);
}
.userLiquidity {
.userLiquidity > div {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-mini);
margin-bottom: calc(var(--spacer) / 4);
color: var(--color-secondary);
}
.userLiquidity > div:last-child {
margin-bottom: calc(var(--spacer) / 4);
}
.userLiquidity span + div {
transform: scale(0.8);
transform-origin: right center;
}
.coinswitch,
.coinPopover li {
cursor: pointer;
}
.coinswitch svg {
width: 0.6em;
height: 0.6em;
display: inline-block;
fill: currentColor;
margin-right: 0.5rem;
margin-left: 0.25rem;
}
.coinPopover li {
padding: calc(var(--spacer) / 4) calc(var(--spacer) / 2);
}
.output {
display: grid;
gap: var(--spacer);

View File

@ -1,17 +1,18 @@
import React, { ReactElement, useState, ChangeEvent, useEffect } from 'react'
import React, { ReactElement, useState, useEffect } from 'react'
import styles from './Add.module.css'
import { useOcean } from '@oceanprotocol/react'
import Header from './Header'
import { toast } from 'react-toastify'
import InputElement from '../../../atoms/Input/InputElement'
import Button from '../../../atoms/Button'
import Token from './Token'
import { Balance } from './'
import PriceUnit from '../../../atoms/Price/PriceUnit'
import Actions from './Actions'
import Tooltip from '../../../atoms/Tooltip'
import { ReactComponent as Caret } from '../../../../images/caret.svg'
import { graphql, useStaticQuery } from 'gatsby'
import * as Yup from 'yup'
import { Field, FieldInputProps, Formik } from 'formik'
import Input from '../../../atoms/Input'
import CoinSelect from './CoinSelect'
const contentQuery = graphql`
query PoolAddQuery {
@ -36,6 +37,14 @@ const contentQuery = graphql`
}
`
interface FormAddLiquidity {
amount: number
}
const initialValues: FormAddLiquidity = {
amount: undefined
}
export default function Add({
setShowAdd,
poolAddress,
@ -57,131 +66,187 @@ export default function Add({
const content = data.content.edges[0].node.childContentJson.pool.add
const { ocean, accountId, balance } = useOcean()
const [amount, setAmount] = useState('')
const [txId, setTxId] = useState<string>('')
const [isLoading, setIsLoading] = useState<boolean>()
const [coin, setCoin] = useState<string>('OCEAN')
const [txId, setTxId] = useState<string>()
const [coin, setCoin] = useState('OCEAN')
const [dtBalance, setDtBalance] = useState<string>()
const [amountMax, setAmountMax] = useState<string>()
const newPoolTokens =
totalBalance &&
((Number(amount) / Number(totalBalance.ocean)) * 100).toFixed(2)
const newPoolShare =
totalBalance &&
((Number(newPoolTokens) / Number(totalPoolTokens)) * 100).toFixed(2)
// Live validation rules
// https://github.com/jquense/yup#number
const validationSchema = Yup.object().shape<FormAddLiquidity>({
amount: Yup.number()
.min(1, 'Must be more or equal to 1')
.max(
Number(amountMax),
`Maximum you can add is ${Number(amountMax).toFixed(2)} ${coin}`
)
.required('Required')
})
// Get datatoken balance when datatoken selected
useEffect(() => {
if (!ocean) return
if (!ocean || coin === 'OCEAN') return
async function getDtBalance() {
const dtBalance = await ocean.datatokens.balance(dtAddress, accountId)
setDtBalance(dtBalance)
}
getDtBalance()
}, [ocean, accountId, dtAddress])
}, [ocean, accountId, dtAddress, coin])
async function handleAddLiquidity() {
setIsLoading(true)
// Get maximum amount for either OCEAN or datatoken
useEffect(() => {
if (!ocean) return
async function getMaximum() {
const amountMaxPool =
coin === 'OCEAN'
? await ocean.pool.getOceanMaxAddLiquidity(poolAddress)
: await ocean.pool.getDTMaxAddLiquidity(poolAddress)
const amountMax =
coin === 'OCEAN'
? Number(balance.ocean) > Number(amountMaxPool)
? amountMaxPool
: balance.ocean
: Number(dtBalance) > Number(amountMaxPool)
? amountMaxPool
: dtBalance
setAmountMax(amountMax)
}
getMaximum()
}, [ocean, poolAddress, coin, dtBalance, balance.ocean])
// Submit
async function handleAddLiquidity(amount: number, resetForm: () => void) {
try {
const result =
coin === 'OCEAN'
? await ocean.pool.addOceanLiquidity(accountId, poolAddress, amount)
: await ocean.pool.addDTLiquidity(accountId, poolAddress, amount)
? await ocean.pool.addOceanLiquidity(
accountId,
poolAddress,
`${amount}`
)
: await ocean.pool.addDTLiquidity(accountId, poolAddress, `${amount}`)
setTxId(result?.transactionHash)
resetForm()
} catch (error) {
console.error(error.message)
toast.error(error.message)
} finally {
setIsLoading(false)
}
}
function handleAmountChange(e: ChangeEvent<HTMLInputElement>) {
setAmount(e.target.value)
}
function handleMax() {
setAmount(coin === 'OCEAN' ? balance.ocean : dtBalance)
}
// TODO: this is only a prototype and is an accessibility nightmare.
// Needs to be refactored to either use custom select element instead of tippy.js,
// or use <button> in this implementation.
// Also needs to be closed when users click an option.
const CoinSelect = () => (
<ul className={styles.coinPopover}>
<li onClick={() => setCoin('OCEAN')}>OCEAN</li>
<li onClick={() => setCoin(dtSymbol)}>{dtSymbol}</li>
</ul>
)
return (
<>
<Header title={content.title} backAction={() => setShowAdd(false)} />
<div className={styles.addInput}>
<div className={styles.userLiquidity}>
<span>Available: </span>
{coin === 'OCEAN' ? (
<PriceUnit price={balance.ocean} symbol="OCEAN" small />
) : (
<PriceUnit price={dtBalance} symbol={dtSymbol} small />
)}
</div>
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={async (values, { setSubmitting, resetForm }) => {
await handleAddLiquidity(values.amount, resetForm)
setSubmitting(false)
}}
>
{({
values,
touched,
setTouched,
isSubmitting,
setFieldValue,
submitForm,
handleChange
}) => {
const newPoolTokens =
totalBalance &&
((values.amount / Number(totalBalance.ocean)) * 100).toFixed(2)
<InputElement
value={amount}
name="coin"
type="number"
prefix={
<Tooltip
content={<CoinSelect />}
trigger="click focus"
className={styles.coinswitch}
placement="bottom"
>
{coin}
<Caret aria-hidden="true" />
</Tooltip>
}
placeholder="0"
onChange={handleAmountChange}
/>
const newPoolShare =
totalBalance &&
((Number(newPoolTokens) / Number(totalPoolTokens)) * 100).toFixed(2)
{(balance.ocean || dtBalance) > amount && (
<Button
className={styles.buttonMax}
style="text"
size="small"
onClick={handleMax}
>
Use Max
</Button>
)}
</div>
return (
<>
<div className={styles.addInput}>
<div className={styles.userLiquidity}>
<div>
<span>Available:</span>
{coin === 'OCEAN' ? (
<PriceUnit price={balance.ocean} symbol="OCEAN" small />
) : (
<PriceUnit price={dtBalance} symbol={dtSymbol} small />
)}
</div>
<div>
<span>Maximum:</span>
<PriceUnit price={amountMax} symbol={coin} small />
</div>
</div>
<div className={styles.output}>
<div>
<p>{content.output.titleIn}</p>
<Token symbol="pool shares" balance={newPoolTokens} />
<Token symbol="% of pool" balance={newPoolShare} />
</div>
<div>
<p>{content.output.titleOut}</p>
<Token symbol="% swap fee" balance={swapFee} />
</div>
</div>
<Field name="amount">
{({
field,
form
}: {
field: FieldInputProps<FormAddLiquidity>
form: any
}) => (
<Input
type="number"
max={amountMax}
value={`${values.amount}`}
prefix={
<CoinSelect dtSymbol={dtSymbol} setCoin={setCoin} />
}
placeholder="0"
field={field}
form={form}
onChange={(e) => {
// Workaround so validation kicks in on first touch
!touched?.amount && setTouched({ amount: true })
handleChange(e)
}}
/>
)}
</Field>
<Actions
isLoading={isLoading}
loaderMessage="Adding Liquidity..."
actionName={content.action}
action={handleAddLiquidity}
txId={txId}
/>
{(Number(balance.ocean) || dtBalance) >
(values.amount || 0) && (
<Button
className={styles.buttonMax}
style="text"
size="small"
onClick={() => setFieldValue('amount', amountMax)}
>
Use Max
</Button>
)}
</div>
<div className={styles.output}>
<div>
<p>{content.output.titleIn}</p>
<Token symbol="pool shares" balance={newPoolTokens} />
<Token symbol="% of pool" balance={newPoolShare} />
</div>
<div>
<p>{content.output.titleOut}</p>
<Token symbol="% swap fee" balance={swapFee} />
</div>
</div>
<Actions
isLoading={isSubmitting}
loaderMessage="Adding Liquidity..."
actionName={content.action}
action={submitForm}
txId={txId}
/>
</>
)
}}
</Formik>
</>
)
}

View File

@ -0,0 +1,26 @@
.coinSelect {
composes: select from '../../../atoms/Input/InputElement.module.css';
font-size: var(--font-size-small);
font-weight: var(--font-weight-base);
border: none;
margin-left: -0.5rem;
margin-right: -0.5rem;
background-color: var(--brand-grey-dimmed);
width: auto;
padding: 0 1.25rem 0 0.25rem;
height: 41px;
text-align: center;
/* custom arrow, without the divider line */
background-image: linear-gradient(
45deg,
transparent 50%,
var(--brand-purple) 50%
),
linear-gradient(135deg, var(--brand-grey) 50%, transparent 50%);
background-position: calc(100% - 14px) 1.2rem, calc(100% - 9px) 1.2rem, 100% 0;
}
.option {
color: var(--brand-grey-dark);
}

View File

@ -0,0 +1,24 @@
import React, { ReactElement } from 'react'
import styles from './CoinSelect.module.css'
export default function CoinSelect({
dtSymbol,
setCoin
}: {
dtSymbol: string
setCoin: (coin: string) => void
}): ReactElement {
return (
<select
className={styles.coinSelect}
onChange={(e) => setCoin(e.target.value)}
>
<option className={styles.option} value="OCEAN">
OCEAN
</option>
<option className={styles.option} value={dtSymbol}>
{dtSymbol}
</option>
</select>
)
}

View File

@ -1,9 +1,9 @@
import Alert from '../../atoms/Alert'
import Success from './Success'
import Button from '../../atoms/Button'
import Loader from '../../atoms/Loader'
import React, { ReactElement } from 'react'
import styles from './Feedback.module.css'
import SuccessConfetti from '../../atoms/SuccessConfetti'
export default function Feedback({
error,
@ -18,6 +18,17 @@ export default function Feedback({
publishStepText: string
setError: (error: string) => void
}): ReactElement {
const SuccessAction = () => (
<Button
style="primary"
size="small"
href={`/asset/${did}`}
className={styles.action}
>
Go to data set
</Button>
)
return (
<div className={styles.feedback}>
<div className={styles.box}>
@ -35,7 +46,7 @@ export default function Feedback({
</Button>
</>
) : success ? (
<Success success={success} did={did} />
<SuccessConfetti success={success} action={<SuccessAction />} />
) : (
<Loader message={publishStepText} />
)}

View File

@ -1,7 +1,7 @@
import React, { ReactElement, useEffect, FormEvent } from 'react'
import styles from './PublishForm.module.css'
import { useOcean } from '@oceanprotocol/react'
import { useFormikContext, Form, Field } from 'formik'
import { useFormikContext, Field } from 'formik'
import Input from '../../atoms/Input'
import Button from '../../atoms/Button'
import { FormContent, FormFieldProps } from '../../../@types/Form'
@ -37,7 +37,7 @@ export default function PublishForm({
}
return (
<Form
<form
className={styles.form}
// do we need this?
onChange={() => status === 'empty' && setStatus(null)}
@ -61,6 +61,6 @@ export default function PublishForm({
</Button>
)}
</footer>
</Form>
</form>
)
}

View File

@ -1,3 +0,0 @@
.action {
margin-top: calc(var(--spacer) / 1.5);
}