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 ? (
<>
+
-
+
>
) : (
-
+
)}
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"