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", "titleIn": "You will receive",
"titleOut": "You will earn" "titleOut": "You will earn"
}, },
"action": "Supply" "action": "Approve & Supply"
}, },
"remove": { "remove": {
"title": "Remove Liquidity", "title": "Remove Liquidity",
@ -20,7 +20,7 @@
"titleIn": "You will spend", "titleIn": "You will spend",
"titleOut": "You will receive" "titleOut": "You will receive"
}, },
"action": "Remove" "action": "Approve & Remove"
} }
} }
} }

View File

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

View File

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

View File

@ -13,12 +13,18 @@
} }
.error { .error {
font-size: var(--font-size-small); display: inline-block;
color: var(--brand-alert-red); 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; position: absolute;
text-align: right;
right: 0; right: 0;
top: 0; top: 85%;
z-index: 1;
} }
.hasError label { .hasError label {
@ -26,7 +32,10 @@
} }
.hasError input, .hasError input,
.hasError input:focus,
.hasError select, .hasError select,
.hasError textarea { .hasError textarea,
.hasError [class*='prefix'],
.hasError [class*='postfix'] {
border-color: var(--brand-alert-red); border-color: var(--brand-alert-red);
} }

View File

@ -3,7 +3,7 @@ import InputElement from './InputElement'
import Help from './Help' import Help from './Help'
import Label from './Label' import Label from './Label'
import styles from './index.module.css' import styles from './index.module.css'
import { ErrorMessage } from 'formik' import { ErrorMessage, FieldInputProps } from 'formik'
import classNames from 'classnames/bind' import classNames from 'classnames/bind'
const cx = classNames.bind(styles) const cx = classNames.bind(styles)
@ -33,7 +33,7 @@ export interface InputProps {
max?: string max?: string
disabled?: boolean disabled?: boolean
readOnly?: boolean readOnly?: boolean
field?: any field?: FieldInputProps<any>
form?: any form?: any
prefix?: string | ReactElement prefix?: string | ReactElement
postfix?: string | ReactElement postfix?: string | ReactElement
@ -71,7 +71,7 @@ export default function Input(props: Partial<InputProps>): ReactElement {
</Label> </Label>
<InputElement small={small} {...field} {...props} /> <InputElement small={small} {...field} {...props} />
{field && field.name !== 'price' && ( {field && field.name !== 'price' && hasError && (
<div className={styles.error}> <div className={styles.error}>
<ErrorMessage name={field.name} /> <ErrorMessage name={field.name} />
</div> </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 Alert from './Alert'
import Button from '../../atoms/Button' import React, { ReactElement, ReactNode, useEffect } from 'react'
import React, { ReactElement, useEffect } from 'react'
import { confetti } from 'dom-confetti' import { confetti } from 'dom-confetti'
import styles from './Success.module.css' import styles from './SuccessConfetti.module.css'
const confettiConfig = { const confettiConfig = {
angle: 90, angle: 90,
@ -24,33 +23,29 @@ const confettiConfig = {
] ]
} }
export default function Success({ export default function SuccessConfetti({
success, success,
did action
}: { }: {
success: string success: string
did: string action: ReactNode
}): ReactElement { }): ReactElement {
// Have some confetti upon success // Have some confetti upon success
useEffect(() => { useEffect(() => {
if (!success || typeof window === 'undefined') return 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) confetti(startElement, confettiConfig)
}, [success]) }, [success])
return ( return (
<> <>
<Alert text={success} state="success" /> <Alert text={success} state="success" />
<Button <span className={styles.action} data-confetti>
style="primary" {action}
size="small" </span>
href={`/asset/${did}`}
className={styles.action}
data-confetti
>
Go to data set
</Button>
</> </>
) )
} }

View File

@ -11,18 +11,18 @@
.balance { .balance {
text-align: center; text-align: center;
font-size: var(--font-size-small); font-size: var(--font-size-small) !important;
border: 1px solid var(--brand-grey-lighter); border: 1px solid var(--brand-grey-lighter);
border-right: 0; border-right: 0;
margin-right: -3px; 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-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius); border-bottom-left-radius: var(--border-radius);
color: var(--color-secondary); color: var(--color-secondary);
} display: flex;
align-items: center;
.balance strong {
color: var(--brand-grey);
} }
.title { .title {

View File

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

View File

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

View File

@ -1,8 +1,8 @@
.addInput { .addInput {
margin: 0 auto calc(var(--spacer) / 1.5) auto; margin: 0 auto calc(var(--spacer) / 1.5) auto;
background: var(--brand-grey-dimmed); background: var(--brand-grey-dimmed);
padding: var(--spacer) calc(var(--spacer) * 3) calc(var(--spacer) * 1.2) padding: var(--spacer) calc(var(--spacer) * 2.5) calc(var(--spacer) * 1.2)
calc(var(--spacer) * 3); calc(var(--spacer) * 2.5);
border-bottom: 1px solid var(--brand-grey-lighter); border-bottom: 1px solid var(--brand-grey-lighter);
margin-top: -2rem; margin-top: -2rem;
margin-left: -2rem; margin-left: -2rem;
@ -14,45 +14,34 @@
text-align: center; text-align: center;
} }
.addInput div[class*='field'] {
margin-bottom: 0;
}
.buttonMax { .buttonMax {
position: absolute; position: absolute;
font-size: var(--font-size-mini); font-size: var(--font-size-mini);
bottom: calc(var(--spacer) / 2); bottom: calc(var(--spacer) / 2);
right: calc(var(--spacer) * 3); right: calc(var(--spacer) * 2.5);
} }
.userLiquidity { .userLiquidity > div {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: var(--font-size-mini); font-size: var(--font-size-mini);
margin-bottom: calc(var(--spacer) / 4);
color: var(--color-secondary); color: var(--color-secondary);
} }
.userLiquidity > div:last-child {
margin-bottom: calc(var(--spacer) / 4);
}
.userLiquidity span + div { .userLiquidity span + div {
transform: scale(0.8); transform: scale(0.8);
transform-origin: right center; 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 { .output {
display: grid; display: grid;
gap: var(--spacer); 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 styles from './Add.module.css'
import { useOcean } from '@oceanprotocol/react' import { useOcean } from '@oceanprotocol/react'
import Header from './Header' import Header from './Header'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import InputElement from '../../../atoms/Input/InputElement'
import Button from '../../../atoms/Button' import Button from '../../../atoms/Button'
import Token from './Token' import Token from './Token'
import { Balance } from './' import { Balance } from './'
import PriceUnit from '../../../atoms/Price/PriceUnit' import PriceUnit from '../../../atoms/Price/PriceUnit'
import Actions from './Actions' import Actions from './Actions'
import Tooltip from '../../../atoms/Tooltip'
import { ReactComponent as Caret } from '../../../../images/caret.svg'
import { graphql, useStaticQuery } from 'gatsby' 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` const contentQuery = graphql`
query PoolAddQuery { query PoolAddQuery {
@ -36,6 +37,14 @@ const contentQuery = graphql`
} }
` `
interface FormAddLiquidity {
amount: number
}
const initialValues: FormAddLiquidity = {
amount: undefined
}
export default function Add({ export default function Add({
setShowAdd, setShowAdd,
poolAddress, poolAddress,
@ -57,106 +66,158 @@ export default function Add({
const content = data.content.edges[0].node.childContentJson.pool.add const content = data.content.edges[0].node.childContentJson.pool.add
const { ocean, accountId, balance } = useOcean() const { ocean, accountId, balance } = useOcean()
const [amount, setAmount] = useState('') const [txId, setTxId] = useState<string>()
const [txId, setTxId] = useState<string>('') const [coin, setCoin] = useState('OCEAN')
const [isLoading, setIsLoading] = useState<boolean>()
const [coin, setCoin] = useState<string>('OCEAN')
const [dtBalance, setDtBalance] = useState<string>() const [dtBalance, setDtBalance] = useState<string>()
const [amountMax, setAmountMax] = useState<string>()
const newPoolTokens = // Live validation rules
totalBalance && // https://github.com/jquense/yup#number
((Number(amount) / Number(totalBalance.ocean)) * 100).toFixed(2) const validationSchema = Yup.object().shape<FormAddLiquidity>({
amount: Yup.number()
const newPoolShare = .min(1, 'Must be more or equal to 1')
totalBalance && .max(
((Number(newPoolTokens) / Number(totalPoolTokens)) * 100).toFixed(2) Number(amountMax),
`Maximum you can add is ${Number(amountMax).toFixed(2)} ${coin}`
)
.required('Required')
})
// Get datatoken balance when datatoken selected
useEffect(() => { useEffect(() => {
if (!ocean) return if (!ocean || coin === 'OCEAN') return
async function getDtBalance() { async function getDtBalance() {
const dtBalance = await ocean.datatokens.balance(dtAddress, accountId) const dtBalance = await ocean.datatokens.balance(dtAddress, accountId)
setDtBalance(dtBalance) setDtBalance(dtBalance)
} }
getDtBalance() getDtBalance()
}, [ocean, accountId, dtAddress]) }, [ocean, accountId, dtAddress, coin])
async function handleAddLiquidity() { // Get maximum amount for either OCEAN or datatoken
setIsLoading(true) 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 { try {
const result = const result =
coin === 'OCEAN' coin === 'OCEAN'
? await ocean.pool.addOceanLiquidity(accountId, poolAddress, amount) ? await ocean.pool.addOceanLiquidity(
: await ocean.pool.addDTLiquidity(accountId, poolAddress, amount) accountId,
poolAddress,
`${amount}`
)
: await ocean.pool.addDTLiquidity(accountId, poolAddress, `${amount}`)
setTxId(result?.transactionHash) setTxId(result?.transactionHash)
resetForm()
} catch (error) { } catch (error) {
console.error(error.message) console.error(error.message)
toast.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 ( return (
<> <>
<Header title={content.title} backAction={() => setShowAdd(false)} /> <Header title={content.title} backAction={() => setShowAdd(false)} />
<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)
const newPoolShare =
totalBalance &&
((Number(newPoolTokens) / Number(totalPoolTokens)) * 100).toFixed(2)
return (
<>
<div className={styles.addInput}> <div className={styles.addInput}>
<div className={styles.userLiquidity}> <div className={styles.userLiquidity}>
<span>Available: </span> <div>
<span>Available:</span>
{coin === 'OCEAN' ? ( {coin === 'OCEAN' ? (
<PriceUnit price={balance.ocean} symbol="OCEAN" small /> <PriceUnit price={balance.ocean} symbol="OCEAN" small />
) : ( ) : (
<PriceUnit price={dtBalance} symbol={dtSymbol} small /> <PriceUnit price={dtBalance} symbol={dtSymbol} small />
)} )}
</div> </div>
<div>
<span>Maximum:</span>
<PriceUnit price={amountMax} symbol={coin} small />
</div>
</div>
<InputElement <Field name="amount">
value={amount} {({
name="coin" field,
form
}: {
field: FieldInputProps<FormAddLiquidity>
form: any
}) => (
<Input
type="number" type="number"
max={amountMax}
value={`${values.amount}`}
prefix={ prefix={
<Tooltip <CoinSelect dtSymbol={dtSymbol} setCoin={setCoin} />
content={<CoinSelect />}
trigger="click focus"
className={styles.coinswitch}
placement="bottom"
>
{coin}
<Caret aria-hidden="true" />
</Tooltip>
} }
placeholder="0" placeholder="0"
onChange={handleAmountChange} field={field}
form={form}
onChange={(e) => {
// Workaround so validation kicks in on first touch
!touched?.amount && setTouched({ amount: true })
handleChange(e)
}}
/> />
)}
</Field>
{(balance.ocean || dtBalance) > amount && ( {(Number(balance.ocean) || dtBalance) >
(values.amount || 0) && (
<Button <Button
className={styles.buttonMax} className={styles.buttonMax}
style="text" style="text"
size="small" size="small"
onClick={handleMax} onClick={() => setFieldValue('amount', amountMax)}
> >
Use Max Use Max
</Button> </Button>
@ -176,12 +237,16 @@ export default function Add({
</div> </div>
<Actions <Actions
isLoading={isLoading} isLoading={isSubmitting}
loaderMessage="Adding Liquidity..." loaderMessage="Adding Liquidity..."
actionName={content.action} actionName={content.action}
action={handleAddLiquidity} action={submitForm}
txId={txId} 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 Alert from '../../atoms/Alert'
import Success from './Success'
import Button from '../../atoms/Button' import Button from '../../atoms/Button'
import Loader from '../../atoms/Loader' import Loader from '../../atoms/Loader'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import styles from './Feedback.module.css' import styles from './Feedback.module.css'
import SuccessConfetti from '../../atoms/SuccessConfetti'
export default function Feedback({ export default function Feedback({
error, error,
@ -18,6 +18,17 @@ export default function Feedback({
publishStepText: string publishStepText: string
setError: (error: string) => void setError: (error: string) => void
}): ReactElement { }): ReactElement {
const SuccessAction = () => (
<Button
style="primary"
size="small"
href={`/asset/${did}`}
className={styles.action}
>
Go to data set
</Button>
)
return ( return (
<div className={styles.feedback}> <div className={styles.feedback}>
<div className={styles.box}> <div className={styles.box}>
@ -35,7 +46,7 @@ export default function Feedback({
</Button> </Button>
</> </>
) : success ? ( ) : success ? (
<Success success={success} did={did} /> <SuccessConfetti success={success} action={<SuccessAction />} />
) : ( ) : (
<Loader message={publishStepText} /> <Loader message={publishStepText} />
)} )}

View File

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

View File

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