diff --git a/.circleci/config.yml b/.circleci/config.yml index 7efe889..ff65d43 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -49,6 +49,7 @@ workflows: ci: jobs: - build_test: + context: GC_FE_TEST node_version: *node_version - security: node_version: *node_version \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0962170 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +NEXT_PUBLIC_WEB3AUTH_NETWORK= +NEXT_PUBLIC_WEB3AUTH_CLIENT_ID= +NEXT_PUBLIC_GHOSTCLOUD_RPC_TARGET= +NEXT_PUBLIC_GHOSTCLOUD_CHAIN_NAMESPACE= +NEXT_PUBLIC_GHOSTCLOUD_DISPLAY_NAME= +NEXT_PUBLIC_GHOSTCLOUD_CHAIN_ID= +NEXT_PUBLIC_GHOSTCLOUD_ADDRESS_PREFIX= +NEXT_PUBLIC_GHOSTCLOUD_DENOM= +NEXT_PUBLIC_GHOSTCLOUD_URL_SCHEME= +NEXT_PUBLIC_GHOSTCLOUD_URL_DOMAIN= +NEXT_PUBLIC_GHOSTCLOUD_GAS_PRICE= +NEXT_PUBLIC_GHOSTCLOUD_GAS_LIMIT_MULTIPLIER= +NEXT_PUBLIC_GHOSTCLOUD_BANK_ACCOUNT_ADDRESS= +NEXT_PUBLIC_NOWPAYMENT_API_KEY= +NEXT_PUBLIC_NOWPAYMENT_API_SUBDOMAIN= +NEXT_PUBLIC_NOWPAYMENT_PRICE_CURRENCY= +NEXT_PUBLIC_NOWPAYMENT_PAY_CURRENCY= +NEXT_PUBLIC_NOWPAYMENT_IS_FIXED_RATE= +NEXT_PUBLIC_NOWPAYMENT_IS_FEE_PAID_BY_USER= +NEXT_PUBLIC_NOWPAYMENT_PRICE_AMOUNT= +NEXT_PUBLIC_NOWPAYMENT_IPN_CALLBACK_URL= +NEXT_PUBLIC_NOWPAYMENT_SUCCESS_REDIRECT_URL= +BANK_ACCOUNT_KEY= +TRANSFER_AMOUNT= +TRANSFER_AMOUNT_GAS_BUFFER= +IPN_SECRET_KEY= +LOG_LEVEL= \ No newline at end of file diff --git a/.env.local b/.env.local deleted file mode 100644 index fe7b16c..0000000 --- a/.env.local +++ /dev/null @@ -1,12 +0,0 @@ -NEXT_PUBLIC_WEB3AUTH_NETWORK=testnet -NEXT_PUBLIC_WEB3AUTH_CLIENT_ID=BKj3lr6GfN2CnvO4CIKo5fuoCg_TpHsAPK7R8lbl6kUlz0CAH_5mFNswScEb7M6szV4hd1Tkwa2oPZ9KiXJB-44 -NEXT_PUBLIC_GHOSTCLOUD_RPC_TARGET=http://localhost:26657 -NEXT_PUBLIC_GHOSTCLOUD_CHAIN_NAMESPACE=other -NEXT_PUBLIC_GHOSTCLOUD_DISPLAY_NAME=Ghostcloud -NEXT_PUBLIC_GHOSTCLOUD_CHAIN_ID=gc-local -NEXT_PUBLIC_GHOSTCLOUD_ADDRESS_PREFIX=gc -NEXT_PUBLIC_GHOSTCLOUD_DENOM=token -NEXT_PUBLIC_GHOSTCLOUD_URL_SCHEME=http -NEXT_PUBLIC_GHOSTCLOUD_URL_DOMAIN=localhost:8880 -NEXT_PUBLIC_GHOSTCLOUD_GAS_PRICE=0.000000025 -NEXT_PUBLIC_GHOSTCLOUD_GAS_LIMIT_MULTIPLIER=1.5 diff --git a/.env.production.local b/.env.production.local deleted file mode 100644 index 530c192..0000000 --- a/.env.production.local +++ /dev/null @@ -1,12 +0,0 @@ -NEXT_PUBLIC_WEB3AUTH_NETWORK=cyan -NEXT_PUBLIC_WEB3AUTH_CLIENT_ID=BLlqF8V3D0hLGDBfedOKvBXEZMYsdJB5kMX2GNCyXB5ZMU1enVyciGhgihBqW8E6NTaeZO182nF8zMiiMd1cAmk -NEXT_PUBLIC_GHOSTCLOUD_RPC_TARGET=https://ghostcloud.org/api -NEXT_PUBLIC_GHOSTCLOUD_CHAIN_NAMESPACE=other -NEXT_PUBLIC_GHOSTCLOUD_DISPLAY_NAME=Ghostcloud -NEXT_PUBLIC_GHOSTCLOUD_CHAIN_ID=gc-testnet-1 -NEXT_PUBLIC_GHOSTCLOUD_ADDRESS_PREFIX=gc -NEXT_PUBLIC_GHOSTCLOUD_DENOM=token -NEXT_PUBLIC_GHOSTCLOUD_URL_SCHEME=https -NEXT_PUBLIC_GHOSTCLOUD_URL_DOMAIN=ghostcloud.org -NEXT_PUBLIC_GHOSTCLOUD_GAS_PRICE=0.000000025 -NEXT_PUBLIC_GHOSTCLOUD_GAS_LIMIT_MULTIPLIER=1.5 diff --git a/.gitignore b/.gitignore index 288f79a..e7df06a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ yarn-error.log* next-env.d.ts .idea/ +.env.local +.env.production.local diff --git a/__tests__/components/address-display.test.tsx b/__tests__/components/address-display.test.tsx index ef5428a..4f72181 100644 --- a/__tests__/components/address-display.test.tsx +++ b/__tests__/components/address-display.test.tsx @@ -4,9 +4,6 @@ import { render, screen } from "@testing-library/react" import { useQuery } from "react-query" import AddressDisplay from "../../components/address-display" -jest.mock("react-query", () => ({ - useQuery: jest.fn(), -})) describe("AddressDisplay", () => { it("renders correct elements", () => { useQuery.mockReturnValue({ diff --git a/__tests__/components/balance-display.test.tsx b/__tests__/components/balance-display.test.tsx index a9b3c75..fa61e8b 100644 --- a/__tests__/components/balance-display.test.tsx +++ b/__tests__/components/balance-display.test.tsx @@ -4,10 +4,6 @@ import { render, screen } from "@testing-library/react" import { useQuery } from "react-query" import BalanceDisplay from "../../components/balance-display" -jest.mock("react-query", () => ({ - useQuery: jest.fn(), -})) - describe("BalanceDisplay", () => { it("renders correct elements", () => { useQuery.mockReturnValue({ diff --git a/__tests__/components/create-deployment.test.tsx b/__tests__/components/create-deployment.test.tsx index 402ebf6..6801635 100644 --- a/__tests__/components/create-deployment.test.tsx +++ b/__tests__/components/create-deployment.test.tsx @@ -2,12 +2,6 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react" import CreateDeploymentModal from "../../components/create-deployment" -jest.mock("react-query", () => ({ - useQuery: jest.fn(), - useQueryClient: jest.fn(), - useMutation: jest.fn(), -})) - describe("CreateDeploymentModal", () => { it("renders form elements", async () => { render() diff --git a/__tests__/components/dashboard.test.tsx b/__tests__/components/dashboard.test.tsx index 6aa98b7..88bc4e0 100644 --- a/__tests__/components/dashboard.test.tsx +++ b/__tests__/components/dashboard.test.tsx @@ -4,19 +4,6 @@ import { useQuery } from "react-query" import Dashboard from "../../components/dashboard" import useWeb3AuthStore from "../../store/web3-auth" -jest.mock("react-query", () => ({ - useQuery: jest.fn(), - useQueryClient: jest.fn(), - useMutation: jest.fn(), -})) -jest.mock("../../store/web3-auth", () => jest.fn()) -jest.mock("../../hooks/useAuthHandlers", () => { - return jest.fn().mockReturnValue({ - handleLogin: jest.fn(), - handleLogout: jest.fn(), - }) -}) - describe("Dashboard", () => { beforeEach(() => { useWeb3AuthStore.mockReturnValue({ diff --git a/__tests__/components/header.test.tsx b/__tests__/components/header.test.tsx index 4deb405..5caa675 100644 --- a/__tests__/components/header.test.tsx +++ b/__tests__/components/header.test.tsx @@ -2,13 +2,6 @@ import "@testing-library/jest-dom" import { render, screen } from "@testing-library/react" import Header from "../../components/header" -jest.mock("../../hooks/useAuthHandlers", () => { - return jest.fn().mockReturnValue({ - handleLogin: jest.fn(), - handleLogout: jest.fn(), - }) -}) - describe("Header", () => { it("renders correct components", () => { render(
) diff --git a/__tests__/components/menu.test.tsx b/__tests__/components/menu.test.tsx index f8b4a76..be2e452 100644 --- a/__tests__/components/menu.test.tsx +++ b/__tests__/components/menu.test.tsx @@ -1,58 +1,34 @@ // @ts-nocheck import { render, screen, fireEvent } from "@testing-library/react" import Menu from "../../components/menu" +import { handleLogin, handleLogout } from "../../jest.setup" import useWeb3AuthStore from "../../store/web3-auth" -import useAuthHandlers from "../../hooks/useAuthHandlers" - -jest.mock("../../store/web3-auth", () => jest.fn()) -jest.mock("../../hooks/useAuthHandlers", () => - jest.fn().mockReturnValue({ - handleLogin: jest.fn(), - handleLogout: jest.fn(), - }), -) describe("Menu", () => { + beforeEach(() => { + useWeb3AuthStore.mockReturnValue({ provider: null }) + }) + it("renders Login button when provider is not available", () => { - useWeb3AuthStore.mockReturnValue({ - provider: null, - }) render() expect(screen.getByText("Login")).toBeInTheDocument() }) it("renders Dashboard and Logout buttons when provider is available", () => { - useWeb3AuthStore.mockReturnValue({ - provider: {}, - }) + useWeb3AuthStore.mockReturnValue({ provider: {} }) render() expect(screen.getByText("Dashboard")).toBeInTheDocument() expect(screen.getByText("Logout")).toBeInTheDocument() }) it("calls handleLogin when Login button is clicked", () => { - useWeb3AuthStore.mockReturnValue({ - provider: null, - }) - const handleLogin = jest.fn() - useAuthHandlers.mockReturnValue({ - handleLogin, - handleLogout: jest.fn(), - }) render() fireEvent.click(screen.getByText("Login")) expect(handleLogin).toHaveBeenCalled() }) it("calls handleLogout when Logout button is clicked", () => { - useWeb3AuthStore.mockReturnValue({ - provider: "mockProvider", - }) - const handleLogout = jest.fn() - useAuthHandlers.mockReturnValue({ - handleLogin: jest.fn(), - handleLogout, - }) + useWeb3AuthStore.mockReturnValue({ provider: {} }) render() fireEvent.click(screen.getByText("Logout")) expect(handleLogout).toHaveBeenCalled() diff --git a/__tests__/pages/dashboard.test.tsx b/__tests__/pages/dashboard.test.tsx index 7ad369e..0bdebdd 100644 --- a/__tests__/pages/dashboard.test.tsx +++ b/__tests__/pages/dashboard.test.tsx @@ -4,7 +4,6 @@ import { render, screen } from "@testing-library/react" import Dashboard from "../../pages/dashboard" import useWeb3AuthStore from "../../store/web3-auth" -jest.mock("../../store/web3-auth", () => jest.fn()) describe("Dashboard", () => { it("renders login message if not connected", () => { useWeb3AuthStore.mockReturnValue({ diff --git a/__tests__/pages/index.test.tsx b/__tests__/pages/index.test.tsx index 66045c6..9aeeebd 100644 --- a/__tests__/pages/index.test.tsx +++ b/__tests__/pages/index.test.tsx @@ -2,13 +2,6 @@ import "@testing-library/jest-dom" import { render, screen } from "@testing-library/react" import Home from "../../pages" -jest.mock("../../hooks/useAuthHandlers", () => { - return jest.fn().mockReturnValue({ - handleLogin: jest.fn(), - handleLogout: jest.fn(), - }) -}) - describe("Home", () => { it("renders the home page", () => { render() diff --git a/components/address-display.tsx b/components/address-display.tsx index 5dd2623..ff982a7 100644 --- a/components/address-display.tsx +++ b/components/address-display.tsx @@ -8,7 +8,7 @@ import { } from "@chakra-ui/react" import { LuCopy, LuCopyCheck } from "react-icons/lu" import { truncateAddress } from "../helpers/address" -import { useFetchAddress } from "../lib/ghostcloud" +import { useFetchAddress } from "../hooks/ghostcloud" export default function AddressDisplay() { const addrBgColor = useColorModeValue( diff --git a/components/balance-display.tsx b/components/balance-display.tsx index 65b286f..5be0152 100644 --- a/components/balance-display.tsx +++ b/components/balance-display.tsx @@ -1,5 +1,5 @@ -import { useFetchBalance } from "../lib/ghostcloud" import { Box, Flex, Spinner, useColorModeValue } from "@chakra-ui/react" +import { useFetchBalance } from "../hooks/ghostcloud" export default function BalanceDisplay() { const { data: balance, isLoading: isBalanceLoading } = useFetchBalance() diff --git a/components/create-deployment.tsx b/components/create-deployment.tsx index f18bef7..616a902 100644 --- a/components/create-deployment.tsx +++ b/components/create-deployment.tsx @@ -15,9 +15,9 @@ import { Text, Textarea, } from "@chakra-ui/react" -import { useCreateDeployment } from "../lib/ghostcloud" import FileUpload from "./file-upload" -import { useDisplayError } from "../helpers/errors" +import { useCreateDeployment } from "../hooks/ghostcloud" +import { useDisplayError } from "../helpers/toast" export interface DeploymentData { name: string diff --git a/components/dashboard.tsx b/components/dashboard.tsx index ac781ee..8ffbce7 100644 --- a/components/dashboard.tsx +++ b/components/dashboard.tsx @@ -13,7 +13,6 @@ import { HStack, } from "@chakra-ui/react" import { DeleteIcon, EditIcon } from "@chakra-ui/icons" -import { useFetchMetas } from "../lib/ghostcloud" import { useEffect, useState } from "react" import CreateDeploymentModal from "./create-deployment" import UpdateDeploymentModal from "./update-deployment" @@ -24,6 +23,7 @@ import { } from "../config/ghostcloud-chain" import useWeb3AuthStore from "../store/web3-auth" import { truncateAddress } from "../helpers/address" +import { useListDeployments } from "../hooks/ghostcloud" function createUrl(name: string, address: string) { return `${GHOSTCLOUD_URL_SCHEME}://${name}-${address}.${GHOSTCLOUD_URL_DOMAIN}` @@ -40,7 +40,7 @@ const Dashboard = () => { const [selectedDeploymentDomain, setSelectedDeploymentDomain] = useState("") const [address, setAddress] = useState("") - const { data: metas, isLoading: isMetaLoading } = useFetchMetas() + const { data: metas, isLoading: isMetaLoading } = useListDeployments() const store = useWeb3AuthStore() useEffect(() => { diff --git a/components/header.tsx b/components/header.tsx index 72a441e..7b52d17 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -18,6 +18,8 @@ import Menu from "./menu" import AddressDisplay from "./address-display" import useWeb3AuthStore from "../store/web3-auth" import BalanceDisplay from "./balance-display" +import { useEffect } from "react" +import { useHandleLogin } from "../hooks/auth/handle-login" function Header() { const { colorMode, toggleColorMode } = useColorMode() @@ -26,8 +28,7 @@ function Header() { "modes.dark.background", ) const logo = useColorModeValue(logoLight, logoDark) - const store = useWeb3AuthStore() - const isConnected = store.isConnected() + const { isConnected } = useWeb3AuthStore() return (
@@ -46,7 +47,7 @@ function Header() { justifyContent={"flex-start"} py={2} > - {isConnected ? ( + {isConnected() ? ( diff --git a/components/menu.tsx b/components/menu.tsx index fdc8947..9f5ee10 100644 --- a/components/menu.tsx +++ b/components/menu.tsx @@ -9,14 +9,18 @@ import { import useWeb3AuthStore from "../store/web3-auth" import { TfiMenu } from "react-icons/tfi" import Link from "next/link" -import useAuthHandlers from "../hooks/useAuthHandlers" import React from "react" +import useHandlePayment from "../hooks/payment/handle-payment" +import { useHandleLogin } from "../hooks/auth/handle-login" +import { useHandleLogout } from "../hooks/auth/handle-logout" type LoginProps = {} const Menu: React.FC = () => { const store = useWeb3AuthStore() // To access the provider - const { handleLogin, handleLogout } = useAuthHandlers() + const { mutate: handleLogin } = useHandleLogin() + const { mutate: handleLogout } = useHandleLogout() + const { handlePayment } = useHandlePayment() return ( @@ -32,13 +36,14 @@ const Menu: React.FC = () => { {store.provider ? ( <> + Buy Tokens Dashboard - Logout + handleLogout()}>Logout ) : ( - Login + handleLogin()}>Login )} diff --git a/components/remove-deployment.tsx b/components/remove-deployment.tsx index 07b57d7..06ab086 100644 --- a/components/remove-deployment.tsx +++ b/components/remove-deployment.tsx @@ -13,8 +13,8 @@ import { } from "@chakra-ui/react" import { Formik, Form, Field, ErrorMessage, FormikHelpers } from "formik" import * as Yup from "yup" -import { useRemoveDeployment } from "../lib/ghostcloud" -import { useDisplayError } from "../helpers/errors" +import { useRemoveDeployment } from "../hooks/ghostcloud" +import { useDisplayError } from "../helpers/toast" const RemoveDeploymentModal = ({ isOpen, diff --git a/components/update-deployment.tsx b/components/update-deployment.tsx index 2af7ff6..e8791d0 100644 --- a/components/update-deployment.tsx +++ b/components/update-deployment.tsx @@ -15,9 +15,9 @@ import { Text, Textarea, } from "@chakra-ui/react" -import { useUpdateDeployment } from "../lib/ghostcloud" import FileUpload from "./file-upload" -import { useDisplayError } from "../helpers/errors" +import { useDisplayError } from "../helpers/toast" +import { useUpdateDeployment } from "../hooks/ghostcloud" export interface DeploymentData { name: string diff --git a/config/ghostcloud-chain.ts b/config/ghostcloud-chain.ts index a017947..eba317e 100644 --- a/config/ghostcloud-chain.ts +++ b/config/ghostcloud-chain.ts @@ -34,6 +34,15 @@ export const GHOSTCLOUD_MODAL_PRIMARY_COLOR = "gray" export const GHOSTCLOUD_GAS_PRICE = GasPrice.fromString( GHOSTCLOUD_GAS_PRICE_AMOUNT.concat(GHOSTCLOUD_GAS_PRICE_DENOM), ) +export const GHOSTCLOUD_BANK_ACCOUNT_ADDRESS = getBankAccountAddress() + +export function getBankAccountAddress() { + const address = process.env.NEXT_PUBLIC_GHOSTCLOUD_BANK_ACCOUNT_ADDRESS + if (!address) { + throw new Error("Bank account address is not set") + } + return address +} export const GHOSTCLOUD_CHAIN_CONFIG = { chainNamespace: GHOSTCLOUD_CHAIN_NAMESPACE, diff --git a/config/nowpayment.ts b/config/nowpayment.ts new file mode 100644 index 0000000..372898b --- /dev/null +++ b/config/nowpayment.ts @@ -0,0 +1,34 @@ +export const API_KEY = process.env.NEXT_PUBLIC_NOWPAYMENT_API_KEY ?? "INVALID" +export const PRICE_AMOUNT = process.env.NEXT_PUBLIC_NOWPAYMENT_PRICE_AMOUNT ?? 5 +export const PRICE_CURRENCY = + process.env.NEXT_PUBLIC_NOWPAYMENT_PRICE_CURRENCY ?? "usd" +export const PAY_CURRENCY = + process.env.NEXT_PUBLIC_NOWPAYMENT_PAY_CURRENCY ?? "ATOM" +export const IS_FIXED_RATE = + process.env.NEXT_PUBLIC_NOWPAYMENT_IS_FIXED_RATE ?? true +export const IS_FEE_PAID_BY_USER = + process.env.NEXT_PUBLIC_NOWPAYMENT_IS_FEE_PAID_BY_USER ?? true +const API_SUBDOMAIN = + process.env.NEXT_PUBLIC_NOWPAYMENT_API_SUBDOMAIN ?? "api-sandbox" +export const IPN_CALLBACK_URL = + process.env.NEXT_PUBLIC_NOWPAYMENT_IPN_CALLBACK_URL ?? + "https://localhost:3000/api/ipn" +export const PAYMENT_SUCCESS_URL = + process.env.NEXT_PUBLIC_NOWPAYMENT_SUCCESS_REDIRECT_URL ?? + "http://localhost:3000/success" +export const API_STATUS_ENDPOINT = `https://${API_SUBDOMAIN}.nowpayments.io/v1/status` +export const API_CREATE_INVOICE_ENDPOINT = `https://${API_SUBDOMAIN}.nowpayments.io/v1/invoice` + +const API_PAYMENT_STATUS_ENDPOINT = `https://${API_SUBDOMAIN}.nowpayments.io/v1/payment` +export const getPaymentStatus = async (id: string) => { + const myHeaders = new Headers() + myHeaders.append("x-api-key", API_KEY) + + const requestOptions = { + method: "GET", + headers: myHeaders, + redirect: "follow" as RequestRedirect, + } + + return await fetch(`${API_PAYMENT_STATUS_ENDPOINT}/${id}`, requestOptions) +} diff --git a/helpers/errors.ts b/helpers/errors.ts deleted file mode 100644 index 4e8d129..0000000 --- a/helpers/errors.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useToast } from "@chakra-ui/react" - -export function useDisplayError() { - const toast = useToast() - - return (title: string, error: Error) => { - toast({ - title, - description: error.message, - status: "error", - duration: 5000, - isClosable: true, - position: "top", - }) - } -} diff --git a/helpers/payment.tsx b/helpers/payment.tsx new file mode 100644 index 0000000..25ef4ce --- /dev/null +++ b/helpers/payment.tsx @@ -0,0 +1,74 @@ +import { PaymentStatus, PaymentStatusResponse } from "../types/payment/types" +import { Box, Text } from "@chakra-ui/react" +import { ReactNode } from "react" + +const paymentStatusMessages: Record = { + [PaymentStatus.waiting]: + "You have not paid yet. Please pay {amount} {currency} to {address}.", + [PaymentStatus.confirming]: + "You have paid {paid} {currency}. Please wait for confirmation.", + [PaymentStatus.confirmed]: + "You have paid {paid} {currency}. Please wait for the transaction to be processed.", + [PaymentStatus.failed]: "You have failed to pay {amount} {currency}.", + [PaymentStatus.expired]: + "You have not paid {amount} {currency} to {address} within the time limit.", + [PaymentStatus.partially_paid]: + "You have paid {paid} {currency}. Please pay the remaining to {address}.", + [PaymentStatus.sending]: + "You have paid {paid} {currency}. Please wait for the transaction to be processed.", + [PaymentStatus.refunded]: "You have been refunded {paid} {currency}.", + [PaymentStatus.finished]: "", +} + +const getFinishedPaymentStatusMessage = ( + response: PaymentStatusResponse, + hash: string, +): ReactNode => ( + + + You have successfully paid{" "} + + {response.pay_amount} {response.pay_currency.toUpperCase()} + + . + + + The transaction hash is{" "} + + {hash} + + . + + +) + +export const getMsgFromPaymentStatus = ( + response: PaymentStatusResponse, + hash: string, +) => { + let msg: ReactNode + let status = response.payment_status as PaymentStatus + let success = true + + if (status === PaymentStatus.finished) { + msg = getFinishedPaymentStatusMessage(response, hash) + } else { + msg = paymentStatusMessages[status] + .replace("{amount}", response.pay_amount.toString()) + .replace("{currency}", response.pay_currency) + .replace("{address}", response.pay_address) + .replace("{paid}", response.actually_paid.toString()) + .replace("{status}", status) + + if ( + status === PaymentStatus.failed || + status === PaymentStatus.expired || + status === PaymentStatus.partially_paid || + status === PaymentStatus.refunded + ) { + success = false + } + } + + return { msg, success } +} diff --git a/helpers/toast.tsx b/helpers/toast.tsx new file mode 100644 index 0000000..8ba88b5 --- /dev/null +++ b/helpers/toast.tsx @@ -0,0 +1,36 @@ +import { useToast } from "@chakra-ui/react" +import { ReactNode } from "react" + +export function useDisplaySuccess() { + const toast = useToast() + + return (title: string, msg: ReactNode) => { + toast({ + title, + description: msg, + status: "success", + duration: 10000, + isClosable: true, + position: "top", + containerStyle: { + width: "850px", + maxWidth: "100%", + }, + }) + } +} + +export function useDisplayError() { + const toast = useToast() + + return (title: string, error: Error) => { + toast({ + title, + description: error.message, + status: "error", + duration: 5000, + isClosable: true, + position: "top", + }) + } +} diff --git a/hooks/auth/handle-login.ts b/hooks/auth/handle-login.ts new file mode 100644 index 0000000..33c4e37 --- /dev/null +++ b/hooks/auth/handle-login.ts @@ -0,0 +1,37 @@ +import { useColorMode } from "@chakra-ui/react" +import { web3AuthInitProvider } from "../../lib/web3-auth" +import { + GHOSTCLOUD_CHAIN_CONFIG, + GHOSTCLOUD_UI_CONFIG, +} from "../../config/ghostcloud-chain" +import useWeb3AuthStore from "../../store/web3-auth" +import { useRouter } from "next/router" +import { useMutation } from "react-query" +import { useDisplayError } from "../../helpers/toast" + +export const useHandleLogin = () => { + const { colorMode } = useColorMode() // To pass the color mode to Web3Auth + const store = useWeb3AuthStore() // To access the provider + const router = useRouter() // To redirect on logout + const displayError = useDisplayError() + + const login = async () => { + if (!store.isConnected()) { + const uiConfig = { + ...GHOSTCLOUD_UI_CONFIG, + mode: colorMode, + } + await web3AuthInitProvider(GHOSTCLOUD_CHAIN_CONFIG, uiConfig) + } + const newPath = + router.pathname === "/dashboard" ? router.asPath : "/dashboard" + await router.push(newPath) + } + + return useMutation(login, { + onError: (error: unknown) => { + const err = error instanceof Error ? error : new Error("Unknown error") + displayError("Failed to login", err) + }, + }) +} diff --git a/hooks/auth/handle-logout.ts b/hooks/auth/handle-logout.ts new file mode 100644 index 0000000..debd76f --- /dev/null +++ b/hooks/auth/handle-logout.ts @@ -0,0 +1,22 @@ +import useWeb3AuthStore from "../../store/web3-auth" +import { useRouter } from "next/router" +import { useMutation } from "react-query" +import { useDisplayError } from "../../helpers/toast" + +export const useHandleLogout = () => { + const store = useWeb3AuthStore() // To access the provider + const router = useRouter() // To redirect on logout + const displayError = useDisplayError() + + const logout = async () => { + store.logout() + await router.push("/") // Return to the homepage + } + + return useMutation(logout, { + onError: (error: unknown) => { + const err = error instanceof Error ? error : new Error("Unknown error") + displayError("Failed to logout", err) + }, + }) +} diff --git a/hooks/ghostcloud.ts b/hooks/ghostcloud.ts new file mode 100644 index 0000000..c60d19b --- /dev/null +++ b/hooks/ghostcloud.ts @@ -0,0 +1,169 @@ +// Create a deployment message from the given deployment data +// Send a deployment creation transaction to the Ghostcloud RPC endpoint +import useWeb3AuthStore from "../store/web3-auth" +import { + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "react-query" +import { DeploymentData } from "../components/create-deployment" +import { + createDeployment, + fetchBalance, + listDeployments, + removeDeployment, + updateDeployment, +} from "../lib/ghostcloud" +import { QueryMetasResponse } from "@liftedinit/gcjs/dist/codegen/ghostcloud/ghostcloud/query" +import { useDisplayError } from "../helpers/toast" +import { Coin } from "@cosmjs/stargate" + +export const useCreateDeployment = () => { + const store = useWeb3AuthStore() + const queryClient = useQueryClient() + + const create = async (data: DeploymentData | null) => { + if (!data) { + throw new Error("Deployment data is empty.") + } + const creator = await store.getAddress() + if (!creator) { + throw new Error("Creator address is empty.") + } + + const pk = await store.getPrivateKey() + if (!pk) { + throw new Error("Private key is empty.") + } + return createDeployment(data, creator, pk) + } + + return useMutation({ + mutationFn: create, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: "metas" }) + queryClient.invalidateQueries({ queryKey: "balance" }) + }, + }) +} + +export const useUpdateDeployment = () => { + const store = useWeb3AuthStore() + const queryClient = useQueryClient() + + const update = async (data: DeploymentData | null) => { + if (!data) { + throw new Error("Deployment data is empty.") + } + const creator = await store.getAddress() + if (!creator) { + throw new Error("Creator address is empty.") + } + const pk = await store.getPrivateKey() + if (!pk) { + throw new Error("Private key is empty.") + } + return updateDeployment(data, creator, pk) + } + + return useMutation({ + mutationFn: update, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: "metas" }) + queryClient.invalidateQueries({ queryKey: "balance" }) + }, + }) +} + +export const useRemoveDeployment = () => { + const store = useWeb3AuthStore() + const queryClient = useQueryClient() + + const remove = async (name: string) => { + const creator = await store.getAddress() + if (!creator) { + throw new Error("Creator address is empty.") + } + const pk = await store.getPrivateKey() + if (!pk) { + throw new Error("Private key is empty.") + } + return removeDeployment(name, creator, pk) + } + + return useMutation({ + mutationFn: remove, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: "metas" }) + queryClient.invalidateQueries({ queryKey: "balance" }) + }, + }) +} + +// Query the Ghostcloud RPC endpoint for deployments created by the current user +export const useListDeployments = (): UseQueryResult< + QueryMetasResponse, + Error +> => { + const store = useWeb3AuthStore() + const displayError = useDisplayError() + + const list = async () => { + const address = await store.getAddress() + if (!address) { + throw new Error("Failed to fetch address from the store") + } + return listDeployments(address) + } + + return useQuery({ + queryKey: "metas", + queryFn: list, + onError: error => { + displayError("Failed to fetch deployments", error) + }, + }) +} +export const useFetchBalance = (): UseQueryResult => { + const store = useWeb3AuthStore() + const displayError = useDisplayError() + + const fetchAccBalance = async () => { + const address = await store.getAddress() + if (!address) { + throw new Error("Failed to fetch address from the store") + } + return fetchBalance(address) + } + + return useQuery({ + queryKey: "balance", + queryFn: fetchAccBalance, + onError: error => { + displayError("Failed to fetch balance", error) + }, + }) +} + +export const useFetchAddress = (): UseQueryResult => { + const store = useWeb3AuthStore() + const displayError = useDisplayError() + + const fetchAddress = async () => { + const address = await store.getAddress() + if (address) { + return address + } else { + throw new Error("Failed to fetch address") + } + } + + return useQuery({ + queryKey: "address", + queryFn: fetchAddress, + onError: error => { + displayError("Failed to fetch address", error) + }, + }) +} diff --git a/hooks/payment/check-api-status.ts b/hooks/payment/check-api-status.ts new file mode 100644 index 0000000..1127947 --- /dev/null +++ b/hooks/payment/check-api-status.ts @@ -0,0 +1,30 @@ +import { useDisplayError } from "../../helpers/toast" +import { API_STATUS_ENDPOINT } from "../../config/nowpayment" +import { useQuery } from "react-query" + +// Check the NOWPayments API status +export const useCheckApiStatus = () => { + const displayError = useDisplayError() + const checkApiStatus = async () => { + const requestOptions = { + method: "GET", + redirect: "follow" as RequestRedirect, + } + + const response = await fetch(API_STATUS_ENDPOINT, requestOptions) + if (!response.ok) { + throw new Error(`Failed to check API status: ${response.statusText}`) + } + + return response + } + + return useQuery({ + queryKey: "apiStatus", + queryFn: checkApiStatus, + onError: error => { + const err = error instanceof Error ? error : new Error("Unknown error") + displayError("Failed to check API status", err) + }, + }) +} diff --git a/hooks/payment/create-invoice.ts b/hooks/payment/create-invoice.ts new file mode 100644 index 0000000..a6ab586 --- /dev/null +++ b/hooks/payment/create-invoice.ts @@ -0,0 +1,74 @@ +import useWeb3AuthStore from "../../store/web3-auth" +import { useDisplayError } from "../../helpers/toast" +import { + API_CREATE_INVOICE_ENDPOINT, + API_KEY, + IPN_CALLBACK_URL, + IS_FEE_PAID_BY_USER, + IS_FIXED_RATE, + PAY_CURRENCY, + PAYMENT_SUCCESS_URL, + PRICE_AMOUNT, + PRICE_CURRENCY, +} from "../../config/nowpayment" +import { useMutation } from "react-query" + +// Create an invoice with NOWPayments +export const useCreateInvoice = () => { + const store = useWeb3AuthStore() + const displayError = useDisplayError() + + const createInvoice = async () => { + const myHeaders = new Headers() + myHeaders.append("x-api-key", API_KEY) + myHeaders.append("Content-Type", "application/json") + + if (!store.isConnected()) { + throw new Error("Wallet not connected") + } + + const address = await store.getAddress() + if (!address) { + throw new Error("Wallet address not found") + } + + const raw = JSON.stringify({ + price_amount: PRICE_AMOUNT, + price_currency: PRICE_CURRENCY, + pay_currency: PAY_CURRENCY, + is_fixed_rate: IS_FIXED_RATE, + success_url: PAYMENT_SUCCESS_URL, + is_fee_paid_by_user: IS_FEE_PAID_BY_USER, + ipn_callback_url: IPN_CALLBACK_URL, + order_description: "Token purchase for " + address, + }) + + const requestOptions = { + method: "POST", + headers: myHeaders, + body: raw, + redirect: "follow" as RequestRedirect, + } + + const response = await fetch( + API_CREATE_INVOICE_ENDPOINT, + requestOptions, + ).then(response => response.json()) + + if (!response.invoice_url) { + throw new Error("Invoice URL not found in response") + } + + return response + } + + return useMutation(createInvoice, { + onError: (error: unknown) => { + const err = error instanceof Error ? error : new Error("Unknown error") + displayError("Failed to create invoice", err) + }, + onSuccess: data => { + window.location.href = data.invoice_url + }, + }) +} diff --git a/hooks/payment/get-payment-status.ts b/hooks/payment/get-payment-status.ts new file mode 100644 index 0000000..e407344 --- /dev/null +++ b/hooks/payment/get-payment-status.ts @@ -0,0 +1,34 @@ +// Check the NOWPayments API status +import { useDisplayError } from "../../helpers/toast" +import { getPaymentStatus } from "../../config/nowpayment" +import { useQuery } from "react-query" +import { + isPaymentStatusResponse, + PaymentStatusResponse, +} from "../../types/payment/types" + +export const useGetPaymentStatus = (id: string) => { + const displayError = useDisplayError() + const paymentStatus = async (id: string): Promise => { + const response = await getPaymentStatus(id) + if (!response.ok) { + throw new Error(`Failed to get payment status: ${response.statusText}`) + } + + const data = await response.json() + if (!isPaymentStatusResponse(data)) { + throw new Error("Invalid payment status response") + } + return data + } + + return useQuery({ + queryKey: ["paymentStatus", id], + queryFn: () => paymentStatus(id), + onError: error => { + const err = error instanceof Error ? error : new Error("Unknown error") + displayError("Failed to get status", err) + }, + enabled: !!id, + }) +} diff --git a/hooks/payment/handle-payment.ts b/hooks/payment/handle-payment.ts new file mode 100644 index 0000000..b3552ce --- /dev/null +++ b/hooks/payment/handle-payment.ts @@ -0,0 +1,25 @@ +import { useCheckApiStatus } from "./check-api-status" +import { useCreateInvoice } from "./create-invoice" +import { useDisplayError } from "../../helpers/toast" + +// Handle the payment process +export default function useHandlePayment() { + const { isSuccess: apiStatusOk } = useCheckApiStatus() + const { mutate: createInvoice } = useCreateInvoice() + const displayError = useDisplayError() + + const handlePayment = async () => { + if (!apiStatusOk) { + return + } + + try { + createInvoice() + } catch (error) { + const err = error instanceof Error ? error : new Error("Unknown error") + displayError("Failed to create invoice", err) + } + } + + return { handlePayment } +} diff --git a/hooks/payment/verify-token-transfer.ts b/hooks/payment/verify-token-transfer.ts new file mode 100644 index 0000000..9487383 --- /dev/null +++ b/hooks/payment/verify-token-transfer.ts @@ -0,0 +1,30 @@ +import { useDisplayError } from "../../helpers/toast" +import { useQuery } from "react-query" +import { isTokenTransferSuccessful } from "../../lib/ghostcloud" + +export const useVerifyTokenTransfer = ( + sender: string, + recipient: string, + invoiceId: string, + paymentId: string, + purchaseId: string, +) => { + const displayError = useDisplayError() + + return useQuery({ + queryKey: ["tokenTransferStatus"], + queryFn: () => + isTokenTransferSuccessful( + sender, + recipient, + invoiceId, + paymentId, + purchaseId, + ), + onError: error => { + const err = error instanceof Error ? error : new Error("Unknown error") + displayError("Failed to get status", err) + }, + enabled: !!paymentId, + }) +} diff --git a/hooks/useAuthHandlers.ts b/hooks/useAuthHandlers.ts deleted file mode 100644 index e307fd0..0000000 --- a/hooks/useAuthHandlers.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Handle the login and logout actions, and fetch the address from the provider - -import { useColorMode, useToast } from "@chakra-ui/react" -import { web3AuthInitProvider } from "../lib/web3-auth" -import { - GHOSTCLOUD_CHAIN_CONFIG, - GHOSTCLOUD_UI_CONFIG, -} from "../config/ghostcloud-chain" -import useWeb3AuthStore from "../store/web3-auth" -import { useRouter } from "next/router" - -export default function useAuthHandlers() { - const { colorMode } = useColorMode() // To pass the color mode to Web3Auth - const store = useWeb3AuthStore() // To access the provider - const toast = useToast() // To display errors - const router = useRouter() // To redirect on logout - - // Handle the login to Web3Auth. - // Redirect to the dashboard if the user is already connected. - const handleLogin = async () => { - if (!store.isConnected()) { - const uiConfig = { - ...GHOSTCLOUD_UI_CONFIG, - mode: colorMode, - } - try { - await web3AuthInitProvider(GHOSTCLOUD_CHAIN_CONFIG, uiConfig) - } catch (error) { - toast({ - title: "An error occurred while trying to login", - description: (error as Error).message, - status: "error", - duration: 5000, - isClosable: true, - position: "top", - }) - } - } - await router.push("/dashboard") - } - - // Handle the logout from Web3Auth. - // Redirect to the homepage after logout. - const handleLogout = async () => { - store.logout() - await router.push("/") // Return to the homepage - } - - return { handleLogin, handleLogout } -} diff --git a/jest.setup.js b/jest.setup.js index 401f198..21185cb 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,2 +1,52 @@ // Learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom" +import { useGetPaymentStatus } from "./hooks/payment/get-payment-status" + +process.env.NEXT_PUBLIC_GHOSTCLOUD_BANK_ACCOUNT_ADDRESS = "mock_address" + +jest.mock("next/router", () => require("next-router-mock")) + +jest.mock("./store/web3-auth", () => ({ + __esModule: true, // this property makes it work as a module + default: jest.fn(() => ({ + provider: null, + web3auth: null, + setProvider: jest.fn(), + setWeb3Auth: jest.fn(), + isConnected: jest.fn(), + getPrivateKey: jest.fn(), + getAddress: jest.fn(), + logout: jest.fn(), + // ... any other methods or properties + })), +})) +jest.mock("react-query", () => ({ + useQuery: jest.fn(), + useQueryClient: jest.fn(), + useMutation: jest.fn(), +})) + +jest.mock("./hooks/payment/get-payment-status", () => ({ + useGetPaymentStatus: () => ({ + data: null, + }), +})) + +jest.mock("./hooks/payment/handle-payment", () => + jest.fn().mockReturnValue({ + handlePayment: jest.fn(), + }), +) + +export const handleLogin = jest.fn() +jest.mock("./hooks/auth/handle-login", () => ({ + useHandleLogin: () => ({ + mutate: handleLogin, + }), +})) +export const handleLogout = jest.fn() +jest.mock("./hooks/auth/handle-logout", () => ({ + useHandleLogout: () => ({ + mutate: handleLogout, + }), +})) diff --git a/lib/ghostcloud.ts b/lib/ghostcloud.ts index 9628f39..40fc29e 100644 --- a/lib/ghostcloud.ts +++ b/lib/ghostcloud.ts @@ -2,6 +2,7 @@ import { cosmos, cosmosAminoConverters, cosmosProtoRegistry, + getSigningCosmosClient, ghostcloud, ghostcloudAminoConverters, ghostcloudProtoRegistry, @@ -13,30 +14,28 @@ import { GHOSTCLOUD_GAS_PRICE, GHOSTCLOUD_RPC_TARGET, } from "../config/ghostcloud-chain" -import useWeb3AuthStore from "../store/web3-auth" import { + DirectSecp256k1HdWallet, DirectSecp256k1Wallet, - OfflineDirectSigner, GeneratedType, + OfflineDirectSigner, Registry, } from "@cosmjs/proto-signing" import { DeploymentData } from "../components/create-deployment" -import { fileToArrayBuffer } from "../helpers/files" -import { - useMutation, - useQuery, - useQueryClient, - UseQueryResult, -} from "react-query" -import { QueryMetasResponse } from "@liftedinit/gcjs/dist/codegen/ghostcloud/ghostcloud/query" -import { useDisplayError } from "../helpers/errors" import { AminoTypes, calculateFee, - Coin, SigningStargateClient, } from "@cosmjs/stargate" import { hexToBytes } from "@metamask/utils" +import { GetTxsEventRequest } from "cosmjs-types/cosmos/tx/v1beta1/service" +import { + createCreateDeploymentMsg, + createRemoveDeploymentMsg, + createSendMsg, + createUpdateDeploymentMsg, +} from "./message_composer" +import _ from "lodash" async function createSigner(pk: Uint8Array) { const getSignerFromKey = async (): Promise => { @@ -75,283 +74,190 @@ async function createGhostcloudRpcClient(pk: Uint8Array) { return await createStargateSigningClient(pk) } -// Create a deployment message from the given deployment data -async function createDeploymentMsg(data: DeploymentData, creator: string) { - let payload - if (data.file) { - const buffer = await fileToArrayBuffer(data.file) - payload = ghostcloud.ghostcloud.Payload.fromPartial({ - archive: ghostcloud.ghostcloud.Archive.fromPartial({ - type: ghostcloud.ghostcloud.ArchiveType.Zip, - content: buffer, - }), - }) +export const createDeployment = async ( + data: DeploymentData, + creator: string, + pk: string, +) => { + const client = await createGhostcloudRpcClient(hexToBytes(pk)) + const msg = await createCreateDeploymentMsg(data, creator) + const gasEstimation = await client.simulate(creator, [msg], "") + const fee = calculateFee( + Math.round(gasEstimation * GHOSTCLOUD_GAS_LIMIT_MULTIPLIER), + GHOSTCLOUD_GAS_PRICE, + ) + const response = await client.signAndBroadcast(creator, [msg], fee, data.memo) + if (response.code) { + throw new Error( + `Deployment creation failed with error code: ${response.code}. Raw log: ${response.rawLog}`, + ) } - const { createDeployment } = ghostcloud.ghostcloud.MessageComposer.withTypeUrl - return createDeployment({ - meta: { - creator, - name: data.name, - description: data.description, - domain: data.domain, - }, - payload, - }) + return response } -// Send a deployment creation transaction to the Ghostcloud RPC endpoint -export const useCreateDeployment = () => { - const store = useWeb3AuthStore() - const queryClient = useQueryClient() - const create = async (data: DeploymentData | null) => { - if (!data) { - throw new Error("Deployment data is empty.") - } - - const creator = await store.getAddress() - if (!creator) { - throw new Error("Creator address is empty.") - } +export const updateDeployment = async ( + data: DeploymentData, + creator: string, + pk: string, +) => { + const client = await createGhostcloudRpcClient(hexToBytes(pk)) + const msg = await createUpdateDeploymentMsg(data, creator) + const gasEstimation = await client.simulate(creator, [msg], "") + const fee = calculateFee( + Math.round(gasEstimation * GHOSTCLOUD_GAS_LIMIT_MULTIPLIER), + GHOSTCLOUD_GAS_PRICE, + ) + const response = await client.signAndBroadcast(creator, [msg], fee, data.memo) - const client = await createGhostcloudRpcClient( - hexToBytes(await store.getPrivateKey()), - ) - const msg = await createDeploymentMsg(data, creator) - const gasEstimation = await client.simulate(creator, [msg], "") - const fee = calculateFee( - Math.round(gasEstimation * GHOSTCLOUD_GAS_LIMIT_MULTIPLIER), - GHOSTCLOUD_GAS_PRICE, + if (response.code) { + throw new Error( + `Deployment update failed with error code: ${response.code}. Raw log: ${response.rawLog}`, ) - const response = await client.signAndBroadcast( - creator, - [msg], - fee, - data.memo, - ) - if (response.code) { - throw new Error( - `Deployment creation failed with error code: ${response.code}. Raw log: ${response.rawLog}`, - ) - } - - return response } - return useMutation({ - mutationFn: create, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: "metas" }) - queryClient.invalidateQueries({ queryKey: "balance" }) - }, - }) + return response } +export const removeDeployment = async ( + name: string, + creator: string, + pk: string, +) => { + const client = await createGhostcloudRpcClient(hexToBytes(pk)) + const msg = createRemoveDeploymentMsg(name, creator) + const gasEstimation = await client.simulate(creator, [msg], "") + const fee = calculateFee( + Math.round(gasEstimation * GHOSTCLOUD_GAS_LIMIT_MULTIPLIER), + GHOSTCLOUD_GAS_PRICE, + ) + const response = await client.signAndBroadcast(creator, [msg], fee) -async function updateDeploymentMsg(data: DeploymentData, creator: string) { - let payload - if (data.file) { - const buffer = await fileToArrayBuffer(data.file) - payload = ghostcloud.ghostcloud.Payload.fromPartial({ - archive: ghostcloud.ghostcloud.Archive.fromPartial({ - type: ghostcloud.ghostcloud.ArchiveType.Zip, - content: buffer, - }), - }) + if (response.code) { + throw new Error( + `Deployment update failed with error code: ${response.code}. Raw log: ${response.rawLog}`, + ) } - const { updateDeployment } = ghostcloud.ghostcloud.MessageComposer.withTypeUrl - return updateDeployment({ - meta: { - creator, - name: data.name, - description: data.description, - domain: data.domain, - }, - payload, - }) + return response } -export const useUpdateDeployment = () => { - const store = useWeb3AuthStore() - const queryClient = useQueryClient() - const update = async (data: DeploymentData | null) => { - if (!data) { - throw new Error("Deployment data is empty.") - } - - const creator = await store.getAddress() - if (!creator) { - throw new Error("Creator address is empty.") - } - - const client = await createGhostcloudRpcClient( - hexToBytes(await store.getPrivateKey()), - ) - const msg = await updateDeploymentMsg(data, creator) - const gasEstimation = await client.simulate(creator, [msg], "") - const fee = calculateFee( - Math.round(gasEstimation * GHOSTCLOUD_GAS_LIMIT_MULTIPLIER), - GHOSTCLOUD_GAS_PRICE, - ) - const response = await client.signAndBroadcast( - creator, - [msg], - fee, - data.memo, - ) - - if (response.code) { - throw new Error( - `Deployment update failed with error code: ${response.code}. Raw log: ${response.rawLog}`, - ) - } - - return response - } - - return useMutation({ - mutationFn: update, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: "metas" }) - queryClient.invalidateQueries({ queryKey: "balance" }) - }, +export const listDeployments = async (address: string) => { + const { createRPCQueryClient } = ghostcloud.ClientFactory + const client = await createRPCQueryClient({ + rpcEndpoint: GHOSTCLOUD_RPC_TARGET, }) -} -async function removeDeploymentMsg(name: string, creator: string) { - const { removeDeployment } = ghostcloud.ghostcloud.MessageComposer.withTypeUrl - return removeDeployment({ - creator, - name: name, + const filter = ghostcloud.ghostcloud.Filter.fromPartial({ + field: ghostcloud.ghostcloud.Filter_Field.CREATOR, + operator: ghostcloud.ghostcloud.Filter_Operator.EQUAL, + value: address, }) + const request = ghostcloud.ghostcloud.QueryMetasRequest.fromPartial({ + filters: [filter], + }) + return await client.ghostcloud.ghostcloud.metas(request) } -export const useRemoveDeployment = () => { - const store = useWeb3AuthStore() - const queryClient = useQueryClient() - const remove = async (name: string) => { - const creator = await store.getAddress() - if (!creator) { - throw new Error("Creator address is empty.") - } - - const client = await createGhostcloudRpcClient( - hexToBytes(await store.getPrivateKey()), - ) - const msg = await removeDeploymentMsg(name, creator) - const gasEstimation = await client.simulate(creator, [msg], "") - const fee = calculateFee( - Math.round(gasEstimation * GHOSTCLOUD_GAS_LIMIT_MULTIPLIER), - GHOSTCLOUD_GAS_PRICE, - ) - const response = await client.signAndBroadcast(creator, [msg], fee) +export const fetchBalance = async (address: string) => { + const { createRPCQueryClient } = cosmos.ClientFactory + const client = await createRPCQueryClient({ + rpcEndpoint: GHOSTCLOUD_RPC_TARGET, + }) - if (response.code) { - throw new Error( - `Deployment update failed with error code: ${response.code}. Raw log: ${response.rawLog}`, - ) - } + // Replace this with the actual method to fetch the balance + const request = cosmos.bank.v1beta1.QueryBalanceRequest.fromPartial({ + address: address, + denom: GHOSTCLOUD_DENOM, + }) + const response = await client.cosmos.bank.v1beta1.balance(request) - return response + if (response.balance) { + return response.balance + } else { + throw new Error("Failed to fetch balance") } - - return useMutation({ - mutationFn: remove, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: "metas" }) - queryClient.invalidateQueries({ queryKey: "balance" }) - }, - }) } -// Query the Ghostcloud RPC endpoint for deployments created by the current user -export const useFetchMetas = (): UseQueryResult => { - const store = useWeb3AuthStore() - const displayError = useDisplayError() - - const list = async () => { - const address = await store.getAddress() - if (address) { - const { createRPCQueryClient } = ghostcloud.ClientFactory - const client = await createRPCQueryClient({ - rpcEndpoint: GHOSTCLOUD_RPC_TARGET, - }) - - const filter = ghostcloud.ghostcloud.Filter.fromPartial({ - field: ghostcloud.ghostcloud.Filter_Field.CREATOR, - operator: ghostcloud.ghostcloud.Filter_Operator.EQUAL, - value: address, - }) - const request = ghostcloud.ghostcloud.QueryMetasRequest.fromPartial({ - filters: [filter], - }) - return await client.ghostcloud.ghostcloud.metas(request) - } - } +export async function fetchTransferEvents(sender: string, recipient: string) { + const { createRPCQueryClient } = cosmos.ClientFactory + const client = await createRPCQueryClient({ + rpcEndpoint: GHOSTCLOUD_RPC_TARGET, + }) - return useQuery({ - queryKey: "metas", - queryFn: list, - onError: error => { - displayError("Failed to fetch deployments", error) - }, + // WARNING: At some point, it will get too slow to search the entire blockchain for transactions + // TODO: I don't know why but I can't mix `events type` in the same query. + // E.g., + // ["transfer.sender='gc1664mxf4257456y3aqfvu75tgqh7kzv9ygzwwuf'", `transfer.recipient='${address}'`] // WORKS + // ["message.sender='gc1664mxf4257456y3aqfvu75tgqh7kzv9ygzwwuf'", `transfer.recipient='${address}'`] // DOESN'T WORKS + // ["message.sender='gc1664mxf4257456y3aqfvu75tgqh7kzv9ygzwwuf'", "message.action='/cosmos.bank.v1beta1.MsgSend'"] // WORKS + const query = GetTxsEventRequest.fromPartial({ + events: [ + `transfer.sender='${sender}'`, + `transfer.recipient='${recipient}'`, + ], }) + return await client.cosmos.tx.v1beta1.getTxsEvent(query) } -export const useFetchBalance = (): UseQueryResult => { - const store = useWeb3AuthStore() - const displayError = useDisplayError() - - const fetchBalance = async () => { - const address = await store.getAddress() - if (address) { - const { createRPCQueryClient } = cosmos.ClientFactory - const client = await createRPCQueryClient({ - rpcEndpoint: GHOSTCLOUD_RPC_TARGET, - }) - - // Replace this with the actual method to fetch the balance - const request = cosmos.bank.v1beta1.QueryBalanceRequest.fromPartial({ - address: address, - denom: GHOSTCLOUD_DENOM, - }) - const response = await client.cosmos.bank.v1beta1.balance(request) +export async function isTokenTransferSuccessful( + sender: string, + recipient: string, + invoiceId: number, + paymentId: number, + purchaseId: number, +) { + const data = await fetchTransferEvents(sender, recipient) + // TODO: We should probably have an internal tracking system instead of relying on NOWPayment to provide the IDs. + const regex = /invoice_id: (\d+), payment_id: (\d+), purchase_id: (\d+)/ + + // Try to find the payment in the blockchain + for (const [tx, txResponse] of _.zip(data.txs, data.txResponses)) { + const memo = tx?.body?.memo + if (!memo) { + continue + } + const matches = RegExp(regex).exec(memo) + if (!matches) { + continue + } - if (response.balance) { - return response.balance - } else { - throw new Error("Failed to fetch balance") - } + if ( + Number(matches[1]) === invoiceId && + Number(matches[2]) === paymentId && + Number(matches[3]) === purchaseId + ) { + console.log("Found payment in blockchain!!!") + return { tx, txResponse } } } - return useQuery({ - queryKey: "balance", - queryFn: fetchBalance, - onError: error => { - displayError("Failed to fetch balance", error) - }, - }) + console.log("Payment not found in blockchain") + return undefined } -export const useFetchAddress = (): UseQueryResult => { - const store = useWeb3AuthStore() - const displayError = useDisplayError() - - const fetchAddress = async () => { - const address = await store.getAddress() - if (address) { - return address - } else { - throw new Error("Failed to fetch address") - } - } - - return useQuery({ - queryKey: "address", - queryFn: fetchAddress, - onError: error => { - displayError("Failed to fetch address", error) - }, +// TODO: Refactor this function to not use mnemonic +export async function sendTokens( + to: string, + mnemonic: string, + amount: number, + invoiceId: number, + paymentId: number, + purchaseId: number, +) { + const signer = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { + prefix: GHOSTCLOUD_ADDRESS_PREFIX, }) + const signerAddress = (await signer.getAccounts())[0].address + const client = await getSigningCosmosClient({ + rpcEndpoint: GHOSTCLOUD_RPC_TARGET, + signer: signer, + }) + const msg = createSendMsg(signerAddress, to, amount.toString()) + const gasEstimation = await client.simulate(signerAddress, [msg], "") + const fee = calculateFee( + Math.round(gasEstimation * GHOSTCLOUD_GAS_LIMIT_MULTIPLIER), + GHOSTCLOUD_GAS_PRICE, + ) + const memo = `invoice_id: ${invoiceId}, payment_id: ${paymentId}, purchase_id: ${purchaseId}` + return await client.signAndBroadcast(signerAddress, [msg], fee, memo) } diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..869879e --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,21 @@ +import winston from "winston" + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL ?? "info", + format: winston.format.json(), + defaultMeta: { service: "ghostcloud-cosmos-fe" }, + transports: [ + new winston.transports.File({ filename: "error.log", level: "error" }), + new winston.transports.File({ filename: "combined.log" }), + ], +}) + +if (process.env.NODE_ENV !== "production") { + logger.add( + new winston.transports.Console({ + format: winston.format.simple(), + }), + ) +} + +export default logger diff --git a/lib/message_composer.ts b/lib/message_composer.ts new file mode 100644 index 0000000..c62f3b9 --- /dev/null +++ b/lib/message_composer.ts @@ -0,0 +1,73 @@ +import { cosmos, ghostcloud } from "@liftedinit/gcjs" +import { GHOSTCLOUD_DENOM } from "../config/ghostcloud-chain" +import { DeploymentData } from "../components/create-deployment" +import { fileToArrayBuffer } from "../helpers/files" + +async function createPayload(file: File | null) { + if (!file) { + return + } + const buffer = await fileToArrayBuffer(file) + return ghostcloud.ghostcloud.Payload.fromPartial({ + archive: ghostcloud.ghostcloud.Archive.fromPartial({ + type: ghostcloud.ghostcloud.ArchiveType.Zip, + content: buffer, + }), + }) +} + +export async function createCreateDeploymentMsg( + data: DeploymentData, + creator: string, +) { + const payload = await createPayload(data.file) + const { createDeployment } = ghostcloud.ghostcloud.MessageComposer.withTypeUrl + return createDeployment({ + meta: { + creator, + name: data.name, + description: data.description, + domain: data.domain, + }, + payload, + }) +} + +export async function createUpdateDeploymentMsg( + data: DeploymentData, + creator: string, +) { + const payload = await createPayload(data.file) + const { updateDeployment } = ghostcloud.ghostcloud.MessageComposer.withTypeUrl + return updateDeployment({ + meta: { + creator, + name: data.name, + description: data.description, + domain: data.domain, + }, + payload, + }) +} + +export function createRemoveDeploymentMsg(name: string, creator: string) { + const { removeDeployment } = ghostcloud.ghostcloud.MessageComposer.withTypeUrl + return removeDeployment({ + creator, + name: name, + }) +} + +export function createSendMsg(from: string, to: string, amount: string) { + const { send } = cosmos.bank.v1beta1.MessageComposer.withTypeUrl + return send({ + fromAddress: from, + toAddress: to, + amount: [ + { + denom: GHOSTCLOUD_DENOM, + amount: amount, + }, + ], + }) +} diff --git a/package.json b/package.json index 61d9c5a..2a7e370 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "chain-registry": "1.20.0", "formik": "^2.4.5", "framer-motion": "10.16.14", + "lodash": "^4.17.21", "next": "14.0.3", "react": "18.2.0", "react-dom": "18.2.0", @@ -42,6 +43,7 @@ "react-icons": "4.12.0", "react-query": "^3.39.3", "sharp": "^0.33.0", + "winston": "^3.11.0", "yup": "^1.3.2", "zustand": "^4.4.7" }, @@ -56,6 +58,7 @@ "eslint-config-next": "14.0.3", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "next-router-mock": "^0.9.11", "prettier": "^3.1.0", "typescript": "5.3.2" } diff --git a/pages/api/payment.tsx b/pages/api/payment.tsx new file mode 100644 index 0000000..47a3ef5 --- /dev/null +++ b/pages/api/payment.tsx @@ -0,0 +1,341 @@ +import crypto from "crypto" +import logger from "../../lib/logger" +import type { NextApiRequest, NextApiResponse } from "next" +import { + fetchBalance, + isTokenTransferSuccessful, + sendTokens, +} from "../../lib/ghostcloud" +import { IncomingHttpHeaders } from "node:http" +import { PaymentStatus } from "../../types/payment/types" +import { getBankAccountAddress } from "../../config/ghostcloud-chain" + +const INVALID_BODY = "Invalid request body" +const INVALID_HEADER = "Invalid request header" +const INVALID_METHOD = "Invalid method. Only POST allowed" +const INVALID_KEY = "Invalid notifications key" +const SIGNATURE_MISMATCH = "Signature missmatch" +const INVALID_SIGNATURE_LENGTH = "Invalid signature length" +const INVALID_SIGNATURE_CONTENT = + "Invalid signature content. Expected hexadecimal characters" +const INVALID_PAYMENT_STATUS = "Invalid payment status" +const INVALID_SIGNATURE_TYPE = "Invalid signature type, expected string" +const INVALID_ORDER_DESCRIPTION_FORMAT = "Invalid order description format" + +const SIGNATURE_KEY = "x-nowpayments-sig" +const PAYMENT_STATUS_KEY = "payment_status" +const INVOICE_ID_KEY = "invoice_id" +const PAYMENT_ID_KEY = "payment_id" +const PURCHASE_ID_KEY = "purchase_id" +const ORDER_DESCRIPTION_KEY = "order_description" + +type ResponseData = { + message: string +} + +type Header = IncomingHttpHeaders + +interface Body { + [PAYMENT_STATUS_KEY]: keyof typeof PaymentStatus + [INVOICE_ID_KEY]: string + [PAYMENT_ID_KEY]: string + [PURCHASE_ID_KEY]: string + [ORDER_DESCRIPTION_KEY]: string +} + +// Sort an object recursively in alphabetical order by key. +function sortObject(obj: Record): Record { + return Object.keys(obj) + .sort() + .reduce((result: Record, key: string) => { + result[key] = + obj[key] && typeof obj[key] === "object" + ? sortObject(obj[key]) + : obj[key] + return result + }, {}) +} + +// Check if the given fields are present AND non-null in the given object. +function isValid>( + data: any, + fields: (keyof T)[], +): data is T { + return fields.every(field => field in data && data[field] !== null) +} + +// Validate the request header and body. +// Throws an error if the request is invalid. +function validateRequest(header: Header, body: Body) { + logger.debug("Validating header keys") + if (!isValid(header, [SIGNATURE_KEY])) { + throw new Error(INVALID_HEADER) + } + + logger.debug("Validating body keys") + if ( + !isValid(body, [ + PAYMENT_STATUS_KEY, + INVOICE_ID_KEY, + PAYMENT_ID_KEY, + PURCHASE_ID_KEY, + ORDER_DESCRIPTION_KEY, + ]) + ) { + throw new Error(INVALID_BODY) + } + + // The signature is a HMAC-SHA512 hash of the sorted JSON body. + logger.debug("Validating signature") + validateSignature(header, body) +} + +// Validate the signature of the request. +// Throws an error if the signature is invalid. +function validateSignature(header: Header, body: Body) { + logger.debug("Sanitizing header signature") + const signature = sanitizeHeaderSignature(header[SIGNATURE_KEY]) + logger.debug("Signature is sanitized") + + logger.debug("Retrieving notifications key from environment") + const notificationsKey = process.env.IPN_SECRET_KEY + if (!notificationsKey) { + throw new Error(INVALID_KEY) + } + logger.debug("Notifications key retrieved") + + // Create a new HMAC object and update it with the sorted JSON string. + logger.debug("Calculating signature") + const hmac = crypto.createHmac("sha512", notificationsKey) + hmac.update(JSON.stringify(sortObject(body))) + const bodySignature = hmac.digest("hex") + + // Compare the signature from the request header with the signature we calculated. + if ( + !crypto.timingSafeEqual(Buffer.from(bodySignature), Buffer.from(signature)) + ) { + throw new Error(SIGNATURE_MISMATCH) + } + logger.debug("Signature is valid") +} + +// Sanitize the signature from the request header. +// Throws an error if the signature is invalid. +function sanitizeHeaderSignature(signature: any): string { + logger.debug("Checking signature type") + if (typeof signature !== "string") { + throw new Error(INVALID_SIGNATURE_TYPE) + } + logger.debug("Signature type is valid") + + // Check that the signature is 128 characters long. + logger.debug("Checking signature length") + if (signature.length !== 128) { + throw new Error(INVALID_SIGNATURE_LENGTH) + } + logger.debug("Signature length is valid") + + // Check that the signature only contains hexadecimal characters. + logger.debug("Checking signature content") + if (!/^[0-9a-fA-F]+$/.test(signature)) { + throw new Error(INVALID_SIGNATURE_CONTENT) + } + logger.debug("Signature content is valid") + + return signature +} + +// Sanitize the payment status from the request body. +// Throws an error if the payment status is invalid. +function sanitizePaymentStatus( + paymentStatus: keyof typeof PaymentStatus, +): PaymentStatus { + logger.debug("Checking payment status") + if (!(paymentStatus in PaymentStatus)) { + throw new Error(INVALID_PAYMENT_STATUS) + } + logger.debug("Payment status is valid") + + return PaymentStatus[paymentStatus] +} + +async function verifyTokenTransferAlreadyProcessed( + address: string, + invoiceId: number, + paymentId: number, + purchaseId: number, +) { + if ( + await isTokenTransferSuccessful( + getBankAccountAddress(), + address, + invoiceId, + paymentId, + purchaseId, + ) + ) { + throw new Error("Payment already exists") + } +} + +function getBankAccountKey() { + // TODO: Get the private key from a Vault instance + const key = process.env.BANK_ACCOUNT_KEY + if (!key) { + throw new Error("Bank account key is not set") + } + return key +} + +async function performTokenTransfer( + address: string, + amount: number, + invoiceId: number, + paymentId: number, + purchaseId: number, +) { + logger.debug("Sending tokens") + logger.debug(`Address: ${address}`) + logger.debug(`Amount: ${amount}`) + logger.debug(`Invoice ID: ${invoiceId}`) + logger.debug(`Payment ID: ${paymentId}`) + logger.debug(`Purchase ID: ${purchaseId}`) + + // TODO: Don't use mnemonic, use a private key instead + const mnemonic = getBankAccountKey() + const res = await sendTokens( + address, + mnemonic, + amount, + invoiceId, + paymentId, + purchaseId, + ) + logger.debug("Tokens sent") + return res +} + +function getTransferAmount() { + const amount = process.env.TRANSFER_AMOUNT + if (!amount) { + throw new Error("Transfer amount is not set") + } + const amountNumber = Number(amount) + if (isNaN(amountNumber)) { + throw new Error("Transfer amount is not a number") + } + return amountNumber +} + +function getTransferAmountGasBuffer() { + const amount = process.env.TRANSFER_AMOUNT_GAS_BUFFER + if (!amount) { + throw new Error("Transfer amount gas buffer is not set") + } + const amountNumber = Number(amount) + if (isNaN(amountNumber)) { + throw new Error("Transfer amount gas buffer is not a number") + } + return amountNumber +} + +async function checkBankAccountBalance() { + const balance = await fetchBalance(getBankAccountAddress()) + const amount = Number(balance.amount) + if (isNaN(amount)) { + throw new Error("Bank account balance is invalid") + } + if (amount + getTransferAmountGasBuffer() < getTransferAmount()) { + throw new Error("Bank account has insufficient funds") + } +} + +// Process the payment status. +// Throws an error if the payment status is invalid. +async function processPayment( + status: PaymentStatus, + invoiceId: number, + paymentId: number, + purchaseId: number, + orderDescription: string, +) { + logger.info(`Processing payment status: ${status}`) + + // We only implement the case where the payment has finished. + if (status === PaymentStatus.finished) { + const match = RegExp(/^Token purchase for (.+)$/).exec(orderDescription) + if (!match) { + throw new Error(INVALID_ORDER_DESCRIPTION_FORMAT) + } + logger.debug("Order description is valid") + logger.debug("Verifying payment on chain") + await verifyTokenTransferAlreadyProcessed( + match[1], + invoiceId, + paymentId, + purchaseId, + ) + logger.debug("Token transfer not processed yet") + logger.debug("Checking bank account balance") + await checkBankAccountBalance() + logger.debug("Bank account has sufficient funds") + + logger.debug("Transferring tokens to user wallet") + const response = await performTokenTransfer( + match[1], + getTransferAmount(), + invoiceId, + paymentId, + purchaseId, + ) + if (!response) { + throw new Error("Could not transfer tokens") + } + logger.debug("Tokens transferred") + logger.debug(`Token transfer height: ${response.height}`) + logger.debug(`Token transfer transaction hash: ${response.transactionHash}`) + } else { + logger.debug(`Payment status handling for ${status} is unimplemented`) + } +} + +function validateMethod(method: string | undefined) { + logger.debug("Validating method") + if (method && method !== "POST") { + throw new Error(INVALID_METHOD) + } + logger.debug("Method is valid") +} + +// TODO: implement rate-limiting to this endpoint +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + validateMethod(req.method) + + const header = req.headers + const body = req.body + + try { + logger.debug("Validating request") + validateRequest(header, body) + logger.debug("Request is valid") + // At this point we know that the signed request is valid, and we can process it. + logger.debug("Request is valid, processing payment") + await processPayment( + sanitizePaymentStatus(body[PAYMENT_STATUS_KEY]), + body[INVOICE_ID_KEY], + body[PAYMENT_ID_KEY], + body[PURCHASE_ID_KEY], + body[ORDER_DESCRIPTION_KEY], + ) + logger.debug("Payment processed") + } catch (e) { + logger.error((e as Error).message) + res.status(400).json({ message: (e as Error).message }) + return + } + + res.status(200).json({ message: "OK" }) +} diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index a826b0f..5c74cf3 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -1,12 +1,70 @@ import useWeb3AuthStore from "../store/web3-auth" import DashboardComponent from "../components/dashboard" import { Box, Container, Grid, GridItem } from "@chakra-ui/react" +import { useEffect } from "react" +import { useRouter } from "next/router" +import { useGetPaymentStatus } from "../hooks/payment/get-payment-status" +import { useDisplayError, useDisplaySuccess } from "../helpers/toast" +import { getMsgFromPaymentStatus } from "../helpers/payment" +import { PaymentStatus, PaymentStatusResponse } from "../types/payment/types" +import { isTokenTransferSuccessful } from "../lib/ghostcloud" +import { GHOSTCLOUD_BANK_ACCOUNT_ADDRESS } from "../config/ghostcloud-chain" +import { useHandleLogin } from "../hooks/auth/handle-login" + +const getHashFromPaymentStatus = async ( + paymentStatus: PaymentStatusResponse, + address: string, +) => { + if (paymentStatus.payment_status !== PaymentStatus.finished) return "" + + const response = await isTokenTransferSuccessful( + GHOSTCLOUD_BANK_ACCOUNT_ADDRESS, + address, + paymentStatus.invoice_id, + paymentStatus.payment_id, + paymentStatus.purchase_id, + ) + + return response?.txResponse?.txhash ?? "ERROR" +} export default function Dashboard() { - const store = useWeb3AuthStore() - const isConnected = store.isConnected() + const router = useRouter() + const NP_id = router.query.NP_id?.toString() ?? "" + const { isConnected, getAddress } = useWeb3AuthStore() + const { data: paymentStatus } = useGetPaymentStatus(NP_id) + const { mutate: handleLogin } = useHandleLogin() + const displaySuccess = useDisplaySuccess() + const displayError = useDisplayError() + + // TODO: Better way to handle this to re-connect to the session + useEffect(() => { + if (!isConnected()) handleLogin() + }, [handleLogin, isConnected]) + + // Display the payment status, if any + useEffect(() => { + const process = async () => { + if (!paymentStatus) return + const address = await getAddress() + if (!address) { + displayError("Error during submission", new Error("No address found")) + return + } + + const hash = await getHashFromPaymentStatus(paymentStatus, address) + const { success, msg } = getMsgFromPaymentStatus(paymentStatus, hash) + success + ? displaySuccess(`Payment Id ${paymentStatus.payment_id}`, msg) + : displayError( + `Payment Status ${paymentStatus.payment_status}`, + new Error(msg?.toString()), + ) + } + process() + }, [paymentStatus]) - return isConnected ? ( + return isConnected() ? ( diff --git a/pages/index.tsx b/pages/index.tsx index 79a19a8..fee22b3 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -18,7 +18,7 @@ import heroDark from "../public/hero-dark.png" import heroLight from "../public/hero-light.png" import React from "react" import { Feature } from "../components" -import useAuthHandlers from "../hooks/useAuthHandlers" +import { useHandleLogin } from "../hooks/auth/handle-login" export default function Home() { const bgColor = useColorModeValue( @@ -31,7 +31,7 @@ export default function Home() { ) const logo = useColorModeValue(logoLight, logoDark) const hero = useColorModeValue(heroLight, heroDark) - const { handleLogin } = useAuthHandlers() + const { mutate: handleLogin } = useHandleLogin() return ( <> @@ -66,7 +66,7 @@ export default function Home() { era of web technology effortlessly. - + diff --git a/types/payment/types.ts b/types/payment/types.ts new file mode 100644 index 0000000..22b3aae --- /dev/null +++ b/types/payment/types.ts @@ -0,0 +1,53 @@ +export enum PaymentStatus { + waiting = "waiting", + confirming = "confirming", + confirmed = "confirmed", + sending = "sending", + partially_paid = "partially_paid", + finished = "finished", + failed = "failed", + refunded = "refunded", + expired = "expired", +} + +export interface PaymentStatusResponse { + payment_id: number + payment_status: string + pay_address: string + price_amount: number + price_currency: string + pay_amount: number + actually_paid: number + pay_currency: string + order_id: string + order_description: string + purchase_id: number + created_at: string + updated_at: string + outcome_amount: number + outcome_currency: string + invoice_id: number +} + +export function isPaymentStatusResponse(data: any) { + return ( + data && + typeof data === "object" && + "payment_id" in data && + "payment_status" in data && + data.payment_status in PaymentStatus && + "pay_address" in data && + "price_amount" in data && + "price_currency" in data && + "pay_amount" in data && + "actually_paid" in data && + "pay_currency" in data && + "order_id" in data && + "order_description" in data && + "purchase_id" in data && + "created_at" in data && + "updated_at" in data && + "outcome_amount" in data && + "outcome_currency" in data + ) +} diff --git a/yarn.lock b/yarn.lock index 6c7f62c..3ef81b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1134,6 +1134,11 @@ resolved "https://registry.yarnpkg.com/@chakra-ui/visually-hidden/-/visually-hidden-2.2.0.tgz#9b0ecef8f01263ab808ba3bda7b36a0d91b4d5c1" integrity sha512-KmKDg01SrQ7VbTD3+cPWf/UfpF5MSwm3v7MWi0n5t8HnnadT13MF0MJCDSXbBWnzLv1ZKJ6zlyAOeARWX+DpjQ== +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + "@confio/ics23@^0.6.8": version "0.6.8" resolved "https://registry.yarnpkg.com/@confio/ics23/-/ics23-0.6.8.tgz#2a6b4f1f2b7b20a35d9a0745bb5a446e72930b3d" @@ -1316,6 +1321,15 @@ dependencies: axios "1.6.0" +"@dabh/diagnostics@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" + integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + "@emnapi/runtime@^0.44.0": version "0.44.0" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-0.44.0.tgz#1ef702f846cfcd559d28eb7673919087ba5b63e3" @@ -4032,6 +4046,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + "@types/ws@^7.4.4": version "7.4.7" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" @@ -4791,6 +4810,11 @@ async-mutex@^0.4.0: dependencies: tslib "^2.4.0" +async@^3.2.3: + version "3.2.5" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66" + integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg== + asynciterator.prototype@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz#8c5df0514936cdd133604dfcc9d3fb93f09b2b62" @@ -5241,7 +5265,7 @@ collect-v8-coverage@^1.0.0: resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== -color-convert@^1.9.0: +color-convert@^1.9.0, color-convert@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -5265,7 +5289,7 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.9.0: +color-string@^1.6.0, color-string@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== @@ -5278,6 +5302,14 @@ color2k@^2.0.2: resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.3.tgz#a771244f6b6285541c82aa65ff0a0c624046e533" integrity sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog== +color@^3.1.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" + integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== + dependencies: + color-convert "^1.9.3" + color-string "^1.6.0" + color@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" @@ -5286,6 +5318,14 @@ color@^4.2.3: color-convert "^2.0.1" color-string "^1.9.0" +colorspace@1.1.x: + version "1.1.4" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" + integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== + dependencies: + color "^3.1.3" + text-hex "1.0.x" + combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -5673,6 +5713,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + end-of-stream@^1.1.0, end-of-stream@^1.4.1, end-of-stream@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -6198,6 +6243,11 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -6264,6 +6314,11 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + focus-lock@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-1.0.0.tgz#2c50d8ce59d3d6608cda2672be9e65812459206c" @@ -7623,6 +7678,11 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + language-subtag-registry@^0.3.20: version "0.3.22" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d" @@ -7749,6 +7809,18 @@ lodash@^4.17.15, lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +logform@^2.3.2, logform@^2.4.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.6.0.tgz#8c82a983f05d6eaeb2d75e3decae7a768b2bf9b5" + integrity sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ== + dependencies: + "@colors/colors" "1.6.0" + "@types/triple-beam" "^1.3.2" + fecha "^4.2.0" + ms "^2.1.1" + safe-stable-stringify "^2.3.1" + triple-beam "^1.3.0" + loglevel@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4" @@ -7953,6 +8025,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +next-router-mock@^0.9.11: + version "0.9.11" + resolved "https://registry.yarnpkg.com/next-router-mock/-/next-router-mock-0.9.11.tgz#f4d4c0fa3249d9091d96d1fe6de494469a5e6e50" + integrity sha512-3w7DdXDtjsBbPvHk3kBtmzyvn5BwRh133JM4G3gdjqt8zBzs08dvdU6EEPMZIiQv3mgxZwp7nFKGYEmUnMV/rQ== + next@14.0.3: version "14.0.3" resolved "https://registry.yarnpkg.com/next/-/next-14.0.3.tgz#8d801a08eaefe5974203d71092fccc463103a03f" @@ -8139,6 +8216,13 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" @@ -8674,7 +8758,7 @@ react@18.2.0: dependencies: loose-envify "^1.1.0" -readable-stream@^3.1.1: +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -8873,7 +8957,7 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" -safe-stable-stringify@^2.1.0: +safe-stable-stringify@^2.1.0, safe-stable-stringify@^2.3.1: version "2.4.3" resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== @@ -9063,6 +9147,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -9284,6 +9373,11 @@ text-encoding-utf-8@^1.0.2: resolved "https://registry.yarnpkg.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz#585b62197b0ae437e3c7b5d0af27ac1021e10d13" integrity sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg== +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -9365,6 +9459,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + ts-api-utils@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331" @@ -9775,6 +9874,32 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +winston-transport@^4.5.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.6.0.tgz#f1c1a665ad1b366df72199e27892721832a19e1b" + integrity sha512-wbBA9PbPAHxKiygo7ub7BYRiKxms0tpfU2ljtWzb3SjRjv5yl6Ozuy/TkXf00HTAt+Uylo3gSkNwzc4ME0wiIg== + dependencies: + logform "^2.3.2" + readable-stream "^3.6.0" + triple-beam "^1.3.0" + +winston@^3.11.0: + version "3.11.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.11.0.tgz#2d50b0a695a2758bb1c95279f0a88e858163ed91" + integrity sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.2" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.4.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.5.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"