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

Swap tokens (#204)

* swap

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* validation and calculation

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* refactor

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* remove unused effect

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* fix interval

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* increase refresh timer, remove optional params

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* make inputs show up without wallet

* style fixes

* restyling

* styling

* more styling

* fix refresh price

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* remove test effect

* fixes, get data as early as possible from DDO and initial state

* refactor

* refactor

* refactor

* label tweaks

* copy

* typo

* prototype output

* remove price header

* ouput swap fee

* fix

* spacing

* copy

* refactor pool transaction titles

* copy

* update math

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* use messaging tweaks

* tab tweaks, output refactor

* fix dark mode selection style

* prototype output

* method tweaks

* slippage to 1%, added warnig banner

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* form tweaks

* error fix

* empty inputs by default

* longer intervals

* maxOcean validation fix

* slippage tolerance UI

* modified slippage UI

* refactor, refresh ocean user balance

* move typings/models around

* typing fix

* fixed output values

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* bump oceanlib

Signed-off-by: mihaisc <mihai.scarlat@smartcontrol.ro>

* remove console.log

* remove placeholder

* tweak

* non-web3 browser tweak

Co-authored-by: Matthias Kretschmann <m@kretschmann.io>
This commit is contained in:
mihaisc 2020-11-16 17:21:15 +02:00 committed by GitHub
parent 461fcaf8ae
commit bb80c4df78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 920 additions and 108 deletions

View File

@ -47,5 +47,9 @@
},
"action": "Approve & Remove"
}
},
"trade": {
"action": "Approve & 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)."
}
}

6
package-lock.json generated
View File

