diff --git a/package.json b/package.json index 7d72020..c833132 100644 --- a/package.json +++ b/package.json @@ -90,5 +90,5 @@ "text-encoding": "^0.7.0", "typescript": "5.1.6" }, - "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1" + "packageManager": "pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b" } diff --git a/src/components/WalletSelectDropdown.tsx b/src/components/WalletSelectDropdown.tsx index bf75c20..119844c 100644 --- a/src/components/WalletSelectDropdown.tsx +++ b/src/components/WalletSelectDropdown.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Select, MenuItem, @@ -19,7 +19,11 @@ import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'; import { walletDataByName } from '@lib/walletsList'; import { useCardano } from '@lib/utils/cardano'; -const WalletSelectDropdown = () => { +interface IWalletSelectDropdown { + onWalletDropDownUpdate?: (addresses: string[]) => void; +} + +const WalletSelectDropdown: FC = ({ onWalletDropDownUpdate }) => { const theme = useTheme() const router = useRouter() const { isWalletConnected: _isWalletConnected, setSelectedAddresses: _setSelectedAddresses, getSelectedAddresses: _getSelectedAddresses } = useCardano() @@ -40,9 +44,17 @@ const WalletSelectDropdown = () => { const localStorageSelectedAddresses = getSelectedAddresses() if (localStorageSelectedAddresses.length > 0) { setSelectedAddress(localStorageSelectedAddresses) + + if (onWalletDropDownUpdate !== undefined) { + onWalletDropDownUpdate(localStorageSelectedAddresses) + } } else { setSelectedAddress(wallets.map((wallet) => wallet.changeAddress)) setSelectedAddresses(wallets.map((wallet) => wallet.changeAddress)) + + if (onWalletDropDownUpdate !== undefined) { + onWalletDropDownUpdate(wallets.map((wallet) => wallet.changeAddress)) + } } setWalletAddresses(wallets.map((wallet) => wallet.changeAddress)) } @@ -65,6 +77,12 @@ const WalletSelectDropdown = () => { router.push('/user/connected-wallets') }; + const handleOnWalletDropDownClose = useCallback(() => { + if (onWalletDropDownUpdate !== undefined) { + onWalletDropDownUpdate(selectedAddresses); + } + }, [selectedAddresses, onWalletDropDownUpdate]) + useEffect(() => { const execute = async () => { if (wallets) { @@ -87,6 +105,7 @@ const WalletSelectDropdown = () => { variant="filled" value={selectedAddresses} onChange={handleChange} + onClose={handleOnWalletDropDownClose} renderValue={() => selectedAddresses.length === 0 ? 'No wallet selected' : 'Select displayed wallets'} MenuProps={{ PaperProps: { @@ -132,7 +151,7 @@ const WalletSelectDropdown = () => { -1} disabled={!isConnectedByWallets[item]} /> - + {!isConnectedByWallets[item] && <> diff --git a/src/components/dashboard/DashboardMenu.tsx b/src/components/dashboard/DashboardMenu.tsx index 3917e3f..a660edf 100644 --- a/src/components/dashboard/DashboardMenu.tsx +++ b/src/components/dashboard/DashboardMenu.tsx @@ -14,7 +14,6 @@ import { Zoom, Typography } from '@mui/material'; -import HomeIcon from '@mui/icons-material/Home'; import MenuOpenIcon from '@mui/icons-material/MenuOpen'; import { useRouter } from 'next/router'; @@ -46,6 +45,19 @@ const links = { ] } +const linksVesting = { + Other: [ + { + name: 'Overview', + link: '/vesting' + }, + { + name: 'Vesting Positions', + link: '/vesting/manage-vesting' + } + ] +} + const DashboardMenu: FC = ({ children }) => { const theme = useTheme() const desktop = useMediaQuery(theme.breakpoints.up('md')) @@ -78,7 +90,7 @@ const DashboardMenu: FC = ({ children }) => { const drawer = (
- {Object.entries(links).map(([category, linkItems], categoryIndex) => ( + {Object.entries((router.asPath.startsWith('/staking') ? links : linksVesting)).map(([category, linkItems], categoryIndex) => (
{category !== "Other" && {category} diff --git a/src/components/dashboard/pages/UnlockVested.tsx b/src/components/dashboard/pages/UnlockVested.tsx index 8fc7e06..38340de 100644 --- a/src/components/dashboard/pages/UnlockVested.tsx +++ b/src/components/dashboard/pages/UnlockVested.tsx @@ -1,8 +1,6 @@ import React, { FC, useEffect, useRef, useState } from 'react'; import { Box, - Button, - Divider, Typography, } from '@mui/material'; import Grid from '@mui/system/Unstable_Grid/Grid'; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 20cb4ea..90f966c 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -43,6 +43,10 @@ const Header: FC = () => { { name: "Staking", link: "/staking" + }, + { + name: "Vesting", + link: "/vesting" } ]; @@ -54,6 +58,10 @@ const Header: FC = () => { localStorage.setItem('darkToggle', temp); }; + function getBaseUrl(pathname: string) { + return `/${pathname.split('/')[1]}`; + } + const NavigationListItem: React.FC = ({ size, fontWeight, page }) => { return ( @@ -69,7 +77,7 @@ const Header: FC = () => { mt: '0', borderRadius: '10px', height: (fontWeight && fontWeight > 500) || (size && size > 20) ? '3px' : '2px', - background: router.pathname === page.link ? theme.palette.primary.main : '', + background: getBaseUrl(router.pathname) === page.link ? theme.palette.primary.main : '', width: '100%', }, }} diff --git a/src/components/vesting/VestingConfirm.tsx b/src/components/vesting/VestingConfirm.tsx new file mode 100644 index 0000000..e156319 --- /dev/null +++ b/src/components/vesting/VestingConfirm.tsx @@ -0,0 +1,242 @@ +import DataSpread from "@components/DataSpread"; +import { useAlert } from "@contexts/AlertContext"; +import { trpc } from "@lib/utils/trpc"; +import { BrowserWallet } from "@meshsdk/core"; +import { useWallet, useWalletList } from "@meshsdk/react"; +import CloseIcon from "@mui/icons-material/Close"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + useMediaQuery, + useTheme, +} from "@mui/material"; +import React, { FC, useState, useCallback, useEffect } from "react"; + +type ClaimEntry = { + id: string; + rootHash: string; + ownerPkh: string; + token: string; + total: number | string; + claimable: number | string; + frequency: string; + nextUnlockDate: string; + endDate: string; + remainingPeriods: string; + ownerAddress: string; + walletType: string; +}; + +interface IVestingConfirmProps { + open: boolean; + setOpen: React.Dispatch>; + claimEntry: ClaimEntry | null; +} + +const VestingConfirm: FC = ({ + open, + setOpen, + claimEntry, +}) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const [isSigning, setIsSigning] = useState(false); + const { addAlert } = useAlert(); + const { connected } = useWallet(); + + const finalizeTransaction = trpc.vesting.finalizeTransaction.useMutation(); + const createClaimTreasuryDataMutation = + trpc.vesting.createClaimTreasuryData.useMutation(); + const claimTreasuryMutation = trpc.vesting.claimTreasury.useMutation(); + const submitClaimTreasuryTransaction = + trpc.vesting.submitClaimTreasuryTransaction.useMutation(); + + const getStakeAddress = useCallback(async (walletType: string) => { + const wallet = await BrowserWallet.enable(walletType); + + const addresses = await wallet.getRewardAddresses(); + + return addresses[0]; + }, []); + + const getPaymentAddress = useCallback(async (walletType: string) => { + const wallet = await BrowserWallet.enable(walletType); + + const addresses = await wallet.getUsedAddresses(); + + return addresses[0]; + }, []); + + const getRawUtxos = useCallback(async (walletType: string) => { + const api = await window.cardano[walletType].enable(); + + const rawUtxos = await api.getUtxos(); + + if (rawUtxos === undefined) return; + return rawUtxos; + }, []); + + const getRawCollateralUtxo = useCallback(async (walletType: string) => { + const api = await window.cardano[walletType].enable(); + + const collateral = await api.experimental.getCollateral(); + + if (collateral === undefined) return; + return collateral[0]; + }, []); + + const signTx = useCallback(async (walletType: string, unsignedTx: string) => { + const api = await window.cardano[walletType].enable(); + + const signedTx = await api.signTx(unsignedTx, true); + + if (signedTx === undefined) return; + return signedTx; + }, []); + + const handleClose = () => setOpen(false); + + const handleSubmit = async () => { + setIsSigning(true); + try { + if (claimEntry === null) return; + const walletType = claimEntry.walletType; + + const rawUtxos = await getRawUtxos(walletType); + const rawCollateralUtxo = await getRawCollateralUtxo(walletType); + if (rawUtxos === undefined || rawCollateralUtxo === undefined) return; + + const rootHash: string = claimEntry.rootHash; + + const paymentAddress = await getPaymentAddress(walletType); + const stakeAddress = await getStakeAddress(walletType); + + if (stakeAddress === undefined) return; + + const { updatedRootHash, rawProof, rawClaimEntry } = + await createClaimTreasuryDataMutation.mutateAsync({ + rootHash, + ownerAddress: stakeAddress, + }); + + const id: string = claimEntry.id; + + const { unsignedTxRaw, treasuryUtxoRaw } = + await claimTreasuryMutation.mutateAsync({ + id, + ownerAddress: paymentAddress, + updatedRootHash, + rawProof, + rawClaimEntry, + rawCollateralUtxo, + rawUtxos, + }); + + const signedTx = await signTx(walletType, unsignedTxRaw); + + if (signedTx === undefined) return; + + const { txHash } = await finalizeTransaction.mutateAsync({ + unsignedTxCbor: unsignedTxRaw, + txWitnessCbor: signedTx, + }); + + const ownerPkh = claimEntry.ownerPkh; + + const response = await submitClaimTreasuryTransaction.mutateAsync({ + id, + ownerPkh, + utxoRaw: treasuryUtxoRaw, + txRaw: txHash, + }); + console.log(response); + setOpen(false); + addAlert("success", "Redeemed Treasury"); + } catch (ex: any) { + addAlert("error", "Error redeeming treasury"); + console.error("Error adding stake", ex); + } + setIsSigning(false); + }; + + return ( + <> + + + Redeem Treasury + + theme.palette.grey[500], + }} + > + + + + + + + + + + + + + ); +}; + +export default VestingConfirm; diff --git a/src/components/vesting/VestingPositionTable.tsx b/src/components/vesting/VestingPositionTable.tsx new file mode 100644 index 0000000..4a18e45 --- /dev/null +++ b/src/components/vesting/VestingPositionTable.tsx @@ -0,0 +1,422 @@ +import React, { useState, useMemo, useCallback, FC } from "react"; +import { + Avatar, + Box, + Button, + Divider, + Skeleton, + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TablePagination, + TableRow, + Typography, + useTheme, +} from "@mui/material"; +import dayjs from "dayjs"; +import DashboardCard from "@components/dashboard/DashboardCard"; +import { trpc } from "@lib/utils/trpc"; +import { ClaimEntriesResponse } from "@server/services/vestingApi"; +import WalletSelectDropdown from "@components/WalletSelectDropdown"; +import { walletDataByName } from "@lib/walletsList"; +import VestingConfirm from "./VestingConfirm"; + +interface IVestingPositionTableProps { + connectedAddress?: string; + walletName?: string; +} + +type ClaimEntry = { + id: string; + rootHash: string; + ownerPkh: string; + token: string; + total: number | string; + claimable: number | string; + frequency: string; + nextUnlockDate: string; + endDate: string; + remainingPeriods: string; + ownerAddress: string; + walletType: string; +}; + +type ClaimEntriesResponseWithWalletType = { + claimEntry: ClaimEntriesResponse; + ownerAddress: string; + walletType: string; +}; + +const rowsPerPageOptions = [5, 10, 15]; + +export const shortenString = (input: string): string => { + if (input.length <= 11) { + return input; + } + + const start = input.slice(0, 7); + const end = input.slice(-4); + + return `${start}...${end}`; +}; + +const decimalConverter = ( + value: number | string | undefined, + decimals: number | string | undefined, +) => { + if (!value) return 0; + if (!decimals) return 0; + + const actualValue = typeof value === "string" ? parseFloat(value) : value; + const decimalValue = + typeof decimals === "string" ? parseFloat(decimals) : decimals; + + if (isNaN(actualValue)) return 0; + if (isNaN(decimalValue)) return 0; + + const divisor = Math.pow(10, decimalValue); + const result = actualValue / divisor; + + return result.toFixed(decimalValue); +}; + +const mapClaimEntriesResponseToClaimEntries = ( + claimEntriesResponsesWithWalletType: ClaimEntriesResponseWithWalletType[], +): ClaimEntry[] => { + return claimEntriesResponsesWithWalletType.map((entry) => { + const total = entry.claimEntry.vestingValue + ? Object.entries(entry.claimEntry.vestingValue) + .filter(([policyId]) => policyId.length > 0) + .reduce( + (acc, [, assets]) => + acc + Object.values(assets).reduce((sum, num) => sum + num, 0), + 0, + ) + : "N/A"; + + const claimable = entry.claimEntry.directValue + ? Object.entries(entry.claimEntry.directValue) + .filter(([policyId]) => policyId.length > 0) + .reduce( + (acc, [, assets]) => + acc + Object.values(assets).reduce((sum, num) => sum + num, 0), + 0, + ) + : "N/A"; + + return { + id: entry.claimEntry.id, + rootHash: entry.claimEntry.rootHash, + ownerPkh: entry.claimEntry.claimantPkh, + token: "CNCT", + total: decimalConverter(total, process.env.DEFAULT_CNCT_DECIMALS), + claimable: decimalConverter(claimable, process.env.DEFAULT_CNCT_DECIMALS), + frequency: "N/A", + nextUnlockDate: "N/A", + endDate: "N/A", + remainingPeriods: "N/A", + ownerAddress: entry.ownerAddress, + walletType: entry.walletType, + }; + }); +}; + +const VestingPositionTable: FC = ({ + connectedAddress, +}: IVestingPositionTableProps) => { + const theme = useTheme(); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(rowsPerPageOptions[0]); + const [claimEntries, setClaimEntries] = useState([]); + const [claimEntry, setClaimEntry] = useState(null); + const [openConfirmationDialog, setOpenConfirmationDialog] = useState(false); + + const _connectedAddress = useMemo(() => connectedAddress, [connectedAddress]); + + const fetchClaimEntriesByAddressMutation = + trpc.vesting.fetchClaimEntriesByAddress.useMutation(); + + const isLoading = useMemo(() => fetchClaimEntriesByAddressMutation.isLoading, [fetchClaimEntriesByAddressMutation.isLoading]); + + const getWallets = trpc.user.getWallets.useQuery(); + + const wallets = useMemo( + () => getWallets.data && getWallets.data.wallets, + [getWallets], + ); + + const walletByName = useCallback( + (name: string) => walletDataByName(name), + [], + ); + + const getWalletTypeOfAddress = useCallback( + (address: string) => { + if (wallets === undefined || wallets === null) return; + + const wallet = wallets.find((wallet) => wallet.changeAddress == address); + + if (wallet === undefined) return; + + return wallet.type; + }, + [wallets], + ); + + const handleChangePage = ( + event: React.MouseEvent | null, + newPage: number, + ) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = ( + event: React.ChangeEvent, + ) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const formatData = ( + data: ClaimEntry, + key: keyof ClaimEntry, + ): string => { + const value = data[key]; + + if (typeof value === "number") { + return value.toLocaleString(); + } else if (value instanceof Date) { + return dayjs(value).format("YYYY/MM/DD"); + } + return shortenString(String(value)); + }; + + const camelCaseToTitle = (camelCase: string) => { + const withSpaces = camelCase.replace(/([A-Z])/g, " $1").trim(); + return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1); + }; + + const fetchClaimEntries = useCallback( + async (addresses: string[]) => { + const claimEntriesResponsesWithWalletType: ClaimEntriesResponseWithWalletType[] = + []; + for (const address of addresses) { + const _claimEntriesResponses: ClaimEntriesResponse[] = + await fetchClaimEntriesByAddressMutation.mutateAsync({ + addresses: [address], + }); + + const walletType = getWalletTypeOfAddress(address); + + const _claimEntriesResponsesWithWalletType: ClaimEntriesResponseWithWalletType[] = + _claimEntriesResponses.map((claimEntriesResponse) => ({ + claimEntry: claimEntriesResponse, + ownerAddress: address, + walletType: walletType !== undefined ? walletType : "", + })); + + claimEntriesResponsesWithWalletType.push( + ..._claimEntriesResponsesWithWalletType, + ); + } + + const mappedClaimEntries = mapClaimEntriesResponseToClaimEntries( + claimEntriesResponsesWithWalletType, + ); + + setClaimEntries(mappedClaimEntries); + }, + [fetchClaimEntriesByAddressMutation, getWalletTypeOfAddress], + ); + + const handleOnRedeemClick = (claimEntry: ClaimEntry) => { + setClaimEntry(claimEntry); + setOpenConfirmationDialog(true); + }; + + return ( + <> + + Your Vesting Positions + + + + + + + {claimEntries.length > 0 ? ( + + + + + {Object.keys(claimEntries[0]) + .filter( + (key) => + key !== "id" && + key !== "ownerPkh" && + key !== "rootHash" && + key !== "ownerAddress" && + key !== "total" && + key !== "walletType", + ) + .map((column) => ( + + {camelCaseToTitle(String(column))} + + ))} + + + + {claimEntries + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((item, index) => ( + + + + + {Object.keys(item) + .filter( + (key) => + key !== "id" && + key !== "ownerPkh" && + key !== "rootHash" && + key !== "ownerAddress" && + key !== "total" && + key !== "walletType", + ) + .map((key, colIndex) => ( + + {isLoading ? ( + + ) : ( + formatData(item, key as keyof ClaimEntry) + )} + + ))} + + + + + ))} + + + + + + +
+ ) : ( + + No data available + + )} +
+ + + ); +}; + +export default VestingPositionTable; diff --git a/src/components/vesting/VestingStatsTable.tsx b/src/components/vesting/VestingStatsTable.tsx new file mode 100644 index 0000000..ecb6913 --- /dev/null +++ b/src/components/vesting/VestingStatsTable.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import { + Avatar, + Box, + Paper, + Skeleton, + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TablePagination, + TableRow, + useTheme +} from '@mui/material'; +import DashboardCard from '@components/dashboard/DashboardCard'; + +interface IVestingStatsTableProps { + data: T[]; + isLoading: boolean; +} + +const VestingStatsTable = >({data,isLoading}: IVestingStatsTableProps) => { + const theme = useTheme(); + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(5); + + const formatData = (data: T, key: keyof T): string => { + const value = data[key]; + if (typeof value === 'number') { + return value.toLocaleString(); + } else if (value instanceof Date) { + return dayjs(value).format('YYYY/MM/DD'); + } + return String(value); + } + + const camelCaseToTitle = (camelCase: string) => { + const withSpaces = camelCase.replace(/([A-Z])/g, ' $1').trim(); + return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1); + } + + const columns: (keyof T)[] = data.length > 0 ? Object.keys(data[0]).filter(key => !key.includes('icon')) as (keyof T)[] : []; + + const handleChangePage = (event: React.MouseEvent | null, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + return ( + + {data.length > 0 ? + + + + + {columns.map((column) => ( + {camelCaseToTitle(String(column))} + ))} + + + + {data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((item, index) => ( + + + {item.icon && } + + {Object.keys(item).map((key, colIndex) => ( + key !== 'icon' && + + {isLoading ? : formatData(item, key as keyof T)} + + ))} + + ))} + + + + + + +
: + + + No data available + + + } +
+ ); +} + +export default VestingStatsTable; diff --git a/src/components/vesting/pages/VestingDashboardPage.tsx b/src/components/vesting/pages/VestingDashboardPage.tsx new file mode 100644 index 0000000..046c94b --- /dev/null +++ b/src/components/vesting/pages/VestingDashboardPage.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from "react"; +import { Box, Container, Typography, useTheme } from "@mui/material"; +import VestingPositionTable from "../VestingPositionTable"; +import { useWallet } from "@meshsdk/react"; +import { useWalletContext } from "@contexts/WalletContext"; + +const VestingDashboardPage = () => { + const { wallet, connected, name } = useWallet(); + useWalletContext(); + const [connectedAddress, setConnectedAddress] = useState( + undefined, + ); + const [, setWalletName] = useState(undefined); + + useEffect(() => { + const execute = async () => { + if (connected) { + const api = await window.cardano[name.toLowerCase()].enable(); + + const changeAddress = await wallet.getChangeAddress(); + setConnectedAddress(changeAddress); + setWalletName(name.toLowerCase()); + } + }; + execute(); + }, [connected, connectedAddress, name, wallet]); + + return ( + + + + Vesting Dashboard + + + + + + + + ); +}; + +export default VestingDashboardPage; diff --git a/src/pages/staking/[[...staking_page]].tsx b/src/pages/staking/[[...staking_page]].tsx index 2bded2e..7f81e08 100644 --- a/src/pages/staking/[[...staking_page]].tsx +++ b/src/pages/staking/[[...staking_page]].tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { NextPage } from 'next'; import { useRouter } from 'next/router'; import ErrorPage from '@components/ErrorPage'; diff --git a/src/pages/vesting/index.tsx b/src/pages/vesting/index.tsx new file mode 100644 index 0000000..ea117c5 --- /dev/null +++ b/src/pages/vesting/index.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { NextPage } from 'next'; +import VestingDashboardPage from '@components/vesting/pages/VestingDashboardPage'; + +const Vesting: NextPage = () => { + return ; +} + +export default Vesting; \ No newline at end of file diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index 195ea56..0eb8603 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -14,6 +14,7 @@ import { tokensRouter } from "./tokens"; import { userRouter } from "./user"; import { whitelistRouter } from "./whitelist"; import { xerberusRouter } from "./xerberus"; +import { vestingRouter } from "./vesting"; /** * This is the primary router for the server. @@ -34,6 +35,7 @@ export const appRouter = createTRPCRouter({ tokens: tokensRouter, xerberus: xerberusRouter, sync: syncRouter, + vesting: vestingRouter, evm: evmRouter, email: emailRouter }); diff --git a/src/server/routers/vesting.ts b/src/server/routers/vesting.ts new file mode 100644 index 0000000..f4f72ed --- /dev/null +++ b/src/server/routers/vesting.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { coinectaVestingApi } from "@server/services/vestingApi"; + +export const vestingRouter = createTRPCRouter({ + createClaimTreasuryData: protectedProcedure + .input( + z.object({ + rootHash: z.string(), + ownerAddress: z.string(), + }), + ) + .mutation(async ({ input }) => { + return await coinectaVestingApi.createClaimTreasuryData(input); + }), + fetchClaimEntriesByAddress: protectedProcedure + .input( + z.object({ + addresses: z.array(z.string()), + }), + ) + .mutation(async ({ input }) => { + return await coinectaVestingApi.fetchClaimEntriesByAddress(input); + }), + finalizeTransaction: protectedProcedure + .input( + z.object({ + unsignedTxCbor: z.string(), + txWitnessCbor: z.string(), + }), + ) + .mutation(async ({ input }) => { + return await coinectaVestingApi.finalizeTransaction(input); + }), + claimTreasury: protectedProcedure + .input( + z.object({ + id: z.string(), + ownerAddress: z.string(), + updatedRootHash: z.string(), + rawProof: z.string(), + rawClaimEntry: z.string(), + rawCollateralUtxo: z.string(), + rawUtxos: z.array(z.string()), + }), + ) + .mutation(async ({ input }) => { + return await coinectaVestingApi.claimTreasury(input); + }), + submitClaimTreasuryTransaction: protectedProcedure + .input( + z.object({ + id: z.string(), + ownerPkh: z.string(), + utxoRaw: z.string(), + txRaw: z.string(), + }), + ) + .mutation(async ({ input }) => { + return await coinectaVestingApi.submitClaimTreasuryTransaction(input); + }), +}); diff --git a/src/server/services/vestingApi.ts b/src/server/services/vestingApi.ts new file mode 100644 index 0000000..426e322 --- /dev/null +++ b/src/server/services/vestingApi.ts @@ -0,0 +1,198 @@ +import { mapAxiosErrorToTRPCError } from "@server/utils/mapErrors"; +import { TRPCError } from "@trpc/server"; +import axios from "axios"; + +export type ClaimEntriesRequest = { + addresses: string[]; +}; + +export type ClaimEntriesResponse = { + id: string; + rootHash: string; + claimantPkh: string; + vestingValue?: { + [key: string]: { + [key: string]: number; + }; + }; + directValue?: { + [key: string]: { + [key: string]: number; + }; + }; +}; + +export type ClaimTreasuryDataRequest = { + rootHash: string; + ownerAddress: string; +}; + +export type ClaimTreasuryDataResponse = { + updatedRootHash: string; + rawProof: string; + rawClaimEntry: string; +}; + +export type ClaimTreasuryRequest = { + id: string; + ownerAddress: string; + updatedRootHash: string; + rawProof: string; + rawClaimEntry: string; + rawCollateralUtxo: string; + rawUtxos: string[]; +}; + +export type ClaimTreasuryResponse = { + unsignedTxRaw: string; + treasuryUtxoRaw: string; +}; + +export type FinalizeTransactionRequest = { + unsignedTxCbor: string; + txWitnessCbor: string; +}; + +export type FinalizeTransactionResponse = { + txHash: string; +}; + +export type SubmitClaimTreasuryTransactionRequest = { + id: string; + ownerPkh: string; + utxoRaw: string; + txRaw: string; +}; + +export type SubmitClaimTreasuryTransactionResponse = { + txHash: string; +}; + +export const coinectaVestingApi = { + async finalizeTransaction( + request: FinalizeTransactionRequest, + ): Promise { + try { + const response = await vestingApi.post( + "/api/v1/transaction/finalize", + request, + ); + return { txHash: response.data } as FinalizeTransactionResponse; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.response?.data ?? "An unknown error occurred", + }); + } else { + console.error(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An unknown error occurred", + }); + } + } + }, + async createClaimTreasuryData( + request: ClaimTreasuryDataRequest, + ): Promise { + try { + const response = await vestingApi.put( + `/api/v1/treasury/claim?rootHash=${request.rootHash}&ownerAddress=${request.ownerAddress}`, + ); + return response.data as ClaimTreasuryDataResponse; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.response?.data ?? "An unknown error occurred", + }); + } else { + console.error(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An unknown error occurred", + }); + } + } + }, + async fetchClaimEntriesByAddress( + request: ClaimEntriesRequest, + ): Promise { + try { + const response = await vestingApi.post( + "/api/v1/treasury/claim/entries", + request.addresses, + ); + return response.data as ClaimEntriesResponse[]; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.response?.data ?? "An unknown error occurred", + }); + } else { + console.error(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An unknown error occurred", + }); + } + } + }, + async claimTreasury( + request: ClaimTreasuryRequest, + ): Promise { + try { + const response = await vestingApi.post( + "/api/v1/transaction/treasury/claim", + request, + ); + return response.data as ClaimTreasuryResponse; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.response?.data ?? "An unknown error occurred", + }); + } else { + console.error(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An unknown error occurred", + }); + } + } + }, + async submitClaimTreasuryTransaction( + request: SubmitClaimTreasuryTransactionRequest, + ): Promise { + try { + const response = await vestingApi.post( + "/api/v1/transaction/treasury/claim/submit", + request, + ); + return response.data as SubmitClaimTreasuryTransactionResponse; + } catch (error) { + if (axios.isAxiosError(error)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error.response?.data ?? "An unknown error occurred", + }); + } else { + console.error(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An unknown error occurred", + }); + } + } + }, +}; + +export const vestingApi = axios.create({ + baseURL: process.env.COINECTA_VESTING_API, + headers: { + "Content-type": "application/json;charset=utf-8", + }, +});