diff --git a/web-portal/backend/src/utils/utils.controller.ts b/web-portal/backend/src/utils/utils.controller.ts index 543388c7..75ae4c17 100644 --- a/web-portal/backend/src/utils/utils.controller.ts +++ b/web-portal/backend/src/utils/utils.controller.ts @@ -1,11 +1,11 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get, UseGuards, Param, Body } from '@nestjs/common'; import { AuthGuard } from '../guards/auth.guard'; import { UtilsService } from './utils.service'; @Controller('utils') @UseGuards(AuthGuard) export class UtilsController { - constructor(private readonly utilsService: UtilsService) {} + constructor(private readonly utilsService: UtilsService) { } @Get('endpoints') async getChains() { @@ -19,4 +19,51 @@ export class UtilsController { const ruleTypes = await this.utilsService.getRuleTypes(); return ruleTypes; } + + @Get('price/:chainId/:tokenAddress') + async getTokenPrice( + @Param('chainId') chainId: string, + @Param('tokenAddress') tokenAddress: string, + ) { + // @note: this action returns price in usd for provided token address and chain id + const price = await this.utilsService.getTokenPrice(chainId, tokenAddress); + return price; + } + + @Get('tokens/:chainId') + async getTokenList(@Param('chainId') chainId: string) { + // @note: this action returns popular tokens by chainId + const tokens = await this.utilsService.getTokenList(chainId); + return tokens; + } + + @Get('quote/:chainName/:sellToken/:sellAmount') + async get0xQuote( + @Param('chainName') chainName: string, + @Param('sellToken') sellToken: string, + @Param('sellAmount') sellAmount: number, + ) { + // @note: this action returns quote from 0x protocol for provided sellToken, sellAmount and chainName + const quote = await this.utilsService.get0xQuote( + chainName, + sellToken, + sellAmount, + ); + return quote; + } + + @Get('price/:chainName/:sellToken/:sellAmount') + async get0xPrice( + @Param('chainName') chainName: string, + @Param('sellToken') sellToken: string, + @Param('sellAmount') sellAmount: number, + ) { + // @note: this action returns price from 0x protocol for provided sellToken, sellAmount and chainName + const price = await this.utilsService.get0xPrice( + chainName, + sellToken, + sellAmount, + ); + return price; + } } diff --git a/web-portal/backend/src/utils/utils.service.ts b/web-portal/backend/src/utils/utils.service.ts index 95598f93..18a74dec 100644 --- a/web-portal/backend/src/utils/utils.service.ts +++ b/web-portal/backend/src/utils/utils.service.ts @@ -2,6 +2,9 @@ import { Injectable, Inject, HttpException, HttpStatus } from '@nestjs/common'; import { CustomPrismaService } from 'nestjs-prisma'; import { PrismaClient } from '@/.generated/client'; + +export const portrAddress = "0x54d5f8a0e0f06991e63e46420bcee1af7d9fe944"; + @Injectable() export class UtilsService { constructor( @@ -32,7 +35,6 @@ export class UtilsService { const ruleTypes = await this.prisma.client.ruleType.findMany({ where: { deletedAt: null, - }, select: { id: true, @@ -52,4 +54,76 @@ export class UtilsService { return ruleTypes; } + async getTokenList(chainId: string) { + const res = await fetch(`https://tokens.1inch.io/v1.2/${chainId}`); + if (!res.ok) { + throw new HttpException( + `Could not fetch token list`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + const data = await res.json(); + return data; + } + + async getTokenPrice(chainId: string, tokenAddress: string) { + const res = await fetch( + `https://api.1inch.dev/price/v1.1/${chainId}/${tokenAddress}?currency=usd`, + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${process.env.ONEINCH_API_KEY!}`, + }, + }, + ); + if (!res.ok) { + throw new HttpException( + `Could not fetch token prices`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + const data = await res.json(); + return data; + } + + async get0xQuote(chainName: string, sellToken: string, sellAmount: number) { + const res = await fetch( + `https://${chainName}.api.0x.org/swap/v1/quote?sellToken=${sellToken}&buyToken=${portrAddress}&sellAmount=${sellAmount}`, + { + headers: { + Accept: "application/json", + "0x-api-key": process.env.OX_API_KEY!, + }, + }, + ); + if (!res.ok) { + throw new HttpException( + `Could not fetch quote`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + const data = await res.json(); + return data; + }; + + async get0xPrice(chainName: string, sellToken: string, sellAmount: number) { + const res = await fetch( + `https://${chainName}.api.0x.org/swap/v1/price?sellToken=${sellToken}&buyToken=${portrAddress}&sellAmount=${sellAmount}`, + { + headers: { + Accept: "application/json", + "0x-api-key": process.env.OX_API_KEY!, + }, + }, + ); + if (!res.ok) { + throw new HttpException( + `Could not fetch quote`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + const data = await res.json(); + return data; + }; + } diff --git a/web-portal/frontend/components/billing/invoiceList.tsx b/web-portal/frontend/components/billing/invoiceList.tsx index 9d8772b2..39e4bb54 100644 --- a/web-portal/frontend/components/billing/invoiceList.tsx +++ b/web-portal/frontend/components/billing/invoiceList.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Stack, Table, Flex, Title, Card, Button } from "@mantine/core"; import { IBill } from "@frontend/utils/types"; -import { usePathname, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import Link from "next/link"; import { billingHistoryAtom, sessionAtom } from "@frontend/utils/atoms"; diff --git a/web-portal/frontend/components/swap/Redeem.tsx b/web-portal/frontend/components/swap/Redeem.tsx index 58fe9672..1ba61b51 100644 --- a/web-portal/frontend/components/swap/Redeem.tsx +++ b/web-portal/frontend/components/swap/Redeem.tsx @@ -1,7 +1,171 @@ +import { useState } from "react"; +import { Flex, Stack, Button, TextInput, Text, Select } from "@mantine/core"; +import _ from "lodash"; +import Image from "next/image"; +import { karla } from "@frontend/utils/theme"; + +import { portrTokenData } from "@frontend/utils/consts"; +import { chains } from "@frontend/utils/Web3Provider"; +import { useTokenBalance } from "@frontend/utils/hooks"; + +// Common styles for TextInput and Select components +const commonStyles = { + input: { + outline: "none", + border: "none", + background: "none", + fontSize: 24, + }, + label: { + color: "#000", + marginLeft: 10, + }, +}; + +const chainOptions = _.map(chains, "name").filter( + (c) => !c.includes("Ethereum"), +); + export default function Redeem() { + const [selectedChainId, setSelectedChainId] = useState(10); + + const { data: selectedTokenBalance } = useTokenBalance({ + token: portrTokenData.address, + chainId: portrTokenData?.chainId, + }); + + const [redeemValue, setRedeemValue] = useState(0); + return ( -
-

Redeem

-
+ + { const selectedChain = _.find( chains, @@ -109,17 +228,14 @@ export default function Swap({ defaultToken }: { defaultToken: IToken }) { placeholder="Enter amount" label="Swap" type="number" - value={swapValue} - onChange={(e) => setSwapValue(parseFloat(e.target.value))} + value={sellAmount} + onChange={(e) => handleSellAmountChange(e)} styles={{ ...commonStyles, input: { ...commonStyles.input, fill: "#fff" }, + error: { marginLeft: 10 }, }} - error={ - swapValue > Number(_.get(tokenBalance, "formatted")) - ? "Not enough balance" - : undefined - } + error={showError ? "Not enough balance" : undefined} /> + + {needToSwitchChain && ( + + You will need to sign-in again,
if you need to switch networks. +
+ )}
); } diff --git a/web-portal/frontend/next.config.js b/web-portal/frontend/next.config.js index 1cfc9ccc..4ea38e5e 100644 --- a/web-portal/frontend/next.config.js +++ b/web-portal/frontend/next.config.js @@ -1,31 +1,31 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - output: "standalone", - webpack: (config) => { - config.externals.push("pino-pretty", "lokijs", "encoding"); - return config; - }, - images: { - remotePatterns: [ - { - protocol: "https", - hostname: "api.web3modal.com", - port: "", - }, - { - protocol: "https", - hostname: "ethereum-optimism.github.io", - }, - ], - }, - async rewrites() { - return [ - { - source: "/api/:path*", - destination: `${process.env.API_ENDPOINT}:path*`, - }, - ]; - }, + output: "standalone", + webpack: (config) => { + config.externals.push("pino-pretty", "lokijs", "encoding"); + return config; + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "api.web3modal.com", + port: "", + }, + { + protocol: "https", + hostname: "*.1inch.io", + }, + ], + }, + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${process.env.API_ENDPOINT}:path*`, + }, + ]; + }, }; module.exports = nextConfig; diff --git a/web-portal/frontend/pages/api/price.tsx b/web-portal/frontend/pages/api/price.tsx new file mode 100644 index 00000000..e69de29b diff --git a/web-portal/frontend/pages/swap/index.tsx b/web-portal/frontend/pages/swap/index.tsx index 36c11147..4e259721 100644 --- a/web-portal/frontend/pages/swap/index.tsx +++ b/web-portal/frontend/pages/swap/index.tsx @@ -1,29 +1,15 @@ import DashboardLayout from "@frontend/components/dashboard/layout"; import { Stack, Tabs, rem } from "@mantine/core"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { crimson } from "@frontend/utils/theme"; import Swap from "@frontend/components/swap/Swap"; import Redeem from "@frontend/components/swap/Redeem"; import classes from "@frontend/styles/tabs.module.css"; -import { IToken } from "@frontend/utils/types"; -import { useSetAtom } from "jotai"; -import { tokenDataAtom } from "@frontend/utils/atoms"; -import { useChainId } from "wagmi"; + import _ from "lodash"; -export default function SwapOrRedeem({ - data, - defaultToken, -}: { - data: IToken[]; - defaultToken: IToken; -}) { +export default function SwapOrRedeem() { const [value, setValue] = useState("swap"); - const setTokenData = useSetAtom(tokenDataAtom); - - useEffect(() => { - setTokenData(data); - }); return ( @@ -72,8 +58,8 @@ export default function SwapOrRedeem({ - - + + @@ -83,17 +69,3 @@ export default function SwapOrRedeem({ ); } - -export async function getServerSideProps() { - const res = await fetch("https://static.optimism.io/optimism.tokenlist.json"); - const data = await res.json(); - - const { tokens } = data; - const defaultToken = _.filter(tokens, { name: "Ether" })[0]; - return { - props: { - data: tokens satisfies IToken[], - defaultToken: defaultToken satisfies IToken, - }, - }; -} diff --git a/web-portal/frontend/utils/Web3Provider.tsx b/web-portal/frontend/utils/Web3Provider.tsx index 31d82f1a..6b7811bd 100644 --- a/web-portal/frontend/utils/Web3Provider.tsx +++ b/web-portal/frontend/utils/Web3Provider.tsx @@ -1,15 +1,8 @@ import { defaultWagmiConfig } from "@web3modal/wagmi/react/config"; import { cookieStorage, createStorage } from "wagmi"; -import { - arbitrum, - base, - mainnet, - optimism, - polygon, - sepolia, -} from "wagmi/chains"; +import { base, mainnet, optimism } from "wagmi/chains"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Provider as JotaiProvider, useSetAtom } from "jotai"; +import { Provider as JotaiProvider } from "jotai"; import { State, WagmiProvider } from "wagmi"; import { ReactNode } from "react"; @@ -29,14 +22,7 @@ const metadata = { }; // Create wagmiConfig -export const chains = [ - mainnet, - sepolia, - optimism, - base, - arbitrum, - polygon, -] as const; +export const chains = [mainnet, optimism, base] as const; export const config = defaultWagmiConfig({ chains, diff --git a/web-portal/frontend/utils/atoms.ts b/web-portal/frontend/utils/atoms.ts index 9a703574..d7e18d0f 100644 --- a/web-portal/frontend/utils/atoms.ts +++ b/web-portal/frontend/utils/atoms.ts @@ -1,9 +1,8 @@ import { atom } from "jotai"; -import { IEndpoint, ISession, IRuleType, IToken } from "./types"; +import { IEndpoint, ISession, IRuleType } from "./types"; export const sessionAtom = atom({}); export const appsAtom = atom([]); export const endpointsAtom = atom([]); export const ruleTypesAtom = atom([]); export const existingRuleValuesAtom = atom([]); export const billingHistoryAtom = atom([]); -export const tokenDataAtom = atom([]); diff --git a/web-portal/frontend/utils/consts.ts b/web-portal/frontend/utils/consts.ts index 58118ac3..c7af23e9 100644 --- a/web-portal/frontend/utils/consts.ts +++ b/web-portal/frontend/utils/consts.ts @@ -1,6 +1,35 @@ +import { Address } from "viem"; +import { IToken } from "./types"; + +export const portrAddress = "0x54d5f8a0e0f06991e63e46420bcee1af7d9fe944"; + export const apiUrl = process.env.NEXT_PUBLIC_API_ENDPOINT!; export const APP_NAME = "Porters Frontend"; export const metadata = { title: "Gateway Demo Portal", description: "Welcome", }; + +export const portrTokenData: IToken = { + chainId: 10, + address: portrAddress, + name: "PORTER Gateway", + symbol: "PORTR", + decimals: 18, + logoURI: "/favicon.ico", +}; + +export const supportedChains = [ + { + id: "10", + name: "optimism", + exchangeProxy: `0xdef1abe32c034e558cdd535791643c58a13acc10` as Address, + portrAddress: "0x54d5f8a0e0f06991e63e46420bcee1af7d9fe944" as Address, + }, + { + id: "8543", + name: "base", + exchangeProxy: `0xdef1c0ded9bec7f1a1670819833240f027b25eff` as Address, + portrAddress: "to-be-deployed", + }, +]; diff --git a/web-portal/frontend/utils/hooks.ts b/web-portal/frontend/utils/hooks.ts index 47da3ef4..a64399fb 100644 --- a/web-portal/frontend/utils/hooks.ts +++ b/web-portal/frontend/utils/hooks.ts @@ -1,7 +1,11 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getSession } from "./siwe"; -import { useAccount } from "wagmi"; +import { useAccount, useBalance, useReadContract } from "wagmi"; import { usePathname, useRouter } from "next/navigation"; +import { Address, erc20Abi } from "viem"; +import { supportedChains } from "./consts"; +import _ from "lodash"; +import { IToken } from "./types"; export const useSession = () => { const { address, isConnected } = useAccount(); @@ -192,20 +196,21 @@ export const useSecretKeyMutation = (appId: string) => { export const useQuote = ({ sellToken, - amount, + chainId, + sellAmount, }: { sellToken: string; - amount: string; + chainId: number | string; + sellAmount: number; }) => { const fetchQuote = async () => { + const chainName = _.get( + _.find(supportedChains, { id: String(chainId) }), + "name", + ); + const response = await fetch( - `https://api.0x.org/swap/v1/price?sellToken=${sellToken}&buyToken=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2&sellAmount=${amount}`, - { - headers: { - "Content-Type": "application/json", - "0x-api-key": "api-key", - }, - }, + `/api/utils/quote/${chainName}/${sellToken}/${sellAmount}`, ); if (!response.ok) { throw new Error("Failed to fetch quote"); @@ -213,7 +218,112 @@ export const useQuote = ({ return response.json(); }; return useQuery({ - queryKey: ["quote", sellToken], + queryKey: ["0xQuote", sellToken], queryFn: fetchQuote, + enabled: sellAmount > 0 && Boolean(sellToken) && Boolean(chainId), + refetchInterval: 10000, + }); +}; + +export const usePrice = ({ + sellToken, + chainId, + sellAmount, +}: { + sellToken: string; + chainId: number | string; + sellAmount: number; +}) => { + const fetchQuote = async () => { + const chainName = _.get( + _.find(supportedChains, { id: String(chainId) }), + "name", + ); + + const response = await fetch( + `/api/utils/price/${chainName}/${sellToken}/${sellAmount}`, + ); + if (!response.ok) { + throw new Error("Failed to fetch quote"); + } + return response.json(); + }; + return useQuery({ + queryKey: ["0xPrice", sellToken], + queryFn: fetchQuote, + enabled: sellAmount > 0 && Boolean(sellToken) && Boolean(chainId), + refetchInterval: 10000, + }); +}; + +export const useTokenBalance = ({ + token, + chainId, +}: { + token?: Address; + chainId: number; +}) => { + const { address } = useAccount(); + return useBalance({ + chainId, + token, + address, + }); +}; + +export const useTokenPrice = ({ + token, + chainId, +}: { + token: Address; + chainId: number; +}) => { + const fetchTokenPrice = async () => { + const response = await fetch(`/api/utils/price/${chainId}/${token}`); + if (!response.ok) { + throw new Error("Failed to fetch token price"); + } + return response.json(); + }; + + return useQuery({ + queryKey: ["price", token], + queryFn: fetchTokenPrice, + }); +}; + +export const useTokenList = ({ chainId }: { chainId: number | string }) => { + const fetchTokenList = async () => { + const response = await fetch(`/api/utils/tokens/${chainId}`); + if (!response.ok) { + throw new Error("Failed to fetch token list"); + } + const res = await response.json(); + return _.toArray(res) as IToken[]; + }; + + return useQuery({ + queryKey: ["tokens", chainId], + queryFn: fetchTokenList, + }); +}; + +export const useCheckAllowance = ({ + sellTokenAddress, + selectedChainId, + exchangeProxy, +}: { + selectedChainId: number; + sellTokenAddress: Address; + exchangeProxy: Address; +}) => { + const { address } = useAccount(); + + return useReadContract({ + chainId: selectedChainId, + address: sellTokenAddress, + abi: erc20Abi, + functionName: "allowance", + args: [address!, exchangeProxy!], }); }; diff --git a/web-portal/frontend/utils/theme.ts b/web-portal/frontend/utils/theme.ts index 2522b971..19b32b6a 100644 --- a/web-portal/frontend/utils/theme.ts +++ b/web-portal/frontend/utils/theme.ts @@ -59,6 +59,7 @@ export const theme = createTheme({ fontFamily: crimson.style.fontFamily, fontWeight: 700, fontSize: rem(20), + color: "white", }, }, }), diff --git a/web-portal/frontend/utils/types.ts b/web-portal/frontend/utils/types.ts index 953c84da..80144292 100644 --- a/web-portal/frontend/utils/types.ts +++ b/web-portal/frontend/utils/types.ts @@ -1,88 +1,116 @@ import { Address } from "viem"; export interface IApp { - id: string; - name: string; - description: string; - appId: string; - active: boolean; - createdAt: string; - updatedAt: string; - deletedAt?: string; + id: string; + name: string; + description: string; + appId: string; + active: boolean; + createdAt: string; + updatedAt: string; + deletedAt?: string; } export interface IOrg { - id: string; - active: boolean; - deletedAt?: string; - createdAt: string; - updatedAt: string; - enterpriseId: string; + id: string; + active: boolean; + deletedAt?: string; + createdAt: string; + updatedAt: string; + enterpriseId: string; } export interface ISession { - chainId?: number; - address?: Address; - id?: string; - active?: boolean; - createdAt?: string; - deletedAt?: string; - orgs?: IOrg[] | null; - tenantId?: string; + chainId?: number; + address?: Address; + id?: string; + active?: boolean; + createdAt?: string; + deletedAt?: string; + orgs?: IOrg[] | null; + tenantId?: string; } export interface IEndpoint { - id: string; - name: string; - weight?: number; - params?: string; - active?: boolean; - deletedAt?: string; - createdAt?: string; - updatedAt?: string; + id: string; + name: string; + weight?: number; + params?: string; + active?: boolean; + deletedAt?: string; + createdAt?: string; + updatedAt?: string; } export interface IRuleType { - id?: string; - name?: string; - description?: string; - isEditable?: boolean; - isMultiple?: boolean; - validationType?: string; - validationValue?: string; + id?: string; + name?: string; + description?: string; + isEditable?: boolean; + isMultiple?: boolean; + validationType?: string; + validationValue?: string; } export interface IAppRule { - id?: string; - appId?: string; - ruleId?: string; - value?: string; - active?: boolean; - deletedAt?: string; - createdAt?: string; - updatedAt?: string; + id?: string; + appId?: string; + ruleId?: string; + value?: string; + active?: boolean; + deletedAt?: string; + createdAt?: string; + updatedAt?: string; } export interface IRuleUpdate { - ruleId: string; - data: string[]; + ruleId: string; + data: string[]; } export interface IBill { - id?: string; - amount?: number; - referenceId?: string; - tenantId?: string; - createdAt?: string; - transactionType?: string; + id?: string; + amount?: number; + referenceId?: string; + tenantId?: string; + createdAt?: string; + transactionType?: string; } export interface IToken { - chainId: number; - address: Address; - name: string; - symbol: string; - decimals: number; - logoURI: string; - extensions?: any; + chainId: number; + address: Address; + name: string; + symbol: string; + decimals: number; + logoURI: string; + extensions?: any; +} + +export interface IQuote { + chainId: number; + price: string; + guaranteedPrice: string; + estimatedPriceImpact: string; + to: Address; + from: string; + data: Address; + value: bigint; + gas: bigint; + estimatedGas: string; + gasPrice: bigint; + grossBuyAmount: string; + protocolFee: string; + minimumProtocolFee: string; + buyTokenAddress: string; + sellTokenAddress: string; + buyAmount: string; + sellAmount: string; + sources: any[]; + orders: any[]; + allowanceTarget: string; + decodedUniqueId: string; + sellTokenToEthRate: string; + buyTokenToEthRate: string; + expectedSlippage: string | null; }