@ -3550,9 +3550,9 @@
"integrity": "sha512-p0oOHXr60hXZuLNsQ/PsOQtCfia79thm7MjPxTrnnBvD+csJoHzARYMB0IFj/KTw6U5vLXODgjJAn8x6QksLwg=="
},
"@oceanprotocol/lib": {
"version": "0.9.12",
"resolved": "https://registry.npmjs.org/@oceanprotocol/lib/-/lib-0.9.12.tgz",
"integrity": "sha512-R52kWSwwpKNzNHfnNbF6seFPvXEtExK3bWIi4V4eIkgmAf272sa6PVza4mJrtEpTAS1WcJv5ihF7cczIDecxbg==",
"version": "0.9.14",
"resolved": "https://registry.npmjs.org/@oceanprotocol/lib/-/lib-0.9.14.tgz",
"integrity": "sha512-UDJpDHqJ5o6O/cOdTuyxhABaebQM1vz+RyfC3zVRSdz5Ke/5xjAChk+3LlYbXBA8VaacxioYa4OlaJLE5uB+Qg==",
"requires": {
"@ethereum-navigator/navigator": "^0.5.0",
"@oceanprotocol/contracts": "^0.5.7",

View File

@ -24,7 +24,7 @@
"@coingecko/cryptoformat": "^0.4.2",
"@loadable/component": "^5.14.1",
"@oceanprotocol/art": "^3.0.0",
"@oceanprotocol/lib": "^0.9.12",
"@oceanprotocol/lib": "^0.9.14",
"@oceanprotocol/list-datapartners": "^1.0.3",
"@oceanprotocol/react": "^0.3.19",
"@oceanprotocol/typographies": "^0.1.0",

4
src/@types/TokenBalance.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export default interface TokenBalance {
ocean: number
datatoken: number
}

View File

@ -26,7 +26,7 @@
.input::placeholder {
font-family: var(--font-family-base);
font-size: var(--font-size-base);
color: var(--brand-grey-light);
color: var(--color-secondary);
font-weight: var(--font-weight-base);
transition: 0.2s ease-out;
opacity: 0.7;
@ -167,6 +167,7 @@
.postfixGroup {
display: inline-flex;
align-items: center;
width: 100%;
}
.prefixGroup input {
@ -259,6 +260,31 @@ input[type='range']::-moz-range-track {
/* Size modifiers */
.mini,
.select.mini {
font-size: var(--font-size-mini);
height: 24px;
padding: calc(var(--spacer) / 8);
}
.mini::placeholder {
font-size: var(--font-size-mini);
}
.prefix.mini,
.postfix.mini {
height: 24px;
font-size: var(--font-size-mini);
}
.select.mini {
padding-right: 2rem;
/* custom arrow */
background-position: calc(100% - 14px) 0.6rem, calc(100% - 9px) 0.6rem, 100% 0;
background-size: 5px 5px, 5px 5px, 1.75rem 3rem;
}
.small,
.select.small {
font-size: var(--font-size-small);

View File

@ -26,6 +26,7 @@ const DefaultInput = ({
export default function InputElement({
type,
options,
sortOptions,
name,
prefix,
postfix,
@ -40,22 +41,25 @@ export default function InputElement({
const styleClasses = cx({ select: true, [size]: size })
switch (type) {
case 'select':
case 'select': {
const sortedOptions =
!sortOptions && sortOptions === false
? options
: options.sort((a: string, b: string) => a.localeCompare(b))
return (
<select id={name} className={styleClasses} {...props}>
{field !== undefined && field.value === '' && (
<option value="">---</option>
)}
{options &&
options
.sort((a: string, b: string) => a.localeCompare(b))
.map((option: string, index: number) => (
<option key={index} value={option}>
{option}
</option>
))}
{sortedOptions &&
sortedOptions.map((option: string, index: number) => (
<option key={index} value={option}>
{option} {postfix}
</option>
))}
</select>
)
}
case 'textarea':
return (
<textarea

View File

@ -17,6 +17,7 @@ export interface InputProps {
tag?: string
type?: string
options?: string[]
sortOptions?: boolean
additionalComponent?: ReactElement
value?: string
onChange?(
@ -39,7 +40,7 @@ export interface InputProps {
postfix?: string | ReactElement
step?: string
defaultChecked?: boolean
size?: 'small' | 'large' | 'default'
size?: 'mini' | 'small' | 'large' | 'default'
}
export default function Input(props: Partial<InputProps>): ReactElement {

View File

@ -8,17 +8,30 @@
.tab {
display: inline-block;
padding: calc(var(--spacer) / 12) var(--spacer);
border-radius: var(--border-radius);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-small);
text-transform: uppercase;
cursor: pointer;
color: var(--color-secondary);
border: 1px solid var(--border-color);
margin-right: -1px;
min-width: 100px;
}
.tab:first-child {
border-top-left-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
}
.tab:last-child {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
.tab[aria-selected='true'] {
background: var(--font-color-heading);
color: var(--background-body);
border-color: var(--font-color-heading);
}
.tabContent {

View File

@ -0,0 +1,12 @@
.userLiquidity > div {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-mini);
color: var(--color-secondary);
}
.userLiquidity span + div {
transform: scale(0.8);
transform-origin: right center;
}

View File

@ -0,0 +1,51 @@
import React, { ReactElement } from 'react'
import PriceUnit from './Price/PriceUnit'
import styles from './UserLiquidity.module.css'
function UserLiquidityLine({
title,
amount,
symbol
}: {
title: string
amount: string
symbol: string
}) {
return (
<div>
<span>{title}</span>
<PriceUnit price={amount} symbol={symbol} small />
</div>
)
}
export default function UserLiquidity({
amount,
symbol,
amountMax,
titleAvailable = 'Balance',
titleMaximum = 'Maximum'
}: {
amount: string
symbol: string
titleAvailable?: string
titleMaximum?: string
amountMax?: string
}): ReactElement {
return (
<div className={styles.userLiquidity}>
<UserLiquidityLine
title={titleAvailable}
amount={amount}
symbol={symbol}
/>
{amountMax && (
<UserLiquidityLine
title={titleMaximum}
amount={amountMax}
symbol={symbol}
/>
)}
</div>
)
}

View File

@ -20,13 +20,19 @@ function Title({ row }: { row: PoolTransaction }) {
const [dtSymbol, setDtSymbol] = useState<string>()
const { locale } = useUserPreferences()
const title = row.tokenAmountIn
? `Add ${formatNumber(Number(row.tokenAmountIn), locale)} ${
dtSymbol || 'OCEAN'
}`
: `Remove ${formatNumber(Number(row.tokenAmountOut), locale)} ${
dtSymbol || 'OCEAN'
}`
const symbol = dtSymbol || 'OCEAN'
const title =
row.type === 'join'
? `Add ${formatNumber(Number(row.tokenAmountIn), locale)} ${symbol}`
: row.type === 'exit'
? `Remove ${formatNumber(Number(row.tokenAmountOut), locale)} ${symbol}`
: `Swap ${formatNumber(
Number(row.tokenAmountIn),
locale
)} ${symbol} for ${formatNumber(
Number(row.tokenAmountOut),
locale
)} ${symbol}`
useEffect(() => {
if (!ocean) return

View File

@ -23,6 +23,6 @@
width: 100%;
}
.hasTokens {
composes: hasTokens from './index.module.css';
.help {
composes: help from './index.module.css';
}

View File

@ -17,8 +17,8 @@
margin-top: calc(var(--spacer) / 2);
}
.hasTokens {
composes: hasTokens from './index.module.css';
.help {
composes: help from './index.module.css';
}
.feedback {

View File

@ -94,9 +94,23 @@ export default function Consume({
{consumeStepText || pricingIsLoading ? (
<Loader message={consumeStepText || pricingStepText} />
) : (
<Button style="primary" onClick={handleConsume} disabled={isDisabled}>
{hasDatatoken || hasPreviousOrder ? 'Download' : 'Buy'}
</Button>
<>
<Button style="primary" onClick={handleConsume} disabled={isDisabled}>
{hasDatatoken || hasPreviousOrder ? 'Download' : 'Buy'}
</Button>
{hasDatatoken && (
<div className={styles.help}>
You own {dtBalance} {dtSymbol} allowing you to use this data set
without paying again.
</div>
)}
{(!hasDatatoken || !hasPreviousOrder) && (
<div className={styles.help}>
For using this data set, you will buy 1 {dtSymbol} and immediatly
spend it back to the publisher and pool.
</div>
)}
</>
)}
</div>
)
@ -109,12 +123,6 @@ export default function Consume({
</div>
<div className={styles.pricewrapper}>
<Price ddo={ddo} conversion />
{hasDatatoken && (
<div className={styles.hasTokens}>
You own {dtBalance} {dtSymbol} allowing you to use this data set
without paying again.
</div>
)}
{!isInPurgatory && <PurchaseButton />}
</div>
</div>

View File

@ -3,7 +3,6 @@
margin-right: -2rem;
padding-left: var(--spacer);
padding-right: var(--spacer);
margin-top: calc(var(--spacer) / 4);
padding-top: calc(var(--spacer) / 1.5);
border-top: 1px solid var(--border-color);
text-align: center;

View File

@ -12,7 +12,8 @@ export default function Actions({
successMessage,
txId,
actionName,
action
action,
isDisabled
}: {
isLoading: boolean
loaderMessage: string
@ -20,6 +21,7 @@ export default function Actions({
txId: string
actionName: string
action: () => void
isDisabled?: boolean
}): ReactElement {
const { networkId, ocean } = useOcean()
@ -33,7 +35,7 @@ export default function Actions({
style="primary"
size="small"
onClick={() => action()}
disabled={!ocean}
disabled={!ocean || isDisabled}
>
{actionName}
</Button>

View File

@ -4,20 +4,3 @@
bottom: calc(var(--spacer) / 2);
right: calc(var(--spacer) * 2.5);
}
.userLiquidity > div {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-mini);
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;
}

View File

@ -1,4 +1,3 @@
import PriceUnit from '../../../../atoms/Price/PriceUnit'
import React, { ChangeEvent, ReactElement, useEffect } from 'react'
import styles from './FormAdd.module.css'
import Input from '../../../../atoms/Input'
@ -12,7 +11,8 @@ import Button from '../../../../atoms/Button'
import CoinSelect from '../CoinSelect'
import { FormAddLiquidity } from '.'
import { useOcean } from '@oceanprotocol/react'
import { Balance } from '..'
import TokenBalance from '../../../../../@types/TokenBalance'
import UserLiquidity from '../../../../atoms/UserLiquidity'
export default function FormAdd({
coin,
@ -32,7 +32,7 @@ export default function FormAdd({
amountMax: string
setCoin: (value: string) => void
totalPoolTokens: string
totalBalance: Balance
totalBalance: TokenBalance
poolAddress: string
setNewPoolTokens: (value: string) => void
setNewPoolShare: (value: string) => void
@ -86,20 +86,11 @@ export default function FormAdd({
return (
<>
<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>
<UserLiquidity
amount={coin === 'OCEAN' ? balance.ocean : dtBalance}
amountMax={amountMax}
symbol={coin}
/>
<Field name="amount">
{({

View File

@ -22,6 +22,7 @@
display: grid;
gap: var(--spacer);
grid-template-columns: 1fr 1fr;
padding-bottom: calc(var(--spacer) / 2);
}
.output p {

View File

@ -2,7 +2,6 @@ import React, { ReactElement, useState, useEffect } from 'react'
import { useOcean } from '@oceanprotocol/react'
import Header from '../Header'
import { toast } from 'react-toastify'
import { Balance } from '..'
import Actions from '../Actions'
import { graphql, useStaticQuery } from 'gatsby'
import * as Yup from 'yup'
@ -11,6 +10,7 @@ import FormAdd from './FormAdd'
import styles from './index.module.css'
import Token from '../Token'
import Alert from '../../../../atoms/Alert'
import TokenBalance from '../../../../../@types/TokenBalance'
import { useUserPreferences } from '../../../../../providers/UserPreferences'
const contentQuery = graphql`
@ -59,7 +59,7 @@ export default function Add({
refreshInfo: () => void
poolAddress: string
totalPoolTokens: string
totalBalance: Balance
totalBalance: TokenBalance
swapFee: string
dtSymbol: string
dtAddress: string
@ -199,6 +199,7 @@ export default function Add({
</div>
<Actions
isDisabled={!isWarningAccepted}
isLoading={isSubmitting}
loaderMessage="Adding Liquidity..."
successMessage="Successfully added liquidity."

View File

@ -5,16 +5,8 @@
padding-bottom: calc(var(--spacer) / 2);
}
.userLiquidity {
composes: userLiquidity from './Add/FormAdd.module.css';
max-width: 12rem;
margin-top: -1rem;
margin-left: auto;
margin-right: auto;
margin-bottom: calc(var(--spacer) / 2);
}
.range {
margin-top: calc(var(--spacer) / 2);
text-align: center;
}

View File

@ -19,6 +19,7 @@ import { getMaxPercentRemove } from './utils'
import { graphql, useStaticQuery } from 'gatsby'
import PriceUnit from '../../../atoms/Price/PriceUnit'
import debounce from 'lodash.debounce'
import UserLiquidity from '../../../atoms/UserLiquidity'
const contentQuery = graphql`
query PoolRemoveQuery {
@ -137,7 +138,6 @@ export default function Remove({
// Check and set outputs when amountPoolShares changes
useEffect(() => {
if (!ocean || !poolTokens) return
console.log('eff', amountPoolShares, isAdvanced)
getValues.current(amountPoolShares, isAdvanced)
}, [
amountPoolShares,
@ -184,12 +184,7 @@ export default function Remove({
<Header title={content.title} backAction={() => setShowRemove(false)} />
<form className={styles.removeInput}>
<div className={styles.userLiquidity}>
<div>
<span>Available:</span>
<PriceUnit price={poolTokens} symbol="pool shares" small />
</div>
</div>
<UserLiquidity amount={poolTokens} symbol="pool shares" />
<div className={styles.range}>
<h3>{amountPercent}%</h3>

View File

@ -46,6 +46,7 @@
text-align: center;
border-top: 1px solid var(--border-color);
padding-top: calc(var(--spacer) / 4);
padding-bottom: calc(var(--spacer) / 4);
}
.update:before {

View File

@ -17,15 +17,11 @@ import EtherscanLink from '../../../atoms/EtherscanLink'
import Token from './Token'
import TokenList from './TokenList'
import { graphql, useStaticQuery } from 'gatsby'
import TokenBalance from '../../../../@types/TokenBalance'
import Transactions from './Transactions'
import Graph, { ChartDataLiqudity } from './Graph'
import axios from 'axios'
export interface Balance {
ocean: number
datatoken: number
}
const contentQuery = graphql`
query PoolQuery {
content: allFile(filter: { relativePath: { eq: "price.json" } }) {
@ -58,7 +54,7 @@ export default function Pool({ ddo }: { ddo: DDO }): ReactElement {
const [poolTokens, setPoolTokens] = useState<string>()
const [totalPoolTokens, setTotalPoolTokens] = useState<string>()
const [userLiquidity, setUserLiquidity] = useState<Balance>()
const [userLiquidity, setUserLiquidity] = useState<TokenBalance>()
const [swapFee, setSwapFee] = useState<string>()
const [weightOcean, setWeightOcean] = useState<string>()
const [weightDt, setWeightDt] = useState<string>()
@ -76,7 +72,7 @@ export default function Pool({ ddo }: { ddo: DDO }): ReactElement {
creatorTotalLiquidityInOcean,
setCreatorTotalLiquidityInOcean
] = useState(0)
const [creatorLiquidity, setCreatorLiquidity] = useState<Balance>()
const [creatorLiquidity, setCreatorLiquidity] = useState<TokenBalance>()
const [creatorPoolTokens, setCreatorPoolTokens] = useState<string>()
const [creatorPoolShare, setCreatorPoolShare] = useState<string>()
const [graphData, setGraphData] = useState<ChartDataLiqudity>()
@ -84,6 +80,12 @@ export default function Pool({ ddo }: { ddo: DDO }): ReactElement {
// the purpose of the value is just to trigger the effect
const [refreshPool, setRefreshPool] = useState(false)
useEffect(() => {
// Re-fetch price periodically, triggering re-calculation of everything
const interval = setInterval(() => refreshPrice(), refreshInterval)
return () => clearInterval(interval)
}, [ddo, refreshPrice])
useEffect(() => {
setIsRemoveDisabled(isInPurgatory && owner === accountId)
}, [isInPurgatory, owner, accountId])

View File

@ -0,0 +1,5 @@
.alertWrap {
min-height: 320px;
display: flex;
align-items: center;
}

View File

@ -0,0 +1,155 @@
import React, { ReactElement, useState } from 'react'
import { useOcean } from '@oceanprotocol/react'
import { BestPrice, DDO, Logger } from '@oceanprotocol/lib'
import * as Yup from 'yup'
import { Formik } from 'formik'
import Actions from '../Pool/Actions'
import { graphql, useStaticQuery } from 'gatsby'
import { useUserPreferences } from '../../../../providers/UserPreferences'
import { toast } from 'react-toastify'
import Swap from './Swap'
import TokenBalance from '../../../../@types/TokenBalance'
import Alert from '../../../atoms/Alert'
import styles from './FormTrade.module.css'
import { FormTradeData, initialValues } from '../../../../models/FormTrade'
const contentQuery = graphql`
query TradeQuery {
content: allFile(filter: { relativePath: { eq: "price.json" } }) {
edges {
node {
childContentJson {
trade {
action
warning
}
}
}
}
}
}
`
export default function FormTrade({
ddo,
balance,
maxDt,
maxOcean,
price
}: {
ddo: DDO
balance: TokenBalance
maxDt: number
maxOcean: number
price: BestPrice
}): ReactElement {
const data = useStaticQuery(contentQuery)
const content = data.content.edges[0].node.childContentJson.trade
const { ocean, accountId } = useOcean()
const { debug } = useUserPreferences()
const [txId, setTxId] = useState<string>()
const [maximumOcean, setMaximumOcean] = useState(maxOcean)
const [maximumDt, setMaximumDt] = useState(maxDt)
const [isWarningAccepted, setIsWarningAccepted] = useState(false)
const validationSchema = Yup.object().shape<FormTradeData>({
ocean: Yup.number()
.max(maximumOcean, (param) => `Must be more or equal to ${param.max}`)
.min(0.001, (param) => `Must be more or equal to ${param.min}`)
.required('Required')
.nullable(),
datatoken: Yup.number()
.max(maxDt, `Must be less or equal than ${maximumDt}`)
.min(0.00001, (param) => `Must be more or equal to ${param.min}`)
.required('Required')
.nullable(),
type: Yup.string(),
slippage: Yup.string()
})
async function handleTrade(values: FormTradeData) {
try {
const tx =
values.type === 'buy'
? // ? await ocean.pool.buyDT(
// accountId,
// price.address,
// values.datatoken.toString(),
// (values.ocean * 1.01).toString()
// )
await ocean.pool.buyDTWithExactOcean(
accountId,
price.address,
(values.datatoken * 0.99).toString(),
values.ocean.toString()
)
: await ocean.pool.sellDT(
accountId,
price.address,
values.datatoken.toString(),
(values.ocean * 0.99).toString()
)
setTxId(tx?.transactionHash)
} catch (error) {
Logger.error(error.message)
toast.error(error.message)
}
}
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={async (values, { setSubmitting, resetForm }) => {
await handleTrade(values)
resetForm()
setSubmitting(false)
}}
>
{({ isSubmitting, submitForm, values }) => (
<>
{isWarningAccepted ? (
<Swap
ddo={ddo}
balance={balance}
maxDt={maxDt}
maxOcean={maxOcean}
price={price}
setMaximumOcean={setMaximumOcean}
setMaximumDt={setMaximumDt}
/>
) : (
<div className={styles.alertWrap}>
<Alert
text={content.warning}
state="info"
action={{
name: 'I understand',
style: 'text',
handleAction: () => setIsWarningAccepted(true)
}}
/>
</div>
)}
<Actions
isDisabled={!isWarningAccepted}
isLoading={isSubmitting}
loaderMessage="Swapping tokens..."
successMessage="Successfully swapped tokens."
actionName={content.action}
action={submitForm}
txId={txId}
/>
{debug && (
<pre>
<code>{JSON.stringify(values, null, 2)}</code>
</pre>
)}
</>
)}
</Formik>
)
}

View File

@ -0,0 +1,22 @@
.output {
border-top: 1px solid var(--border-color);
padding: var(--spacer);
display: grid;
gap: var(--spacer);
grid-template-columns: 1fr 1fr;
margin-left: -2rem;
margin-right: -2rem;
}
.output p {
font-weight: var(--font-weight-bold);
margin-bottom: calc(var(--spacer) / 8);
}
.output [class*='token'] {
white-space: normal;
}
.output [class*='token'] > figure {
display: none;
}

View File

@ -0,0 +1,77 @@
import { useOcean } from '@oceanprotocol/react'
import { FormikContextType, useFormikContext } from 'formik'
import React, { ReactElement, useEffect, useState } from 'react'
import { FormTradeData } from '../../../../models/FormTrade'
import Token from '../Pool/Token'
import styles from './Output.module.css'
export default function Output({
dtSymbol,
poolAddress
}: {
dtSymbol: string
poolAddress: string
}): ReactElement {
const { ocean } = useOcean()
const [maxOutput, setMaxOutput] = useState<string>()
const [swapFee, setSwapFee] = useState<string>()
const [swapFeeValue, setSwapFeeValue] = useState<string>()
// Connect with form
const { values }: FormikContextType<FormTradeData> = useFormikContext()
// Get swap fee
useEffect(() => {
if (!ocean || !poolAddress) return
async function getSwapFee() {
const swapFee = await ocean.pool.getSwapFee(poolAddress)
// swapFee is tricky: to get 0.1% you need to convert from 0.001
setSwapFee(`${Number(swapFee) * 100}`)
const value =
values.type === 'buy'
? Number(swapFee) * values.ocean
: Number(swapFee) * values.datatoken
setSwapFeeValue(value.toString())
}
getSwapFee()
}, [ocean, poolAddress, values])
// Get output values
useEffect(() => {
if (!ocean || !poolAddress) return
async function getOutput() {
// Minimum received
// TODO: check if this here is redundant cause we call some of that already in Swap.tsx
const maxImpact = 1 - Number(values.slippage) / 100
const maxPrice =
values.type === 'buy'
? (values.datatoken * maxImpact).toString()
: (values.ocean * maxImpact).toString()
setMaxOutput(maxPrice)
}
getOutput()
}, [ocean, poolAddress, values])
return (
<div className={styles.output}>
<div>
<p>Minimum Received</p>
<Token
symbol={values.type === 'buy' ? dtSymbol : 'OCEAN'}
balance={maxOutput}
/>
</div>
<div>
<p>Swap fee</p>
<Token
symbol={`${values.type === 'buy' ? `OCEAN` : dtSymbol} ${
swapFee ? `(${swapFee}%)` : ''
}`}
balance={swapFeeValue}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,30 @@
.slippage {
font-size: var(--font-size-small);
border-top: 1px solid var(--border-color);
margin-left: -2rem;
margin-right: -2rem;
padding: calc(var(--spacer) / 4) var(--spacer);
text-align: center;
color: var(--color-secondary);
}
.slippage strong {
font-weight: var(--font-weight-base);
color: var(--font-color-heading);
}
.title {
font-family: var(--font-family-base);
font-weight: var(--font-weight-base);
font-size: var(--font-size-mini);
text-align: center;
margin-bottom: calc(var(--spacer) / 4);
color: var(--color-secondary);
}
.slippage select {
width: fit-content;
display: inline-block;
margin-left: calc(var(--spacer) / 4);
margin-right: calc(var(--spacer) / 4);
}

View File

@ -0,0 +1,35 @@
import { FormikContextType, useFormikContext } from 'formik'
import React, { ChangeEvent, ReactElement, useEffect, useState } from 'react'
import { FormTradeData, slippagePresets } from '../../../../models/FormTrade'
import InputElement from '../../../atoms/Input/InputElement'
import styles from './Slippage.module.css'
export default function Slippage(): ReactElement {
// Connect with form
const {
setFieldValue,
values
}: FormikContextType<FormTradeData> = useFormikContext()
function handleChange(e: ChangeEvent<HTMLSelectElement>) {
setFieldValue('slippage', e.target.value)
}
return (
<>
<div className={styles.slippage}>
<strong>Expected price impact</strong>
<InputElement
name="slippage"
type="select"
size="mini"
postfix="%"
sortOptions={false}
options={slippagePresets}
value={values.slippage}
onChange={handleChange}
/>
</div>
</>
)
}

View File

@ -0,0 +1,24 @@
.swap {
margin-top: -2rem;
}
.swapButton,
.swapButton:hover {
padding: 0;
display: block;
width: calc(100% + 4rem);
text-align: center;
margin-left: -2rem;
margin-right: -2rem;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
padding: calc(var(--spacer) / 3) 0 calc(var(--spacer) / 6) 0;
}
.swapButton svg {
display: inline-block;
width: var(--font-size-large);
height: var(--font-size-large);
fill: var(--brand-pink);
transform: rotate(90deg);
}

View File

@ -0,0 +1,145 @@
import React, { ReactElement, useEffect, useState } from 'react'
import { useOcean } from '@oceanprotocol/react'
import { BestPrice, DDO } from '@oceanprotocol/lib'
import styles from './Swap.module.css'
import TradeInput from './TradeInput'
import Button from '../../../atoms/Button'
import { ReactComponent as Arrow } from '../../../../images/arrow.svg'
import { FormikContextType, useFormikContext } from 'formik'
import TokenBalance from '../../../../@types/TokenBalance'
import Output from './Output'
import Slippage from './Slippage'
import { FormTradeData, TradeItem } from '../../../../models/FormTrade'
export default function Swap({
ddo,
maxDt,
maxOcean,
balance,
price,
setMaximumDt,
setMaximumOcean
}: {
ddo: DDO
maxDt: number
maxOcean: number
balance: TokenBalance
price: BestPrice
setMaximumDt: (value: number) => void
setMaximumOcean: (value: number) => void
}): ReactElement {
const { ocean } = useOcean()
const [oceanItem, setOceanItem] = useState<TradeItem>({
amount: 0,
token: 'OCEAN',
maxAmount: 0
})
const [dtItem, setDtItem] = useState<TradeItem>({
amount: 0,
token: ddo.dataTokenInfo.symbol,
maxAmount: 0
})
const {
setFieldValue,
values,
setErrors,
validateForm
}: FormikContextType<FormTradeData> = useFormikContext()
useEffect(() => {
if (!ddo || !balance || !values || !price) return
async function calculateMaximum() {
const dtAmount = values.type === 'buy' ? maxDt : balance.datatoken
const oceanAmount = values.type === 'buy' ? balance.ocean : maxOcean
const maxBuyOcean = await ocean.pool.getOceanReceived(
price.address,
dtAmount.toString()
)
const maxBuyDt = await ocean.pool.getDTReceived(
price.address,
oceanAmount.toString()
)
const maximumDt =
values.type === 'buy'
? Number(dtAmount) > Number(maxBuyDt)
? Number(maxBuyDt)
: Number(dtAmount)
: Number(dtAmount) > balance.datatoken
? balance.datatoken
: Number(dtAmount)
const maximumOcean =
values.type === 'sell'
? Number(oceanAmount) > Number(maxBuyOcean)
? Number(maxBuyOcean)
: Number(oceanAmount)
: Number(oceanAmount) > balance.ocean
? balance.ocean
: Number(oceanAmount)
setMaximumDt(maximumDt)
setMaximumOcean(maximumOcean)
setOceanItem({
...oceanItem,
amount: oceanAmount,
maxAmount: maximumOcean
})
setDtItem({
...dtItem,
amount: dtAmount,
maxAmount: maximumDt
})
}
calculateMaximum()
}, [ddo, maxOcean, maxDt, balance, price?.value, values.type])
const switchTokens = () => {
setFieldValue('type', values.type === 'buy' ? 'sell' : 'buy')
// don't reset form because we don't want to reset type
setFieldValue('datatoken', 0)
setFieldValue('ocean', 0)
setErrors({})
}
const handleValueChange = async (name: string, value: number) => {
const newValue =
name === 'ocean'
? values.type === 'sell'
? await ocean.pool.getDTNeeded(price.address, value.toString())
: await ocean.pool.getDTReceived(price.address, value.toString())
: values.type === 'sell'
? await ocean.pool.getOceanReceived(price.address, value.toString())
: await ocean.pool.getOceanNeeded(price.address, value.toString())
setFieldValue(name === 'ocean' ? 'datatoken' : 'ocean', newValue)
validateForm()
}
return (
<div className={styles.swap}>
<TradeInput
name={values.type === 'sell' ? 'datatoken' : 'ocean'}
item={values.type === 'sell' ? dtItem : oceanItem}
handleValueChange={handleValueChange}
/>
<Button className={styles.swapButton} style="text" onClick={switchTokens}>
<Arrow />
</Button>
<TradeInput
name={values.type === 'sell' ? 'ocean' : 'datatoken'}
item={values.type === 'sell' ? oceanItem : dtItem}
handleValueChange={handleValueChange}
/>
<Output dtSymbol={dtItem.token} poolAddress={price?.address} />
<Slippage />
</div>
)
}

View File

@ -0,0 +1,35 @@
.tradeInput {
position: relative;
padding: var(--spacer) calc(var(--spacer) * 2);
margin-left: -2rem;
margin-right: -2rem;
background: var(--background-highlight);
}
.tradeInput input {
text-align: center;
}
.tradeInput div[class*='field'] {
margin-bottom: 0;
}
.tradeInput div[class*='prefix'] {
min-width: 6.5rem;
width: fit-content;
justify-content: center;
}
.label {
font-family: var(--font-family-heading);
font-size: var(--font-size-base);
text-align: center;
display: block;
}
.buttonMax {
position: absolute;
font-size: var(--font-size-mini);
bottom: calc(var(--spacer) / 2);
right: calc(var(--spacer) * 2);
}

View File

@ -0,0 +1,86 @@
import React, { ChangeEvent, ReactElement } from 'react'
import styles from './TradeInput.module.css'
import {
Field,
FieldInputProps,
FormikContextType,
useFormikContext
} from 'formik'
import { useOcean } from '@oceanprotocol/react'
import Input from '../../../atoms/Input'
import Button from '../../../atoms/Button'
import UserLiquidity from '../../../atoms/UserLiquidity'
import { FormTradeData, TradeItem } from '../../../../models/FormTrade'
export default function TradeInput({
name,
item,
handleValueChange
}: {
name: string
item: TradeItem
handleValueChange: (name: string, value: number) => void
}): ReactElement {
const { ocean } = useOcean()
// Connect with form
const {
handleChange,
setFieldValue,
validateForm,
values
}: FormikContextType<FormTradeData> = useFormikContext()
const isTopField =
(name === 'ocean' && values.type === 'buy') ||
(name === 'datatoken' && values.type === 'sell')
const titleAvailable = isTopField ? `Balance` : `Available from pool`
const titleMaximum = isTopField ? `Maximum to spend` : `Maximum to receive`
return (
<section className={styles.tradeInput}>
<UserLiquidity
amount={`${item?.amount}`}
amountMax={`${item?.maxAmount}`}
symbol={item?.token}
titleAvailable={titleAvailable}
titleMaximum={titleMaximum}
/>
<Field name={name}>
{({ field, form }: { field: FieldInputProps<number>; form: any }) => (
<Input
type="number"
max={`${item?.maxAmount}`}
prefix={item?.token}
placeholder="0"
field={field}
form={form}
value={`${field.value}`}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
handleValueChange(name, Number(e.target.value))
validateForm()
handleChange(e)
}}
disabled={!ocean}
/>
)}
</Field>
{!isTopField && (
<Button
className={styles.buttonMax}
style="text"
size="small"
onClick={() => {
setFieldValue(name, item?.maxAmount)
handleValueChange(name, item?.maxAmount)
}}
>
Use Max
</Button>
)}
</section>
)
}

View File

@ -0,0 +1,69 @@
import React, { ReactElement, useEffect, useState } from 'react'
import { useOcean, useMetadata } from '@oceanprotocol/react'
import { DDO } from '@oceanprotocol/lib'
import FormTrade from './FormTrade'
import TokenBalance from '../../../../@types/TokenBalance'
const refreshInterval = 6000 // 6 sec, if the interval is bellow 3-5 seconds the price will be 0 all the time
export default function Trade({ ddo }: { ddo: DDO }): ReactElement {
const { ocean, balance, accountId, networkId, refreshBalance } = useOcean()
const [tokenBalance, setTokenBalance] = useState<TokenBalance>()
const { price, refreshPrice } = useMetadata(ddo)
const [maxDt, setMaxDt] = useState(0)
const [maxOcean, setMaxOcean] = useState(0)
// Get datatoken balance, and combine with OCEAN balance from hooks into one object
useEffect(() => {
if (!ocean || !balance?.ocean || !accountId || !ddo?.dataToken) return
async function getTokenBalance() {
const dtBalance = await ocean.datatokens.balance(ddo.dataToken, accountId)
setTokenBalance({
ocean: Number(balance.ocean),
datatoken: Number(dtBalance)
})
}
getTokenBalance()
}, [balance.ocean, ocean, accountId, ddo.dataToken])
// Re-fetch price & balance periodically, triggering re-calculation of everything
useEffect(() => {
if (!ocean || !networkId || !accountId) return
const interval = setInterval(async () => {
refreshPrice()
refreshBalance()
}, refreshInterval)
return () => clearInterval(interval)
}, [ocean, ddo, networkId, accountId, refreshPrice, refreshBalance])
// Get maximum amount for either OCEAN or datatoken
useEffect(() => {
if (!ocean || !price || price.value === 0) return
async function getMaximum() {
const maxTokensInPool = await ocean.pool.getDTMaxBuyQuantity(
price.address
)
setMaxDt(Number(maxTokensInPool))
const maxOceanInPool = await ocean.pool.getOceanMaxBuyQuantity(
price.address
)
setMaxOcean(Number(maxOceanInPool))
}
getMaximum()
}, [ocean, balance.ocean, price])
return (
<FormTrade
ddo={ddo}
price={price}
balance={tokenBalance}
maxDt={maxDt}
maxOcean={maxOcean}
/>
)
}

View File

@ -5,8 +5,8 @@
padding: 0;
}
.hasTokens {
.help {
font-size: var(--font-size-mini);
color: var(--color-secondary);
margin-top: calc(var(--spacer) / 12);
margin-top: calc(var(--spacer) / 3);
}

View File

@ -7,6 +7,7 @@ import Tabs from '../../atoms/Tabs'
import { useOcean, useMetadata } from '@oceanprotocol/react'
import compareAsBN from '../../../utils/compareAsBN'
import Pool from './Pool'
import Trade from './Trade'
export default function AssetActions({ ddo }: { ddo: DDO }): ReactElement {
const { ocean, balance, accountId } = useOcean()
@ -74,10 +75,16 @@ export default function AssetActions({ ddo }: { ddo: DDO }): ReactElement {
const hasPool = ddo.price?.type === 'pool'
hasPool &&
tabs.push({
title: 'Pool',
content: <Pool ddo={ddo} />
})
tabs.push(
{
title: 'Pool',
content: <Pool ddo={ddo} />
},
{
title: 'Trade',
content: <Trade ddo={ddo} />
}
)
return <Tabs items={tabs} className={styles.actions} />
}

View File

@ -71,7 +71,6 @@ export async function getResults(
page,
offset
)
console.log(searchQuery)
const queryResult = await metadataCache.queryMetadata(searchQuery)
return queryResult

View File

@ -122,8 +122,8 @@ ul li {
}
::selection {
background: var(--brand-black);
color: var(--brand-white);
background: var(--font-color-heading);
color: var(--background-body);
}
form,

3
src/images/arrow.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="20" height="19" viewBox="0 0 20 19" xmlns="http://www.w3.org/2000/svg">
<path d="M0 10.4304L16.3396 10.4304L8.88727 17.6833L10.2401 19L20 9.5L10.2401 0L8.88727 1.31491L16.3396 8.56959L0 8.56959V10.4304Z" />
</svg>

After

Width:  |  Height:  |  Size: 227 B

24
src/models/FormTrade.ts Normal file
View File

@ -0,0 +1,24 @@
import TokenBalance from '../@types/TokenBalance'
export interface FormTradeData extends TokenBalance {
// in reference to datatoken, buy = swap from ocean to dt ( buy dt) , sell = swap from dt to ocean (sell dt)
type: 'buy' | 'sell'
slippage: string
}
export interface TradeItem {
amount: number
token: string
maxAmount: number
}
export const initialValues: FormTradeData = {
ocean: undefined,
datatoken: undefined,
type: 'buy',
slippage: '5'
}
export const slippagePresets = ['5', '10', '15', '25', '50']
// validationSchema lives in components/organisms/AssetActions/Trade/FormTrade.tsx

View File

@ -43,7 +43,7 @@ export default function PricesProvider({
// Fetch new prices periodically with swr
useSWR(url, fetchData, {
refreshInterval: 30000, // 30 sec.
refreshInterval: 60000, // 60 sec.
onSuccess
})