From cb2180213af311ff84cda46da3caecc50e45d132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= Date: Tue, 30 Sep 2025 17:13:48 +0200 Subject: [PATCH 1/6] feat: can manually create a token refs #1946 --- .../CreateCertificateButton.tsx | 55 +++++-- .../JwtProvider/JwtProviderContext.tsx | 141 ++++++++++++++++++ .../src/context/JwtProvider/index.ts | 2 + apps/deploy-web/src/pages/_app.tsx | 13 +- .../services/analytics/analytics.service.ts | 1 + .../src/utils/TransactionMessageData.ts | 13 ++ 6 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx create mode 100644 apps/deploy-web/src/context/JwtProvider/index.ts diff --git a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx index 3d0d44849..7d08b4a30 100644 --- a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx +++ b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx @@ -5,6 +5,7 @@ import { Alert, Button, Spinner } from "@akashnetwork/ui/components"; import { cn } from "@akashnetwork/ui/utils"; import { useCertificate } from "@src/context/CertificateProvider"; +import { useJwt } from "@src/context/JwtProvider/JwtProviderContext"; import { useSettings } from "@src/context/SettingsProvider"; export const DEPENDENCIES = { @@ -12,6 +13,7 @@ export const DEPENDENCIES = { Button, Spinner, useCertificate, + useJwt, useSettings }; @@ -24,31 +26,60 @@ export interface Props extends Omit { export const CreateCertificateButton: FC = ({ afterCreate, containerClassName, dependencies: d = DEPENDENCIES, ...buttonProps }) => { const { settings } = d.useSettings(); const { isCreatingCert, createCertificate, isLocalCertExpired, localCert } = d.useCertificate(); + const { isCreatingToken, createToken, isLocalTokenExpired, localToken } = d.useJwt(); const _createCertificate = useCallback(async () => { await createCertificate(); afterCreate?.(); }, [createCertificate, afterCreate]); + const _createToken = useCallback(async () => { + await createToken(); + afterCreate?.(); + }, [createToken, afterCreate]); + const isInCertMode = false; const warningText = useMemo(() => { - if (isLocalCertExpired) return "Your certificate has expired. Please create a new one."; - if (!localCert) return "You need to create a certificate to view deployment details."; + if (isInCertMode) { + if (isLocalCertExpired) return "Your certificate has expired. Please create a new one."; + if (!localCert) return "You need to create a certificate to view deployment details."; + } + + if (isLocalTokenExpired) return "Your token has expired. Please create a new one."; + if (!localToken) return "You need to create a token to view deployment details."; + return undefined; - }, [isLocalCertExpired, isCreatingCert, localCert]); - const buttonText = useMemo(() => (isLocalCertExpired ? "Regenerate Certificate" : "Create Certificate"), [isLocalCertExpired]); + }, [isLocalCertExpired, isLocalTokenExpired, localCert, localToken]); + const buttonText = useMemo(() => { + if (isInCertMode) { + return isLocalCertExpired ? "Regenerate Certificate" : "Create Certificate"; + } + + return isLocalTokenExpired ? "Regenerate Token" : "Create Token"; + }, [isLocalCertExpired, isLocalTokenExpired]); return (
{warningText} - - {isCreatingCert ? : buttonText} - + {isInCertMode ? ( + + {isCreatingCert ? : buttonText} + + ) : ( + + {isCreatingToken ? : buttonText} + + )}
); }; diff --git a/apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx b/apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx new file mode 100644 index 000000000..90d756487 --- /dev/null +++ b/apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx @@ -0,0 +1,141 @@ +"use client"; +import React, { useEffect, useMemo, useState } from "react"; +import { JwtToken, type JwtTokenPayload } from "@akashnetwork/jwt"; +import { useSnackbar } from "notistack"; + +import { useLocalStorage } from "@src/hooks/useLocalStorage"; +import { TransactionMessageData } from "@src/utils/TransactionMessageData"; +import { useSelectedChain } from "../CustomChainProvider"; +import { useServices } from "../ServicesProvider"; +import { useSettings } from "../SettingsProvider"; +import { useWallet } from "../WalletProvider"; + +export type LocalToken = { + token: string; + address: string; +}; + +export type ContextType = { + localToken: LocalToken | null; + isLocalTokenExpired: boolean; + setLocalToken: React.Dispatch; + createToken: () => Promise; + isCreatingToken: boolean; +}; + +const JwtProviderContext = React.createContext({} as ContextType); + +export const DEPENDENCIES = { + useSettings, + useWallet, + useSnackbar, + useServices, + useLocalStorage +}; + +type Props = { + children: React.ReactNode; + dependencies?: typeof DEPENDENCIES; +}; + +export const JwtProvider: React.FC = ({ children, dependencies: d = DEPENDENCIES }) => { + const { getAccount, signArbitrary } = useSelectedChain(); + const { analyticsService } = d.useServices(); + + const [isCreatingToken, setIsCreatingToken] = useState(false); + const [localToken, setLocalToken] = useState(null); + const { address, signAndBroadcastTx } = d.useWallet(); + const { isSettingsInit } = d.useSettings(); + const { setLocalStorageItem, removeLocalStorageItem, getLocalStorageItem } = d.useLocalStorage(); + + useEffect(() => { + if (!isSettingsInit) return; + + setLocalToken(null); + removeLocalStorageItem("jwt"); + + if (address) { + loadLocalToken(address); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, isSettingsInit]); + + const parsedLocalToken = useMemo(() => { + if (!localToken) return null; + const jwtToken = new JwtToken({} as any); + return jwtToken.decodeToken(localToken.token); + }, [localToken]); + + const loadLocalToken = async (address: string) => { + const token = getLocalStorageItem(`jwt::${address}`); + if (token) { + setLocalToken({ token, address }); + return; + } + }; + + async function createToken() { + setIsCreatingToken(true); + + const { pubkey } = await getAccount(); + + const jwtToken = new JwtToken({ + signArbitrary, + address, + pubkey + }); + + const token = await jwtToken.createToken({ + version: "v1", + iss: "https://example.com", + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000) + }); + + try { + const message = TransactionMessageData.getCreateJwtMsg(address, token); + const response = await signAndBroadcastTx([message]); + if (response) { + setLocalStorageItem(`jwt::${address}`, token); + loadLocalToken(address); + + analyticsService.track("create_jwt", { + category: "certificates", + label: "Created jwt" + }); + } + + setIsCreatingToken(false); + } catch (error) { + setIsCreatingToken(false); + + throw error; + } + } + + return ( + + {children} + + ); +}; + +export const useJwt = (): ContextType => { + return { ...React.useContext(JwtProviderContext) }; +}; + +function isExpired(parsedLocalToken: JwtTokenPayload) { + return parsedLocalToken.exp < Date.now() / 1000; +} diff --git a/apps/deploy-web/src/context/JwtProvider/index.ts b/apps/deploy-web/src/context/JwtProvider/index.ts new file mode 100644 index 000000000..a7bbe833b --- /dev/null +++ b/apps/deploy-web/src/context/JwtProvider/index.ts @@ -0,0 +1,2 @@ +export { useJwt, JwtProvider, DEPENDENCIES } from "./JwtProviderContext"; +export type { LocalToken } from "./JwtProviderContext"; diff --git a/apps/deploy-web/src/pages/_app.tsx b/apps/deploy-web/src/pages/_app.tsx index a2374be78..33d659bd4 100644 --- a/apps/deploy-web/src/pages/_app.tsx +++ b/apps/deploy-web/src/pages/_app.tsx @@ -30,6 +30,7 @@ import { ChainParamProvider } from "@src/context/ChainParamProvider"; import { CustomChainProvider } from "@src/context/CustomChainProvider"; import { ColorModeProvider } from "@src/context/CustomThemeContext"; import { FlagProvider } from "@src/context/FlagProvider/FlagProvider"; +import { JwtProvider } from "@src/context/JwtProvider/JwtProviderContext"; import { LocalNoteProvider } from "@src/context/LocalNoteProvider"; import { PaymentPollingProvider } from "@src/context/PaymentPollingProvider"; import { PricingProvider } from "@src/context/PricingProvider/PricingProvider"; @@ -80,11 +81,13 @@ const App: React.FunctionComponent = props => { - - - - - + + + + + + + diff --git a/apps/deploy-web/src/services/analytics/analytics.service.ts b/apps/deploy-web/src/services/analytics/analytics.service.ts index 9ae21c6ca..7f8159495 100644 --- a/apps/deploy-web/src/services/analytics/analytics.service.ts +++ b/apps/deploy-web/src/services/analytics/analytics.service.ts @@ -37,6 +37,7 @@ export type AnalyticsEvent = | "create_certificate" | "regenerate_certificate" | "export_certificate" + | "create_jwt" | "deployment_deposit" | "close_deployment" | "use_depositor" diff --git a/apps/deploy-web/src/utils/TransactionMessageData.ts b/apps/deploy-web/src/utils/TransactionMessageData.ts index 07e3018f8..d4cac4e9a 100644 --- a/apps/deploy-web/src/utils/TransactionMessageData.ts +++ b/apps/deploy-web/src/utils/TransactionMessageData.ts @@ -31,6 +31,7 @@ export class TransactionMessageData { MSG_CREATE_LEASE: "", MSG_REVOKE_CERTIFICATE: "", MSG_CREATE_CERTIFICATE: "", + MSG_CREATE_JWT: "", MSG_UPDATE_PROVIDER: "", // Cosmos @@ -68,6 +69,18 @@ export class TransactionMessageData { return message; } + static getCreateJwtMsg(address: string, token: string) { + const message = { + typeUrl: TransactionMessageData.Types.MSG_CREATE_JWT, + value: { + owner: address, + token: Buffer.from(token).toString("base64") + } + }; + + return message; + } + static getCreateLeaseMsg(bid: BidDto) { const message = { typeUrl: TransactionMessageData.Types.MSG_CREATE_LEASE, From fdef7fa6fd9870ba1934c0ac3b36e5c30179c045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= Date: Wed, 1 Oct 2025 17:30:33 +0200 Subject: [PATCH 2/6] fix: use a hook instead of a context and provider refs #1946 --- .../CreateCertificateButton.spec.tsx | 60 ++- .../CreateCertificateButton.tsx | 46 +-- .../JwtProvider/JwtProviderContext.tsx | 141 ------- .../src/context/JwtProvider/index.ts | 2 - .../SettingsProviderContext.tsx | 2 +- apps/deploy-web/src/hooks/useJwt.spec.tsx | 369 ++++++++++++++++++ apps/deploy-web/src/hooks/useJwt.ts | 131 +++++++ apps/deploy-web/src/pages/_app.tsx | 13 +- apps/deploy-web/src/types/feature-flags.ts | 3 +- apps/deploy-web/src/utils/walletUtils.ts | 1 + 10 files changed, 592 insertions(+), 176 deletions(-) delete mode 100644 apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx delete mode 100644 apps/deploy-web/src/context/JwtProvider/index.ts create mode 100644 apps/deploy-web/src/hooks/useJwt.spec.tsx create mode 100644 apps/deploy-web/src/hooks/useJwt.ts diff --git a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx index ea32aa5ae..c2ce05242 100644 --- a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx +++ b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.spec.tsx @@ -1,4 +1,5 @@ import type { LocalCert } from "@src/context/CertificateProvider"; +import type { LocalToken } from "@src/hooks/useJwt"; import type { Props as CreateCertificateButtonProps } from "./CreateCertificateButton"; import { CreateCertificateButton, DEPENDENCIES as CREATE_CERTIFICATE_BUTTON_DEPENDENCIES } from "./CreateCertificateButton"; @@ -11,6 +12,12 @@ describe(CreateCertificateButton.name, () => { expect(screen.queryByRole("alert")).toHaveTextContent(/You need to create a certificate to view deployment details./); }); + it("renders create token button", () => { + setup({ isJwtEnabled: true }); + expect(screen.getByRole("button", { name: /create token/i })).toBeInTheDocument(); + expect(screen.queryByRole("alert")).toHaveTextContent(/You need to create a token to view deployment details./); + }); + it("calls createCertificate when clicked", async () => { const createCertificate = jest.fn(async () => {}); setup({ createCertificate }); @@ -21,6 +28,16 @@ describe(CreateCertificateButton.name, () => { expect(createCertificate).toHaveBeenCalled(); }); + it("calls createToken when clicked", async () => { + const createToken = jest.fn(async () => {}); + setup({ createToken, isJwtEnabled: true }); + + const button = screen.getByRole("button", { name: /create token/i }); + fireEvent.click(button); + + expect(createToken).toHaveBeenCalled(); + }); + it("displays warning text if certificate is expired", () => { setup({ localCert: { @@ -33,16 +50,43 @@ describe(CreateCertificateButton.name, () => { expect(screen.queryByRole("alert")).toHaveTextContent(/Your certificate has expired. Please create a new one./); }); + it("displays warning text if token is expired", () => { + setup({ + localToken: { + token: "expired", + address: "akash1234567890" + }, + isLocalTokenExpired: true, + isJwtEnabled: true + }); + expect(screen.queryByRole("alert")).toHaveTextContent(/Your token has expired. Please create a new one./); + }); + function setup( input?: CreateCertificateButtonProps & { createCertificate?: () => Promise; + createToken?: () => Promise; isCreatingCert?: boolean; isLocalCertExpired?: boolean; + isLocalTokenExpired?: boolean; localCert?: LocalCert; + localToken?: LocalToken; isBlockchainDown?: boolean; + isJwtEnabled?: boolean; } ) { - const { createCertificate, isCreatingCert, isLocalCertExpired, localCert, isBlockchainDown, ...props } = input ?? {}; + const { + createCertificate, + createToken, + isCreatingCert, + isLocalCertExpired, + isLocalTokenExpired, + localCert, + localToken, + isBlockchainDown, + isJwtEnabled, + ...props + } = input ?? {}; return render( { isSettingsInit: true, refreshNodeStatuses: jest.fn(), isRefreshingNodeStatus: false + }), + useJwt: () => ({ + isCreatingToken: false, + createToken: createToken ?? jest.fn(() => Promise.resolve()), + isLocalTokenExpired: isLocalTokenExpired ?? false, + localToken: localToken ?? null, + setLocalToken: jest.fn() + }), + useFlag: jest.fn().mockImplementation(flag => { + if (flag === "jwt_instead_of_cert") { + return isJwtEnabled ?? false; + } + + return false; }) }} /> diff --git a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx index 7d08b4a30..d7351e68b 100644 --- a/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx +++ b/apps/deploy-web/src/components/deployments/CreateCertificateButton/CreateCertificateButton.tsx @@ -5,8 +5,9 @@ import { Alert, Button, Spinner } from "@akashnetwork/ui/components"; import { cn } from "@akashnetwork/ui/utils"; import { useCertificate } from "@src/context/CertificateProvider"; -import { useJwt } from "@src/context/JwtProvider/JwtProviderContext"; import { useSettings } from "@src/context/SettingsProvider"; +import { useFlag } from "@src/hooks/useFlag"; +import { useJwt } from "@src/hooks/useJwt"; export const DEPENDENCIES = { Alert, @@ -14,7 +15,8 @@ export const DEPENDENCIES = { Spinner, useCertificate, useJwt, - useSettings + useSettings, + useFlag }; export interface Props extends Omit { @@ -24,6 +26,7 @@ export interface Props extends Omit { } export const CreateCertificateButton: FC = ({ afterCreate, containerClassName, dependencies: d = DEPENDENCIES, ...buttonProps }) => { + const isJwtEnabled = d.useFlag("jwt_instead_of_cert"); const { settings } = d.useSettings(); const { isCreatingCert, createCertificate, isLocalCertExpired, localCert } = d.useCertificate(); const { isCreatingToken, createToken, isLocalTokenExpired, localToken } = d.useJwt(); @@ -36,48 +39,47 @@ export const CreateCertificateButton: FC = ({ afterCreate, containerClass await createToken(); afterCreate?.(); }, [createToken, afterCreate]); - const isInCertMode = false; const warningText = useMemo(() => { - if (isInCertMode) { - if (isLocalCertExpired) return "Your certificate has expired. Please create a new one."; - if (!localCert) return "You need to create a certificate to view deployment details."; + if (isJwtEnabled) { + if (isLocalTokenExpired) return "Your token has expired. Please create a new one."; + if (!localToken) return "You need to create a token to view deployment details."; } - if (isLocalTokenExpired) return "Your token has expired. Please create a new one."; - if (!localToken) return "You need to create a token to view deployment details."; + if (isLocalCertExpired) return "Your certificate has expired. Please create a new one."; + if (!localCert) return "You need to create a certificate to view deployment details."; return undefined; - }, [isLocalCertExpired, isLocalTokenExpired, localCert, localToken]); + }, [isJwtEnabled, isLocalCertExpired, isLocalTokenExpired, localCert, localToken]); const buttonText = useMemo(() => { - if (isInCertMode) { - return isLocalCertExpired ? "Regenerate Certificate" : "Create Certificate"; + if (isJwtEnabled) { + return isLocalTokenExpired ? "Regenerate Token" : "Create Token"; } - return isLocalTokenExpired ? "Regenerate Token" : "Create Token"; - }, [isLocalCertExpired, isLocalTokenExpired]); + return isLocalCertExpired ? "Regenerate Certificate" : "Create Certificate"; + }, [isJwtEnabled, isLocalCertExpired, isLocalTokenExpired]); return (
{warningText} - {isInCertMode ? ( + {isJwtEnabled ? ( - {isCreatingCert ? : buttonText} + {isCreatingToken ? : buttonText} ) : ( - {isCreatingToken ? : buttonText} + {isCreatingCert ? : buttonText} )}
diff --git a/apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx b/apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx deleted file mode 100644 index 90d756487..000000000 --- a/apps/deploy-web/src/context/JwtProvider/JwtProviderContext.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; -import React, { useEffect, useMemo, useState } from "react"; -import { JwtToken, type JwtTokenPayload } from "@akashnetwork/jwt"; -import { useSnackbar } from "notistack"; - -import { useLocalStorage } from "@src/hooks/useLocalStorage"; -import { TransactionMessageData } from "@src/utils/TransactionMessageData"; -import { useSelectedChain } from "../CustomChainProvider"; -import { useServices } from "../ServicesProvider"; -import { useSettings } from "../SettingsProvider"; -import { useWallet } from "../WalletProvider"; - -export type LocalToken = { - token: string; - address: string; -}; - -export type ContextType = { - localToken: LocalToken | null; - isLocalTokenExpired: boolean; - setLocalToken: React.Dispatch; - createToken: () => Promise; - isCreatingToken: boolean; -}; - -const JwtProviderContext = React.createContext({} as ContextType); - -export const DEPENDENCIES = { - useSettings, - useWallet, - useSnackbar, - useServices, - useLocalStorage -}; - -type Props = { - children: React.ReactNode; - dependencies?: typeof DEPENDENCIES; -}; - -export const JwtProvider: React.FC = ({ children, dependencies: d = DEPENDENCIES }) => { - const { getAccount, signArbitrary } = useSelectedChain(); - const { analyticsService } = d.useServices(); - - const [isCreatingToken, setIsCreatingToken] = useState(false); - const [localToken, setLocalToken] = useState(null); - const { address, signAndBroadcastTx } = d.useWallet(); - const { isSettingsInit } = d.useSettings(); - const { setLocalStorageItem, removeLocalStorageItem, getLocalStorageItem } = d.useLocalStorage(); - - useEffect(() => { - if (!isSettingsInit) return; - - setLocalToken(null); - removeLocalStorageItem("jwt"); - - if (address) { - loadLocalToken(address); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [address, isSettingsInit]); - - const parsedLocalToken = useMemo(() => { - if (!localToken) return null; - const jwtToken = new JwtToken({} as any); - return jwtToken.decodeToken(localToken.token); - }, [localToken]); - - const loadLocalToken = async (address: string) => { - const token = getLocalStorageItem(`jwt::${address}`); - if (token) { - setLocalToken({ token, address }); - return; - } - }; - - async function createToken() { - setIsCreatingToken(true); - - const { pubkey } = await getAccount(); - - const jwtToken = new JwtToken({ - signArbitrary, - address, - pubkey - }); - - const token = await jwtToken.createToken({ - version: "v1", - iss: "https://example.com", - exp: Math.floor(Date.now() / 1000) + 3600, - iat: Math.floor(Date.now() / 1000) - }); - - try { - const message = TransactionMessageData.getCreateJwtMsg(address, token); - const response = await signAndBroadcastTx([message]); - if (response) { - setLocalStorageItem(`jwt::${address}`, token); - loadLocalToken(address); - - analyticsService.track("create_jwt", { - category: "certificates", - label: "Created jwt" - }); - } - - setIsCreatingToken(false); - } catch (error) { - setIsCreatingToken(false); - - throw error; - } - } - - return ( - - {children} - - ); -}; - -export const useJwt = (): ContextType => { - return { ...React.useContext(JwtProviderContext) }; -}; - -function isExpired(parsedLocalToken: JwtTokenPayload) { - return parsedLocalToken.exp < Date.now() / 1000; -} diff --git a/apps/deploy-web/src/context/JwtProvider/index.ts b/apps/deploy-web/src/context/JwtProvider/index.ts deleted file mode 100644 index a7bbe833b..000000000 --- a/apps/deploy-web/src/context/JwtProvider/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useJwt, JwtProvider, DEPENDENCIES } from "./JwtProviderContext"; -export type { LocalToken } from "./JwtProviderContext"; diff --git a/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx b/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx index 5f33341e1..d70c64c9b 100644 --- a/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx +++ b/apps/deploy-web/src/context/SettingsProvider/SettingsProviderContext.tsx @@ -29,7 +29,7 @@ export type Settings = { isBlockchainDown: boolean; }; -type ContextType = { +export type ContextType = { settings: Settings; setSettings: React.Dispatch>; isLoadingSettings: boolean; diff --git a/apps/deploy-web/src/hooks/useJwt.spec.tsx b/apps/deploy-web/src/hooks/useJwt.spec.tsx new file mode 100644 index 000000000..826645213 --- /dev/null +++ b/apps/deploy-web/src/hooks/useJwt.spec.tsx @@ -0,0 +1,369 @@ +import type { ChainContext } from "@cosmos-kit/core"; +import { mock } from "jest-mock-extended"; + +import type { AppDIContainer } from "@src/context/ServicesProvider"; +import type { ContextType as SettingsProviderContextType } from "@src/context/SettingsProvider/SettingsProviderContext"; +import type { ContextType as WalletProviderContextType } from "@src/context/WalletProvider/WalletProvider"; +import type { LocalWallet } from "@src/utils/walletUtils"; +import { useJwt } from "./useJwt"; + +import { act, renderHook, waitFor } from "@testing-library/react"; + +describe(useJwt.name, () => { + it("should return initial state when no address is provided", () => { + const { result } = setup({ + address: "", + isSettingsInit: true, + wallets: [] + }); + + expect(result.current.localToken).toBeNull(); + expect(result.current.isLocalTokenExpired).toBe(false); + expect(result.current.isCreatingToken).toBe(false); + expect(typeof result.current.createToken).toBe("function"); + expect(typeof result.current.setLocalToken).toBe("function"); + }); + + it("should return initial state when settings are not initialized", () => { + const { result } = setup({ + address: "akash123", + isSettingsInit: false, + wallets: [] + }); + + expect(result.current.localToken).toBeNull(); + expect(result.current.isLocalTokenExpired).toBe(false); + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should load local token when address exists and settings are initialized", async () => { + const mockToken = "mock-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) + 3600, // Not expired + iat: Math.floor(Date.now() / 1000), + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toEqual({ + token: mockToken, + address + }); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + it("should not load token when no matching wallet is found", async () => { + const address = "akash123"; + const wallets = [{ address: "different-address", token: "some-token" }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + it("should handle wallet without token", async () => { + const address = "akash123"; + const wallets = [{ address, token: undefined }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + }); + + it("should return null for localToken when token is expired", async () => { + const mockToken = "mock-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) - 3600, // Expired + iat: Math.floor(Date.now() / 1000) - 7200, + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + + expect(result.current.isLocalTokenExpired).toBe(true); + }); + + it("should return token when not expired", async () => { + const mockToken = "mock-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) + 3600, // Not expired + iat: Math.floor(Date.now() / 1000), + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toEqual({ + token: mockToken, + address + }); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + it("should create token successfully", async () => { + const address = "akash123"; + const mockToken = "new-jwt-token"; + + const { result, mocks } = setup({ + address, + isSettingsInit: true, + wallets: [], + mockCreatedToken: mockToken + }); + + await act(async () => { + await result.current.createToken(); + }); + + expect(mocks.useSelectedChain.getAccount).toHaveBeenCalled(); + expect(mocks.signAndBroadcastTx).toHaveBeenCalled(); + expect(mocks.updateWallet).toHaveBeenCalledWith(address, expect.any(Function)); + expect(mocks.analyticsService.track).toHaveBeenCalledWith("create_jwt", { + category: "certificates", + label: "Created jwt" + }); + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should handle token creation error", async () => { + const address = "akash123"; + const error = new Error("Token creation failed"); + + const { result } = setup({ + address, + isSettingsInit: true, + wallets: [], + mockCreateTokenError: error + }); + + await expect(async () => { + await act(async () => { + await result.current.createToken(); + }); + }).rejects.toThrow("Token creation failed"); + + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should handle transaction broadcast failure", async () => { + const address = "akash123"; + const mockToken = "new-jwt-token"; + + const { result, mocks } = setup({ + address, + isSettingsInit: true, + wallets: [], + mockCreatedToken: mockToken, + mockSignAndBroadcastTxResponse: false + }); + + await act(async () => { + await result.current.createToken(); + }); + + expect(mocks.useSelectedChain.getAccount).toHaveBeenCalled(); + expect(mocks.signAndBroadcastTx).toHaveBeenCalled(); + expect(mocks.updateWallet).not.toHaveBeenCalled(); + expect(mocks.analyticsService.track).not.toHaveBeenCalled(); + expect(result.current.isCreatingToken).toBe(false); + }); + + it("should clear local token when setLocalToken is called with null", async () => { + const { result } = setup({ + address: "akash123", + isSettingsInit: true, + wallets: [] + }); + + await act(async () => { + result.current.setLocalToken(null); + }); + + expect(result.current.localToken).toBeNull(); + }); + + it("should handle multiple wallets and find correct one", async () => { + const address = "akash123"; + const mockToken = "correct-token"; + const wallets = [ + { address: "akash111", token: "wrong-token-1" }, + { address: "akash222", token: "wrong-token-2" }, + { address, token: mockToken }, + { address: "akash333", token: "wrong-token-3" } + ] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodedToken: { + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + iss: "https://example.com", + version: "v1" + } + }); + + await waitFor(() => { + expect(result.current.localToken).toEqual({ + token: mockToken, + address + }); + }); + }); + + it("should handle JWT token decoding error gracefully", async () => { + const mockToken = "invalid-jwt-token"; + const address = "akash123"; + const wallets = [{ address, token: mockToken }] as LocalWallet[]; + + const { result } = setup({ + address, + isSettingsInit: true, + wallets, + mockDecodeTokenError: new Error("Invalid JWT token") + }); + + await waitFor(() => { + expect(result.current.localToken).toBeNull(); + }); + + expect(result.current.isLocalTokenExpired).toBe(false); + }); + + function setup(input: { + address?: string; + isSettingsInit?: boolean; + wallets?: Array; + mockDecodedToken?: any; + mockCreatedToken?: string; + mockCreateTokenError?: Error; + mockSignAndBroadcastTxResponse?: any; + mockDecodeTokenError?: Error; + }) { + class MockJwtToken { + createToken() { + if (input.mockCreateTokenError) { + throw input.mockCreateTokenError; + } + + return input.mockCreatedToken ?? null; + } + + decodeToken() { + if (input.mockDecodeTokenError) { + throw input.mockDecodeTokenError; + } + + return input.mockDecodedToken ?? null; + } + } + + const mockUseSelectedChain = mock({ + getAccount: jest.fn().mockResolvedValue({ pubkey: "mock-pubkey" }), + signArbitrary: jest.fn().mockResolvedValue("mock-signature") + }); + + const mockAnalyticsService = mock(); + const mockUseServices = mock({ + analyticsService: mockAnalyticsService + }); + + const mockSignAndBroadcastTx = jest.fn().mockResolvedValue(input.mockSignAndBroadcastTxResponse ?? { success: true }); + const mockUseWallet = mock({ + address: input.address ?? "", + signAndBroadcastTx: mockSignAndBroadcastTx + }); + + const mockUseSettings = mock({ + settings: { + apiEndpoint: "https://api.example.com", + rpcEndpoint: "https://rpc.example.com", + isCustomNode: false, + nodes: [], + selectedNode: null, + customNode: null, + isBlockchainDown: false + } + }); + + const mockGetStorageWallets = jest.fn().mockReturnValue(input.wallets ?? []); + + const mockUpdateWallet = jest.fn().mockImplementation((address, updater) => { + const wallet = { address, token: null }; + return updater(wallet); + }); + + const { result, rerender } = renderHook(() => + useJwt({ + dependencies: { + useSelectedChain: () => mockUseSelectedChain, + useServices: () => mockUseServices, + useWallet: () => mockUseWallet, + useSettings: () => mockUseSettings, + getStorageWallets: mockGetStorageWallets, + updateWallet: mockUpdateWallet, + JwtToken: MockJwtToken as any + } + }) + ); + + return { + result, + rerender, + mocks: { + useSelectedChain: mockUseSelectedChain, + signAndBroadcastTx: mockSignAndBroadcastTx, + analyticsService: mockAnalyticsService, + getStorageWallets: mockGetStorageWallets, + updateWallet: mockUpdateWallet + } + }; + } +}); diff --git a/apps/deploy-web/src/hooks/useJwt.ts b/apps/deploy-web/src/hooks/useJwt.ts new file mode 100644 index 000000000..faa165a26 --- /dev/null +++ b/apps/deploy-web/src/hooks/useJwt.ts @@ -0,0 +1,131 @@ +"use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { JwtToken, type JwtTokenPayload } from "@akashnetwork/jwt"; + +import { useSelectedChain } from "@src/context/CustomChainProvider"; +import { useServices } from "@src/context/ServicesProvider"; +import { useSettings } from "@src/context/SettingsProvider"; +import { useWallet } from "@src/context/WalletProvider"; +import { TransactionMessageData } from "@src/utils/TransactionMessageData"; +import { getStorageWallets, updateWallet } from "@src/utils/walletUtils"; + +export type LocalToken = { + token: string; + address: string; +}; + +const DEPENDENCIES = { + useSelectedChain, + useServices, + useWallet, + useSettings, + getStorageWallets, + updateWallet, + JwtToken +}; + +export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { + const { getAccount, signArbitrary } = d.useSelectedChain(); + const { analyticsService } = d.useServices(); + + const [isCreatingToken, setIsCreatingToken] = useState(false); + const [localToken, setLocalToken] = useState(null); + const { address, signAndBroadcastTx } = d.useWallet(); + const { isSettingsInit } = d.useSettings(); + + useEffect(() => { + if (!isSettingsInit) return; + + setLocalToken(null); + + if (address) { + loadLocalToken(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, isSettingsInit]); + + const parsedLocalToken = useMemo(() => { + if (!localToken) return null; + + try { + const jwtToken = new d.JwtToken({} as any); + return jwtToken.decodeToken(localToken.token); + } catch (error) { + return null; + } + }, [localToken]); + + const loadLocalToken = useCallback(async () => { + const wallets = d.getStorageWallets(); + wallets.find(wallet => { + const token: LocalToken | null = wallet.token ? { token: wallet.token, address: wallet.address } : null; + + if (wallet.address === address) { + setLocalToken(token); + return true; + } + }); + }, [address]); + + const createToken = useCallback(async () => { + setIsCreatingToken(true); + + const { pubkey } = await getAccount(); + + const jwtToken = new d.JwtToken({ + signArbitrary, + address, + pubkey + }); + + const token = await jwtToken.createToken({ + version: "v1", + iss: "https://example.com", + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000) + }); + + try { + const message = TransactionMessageData.getCreateJwtMsg(address, token); + const response = await signAndBroadcastTx([message]); + if (response) { + d.updateWallet(address, wallet => { + return { + ...wallet, + token + }; + }); + loadLocalToken(); + + analyticsService.track("create_jwt", { + category: "certificates", + label: "Created jwt" + }); + } + + setIsCreatingToken(false); + } catch (error) { + setIsCreatingToken(false); + + throw error; + } + }, [getAccount, signArbitrary, address, signAndBroadcastTx, d, loadLocalToken, analyticsService]); + + return useMemo(() => { + return { + get localToken() { + return !parsedLocalToken || isExpired(parsedLocalToken) ? null : localToken; + }, + get isLocalTokenExpired() { + return !!parsedLocalToken && isExpired(parsedLocalToken); + }, + setLocalToken, + createToken, + isCreatingToken + }; + }, [parsedLocalToken, localToken, setLocalToken, createToken, isCreatingToken]); +}; + +function isExpired(parsedLocalToken: JwtTokenPayload) { + return parsedLocalToken.exp < Date.now() / 1000; +} diff --git a/apps/deploy-web/src/pages/_app.tsx b/apps/deploy-web/src/pages/_app.tsx index 33d659bd4..a2374be78 100644 --- a/apps/deploy-web/src/pages/_app.tsx +++ b/apps/deploy-web/src/pages/_app.tsx @@ -30,7 +30,6 @@ import { ChainParamProvider } from "@src/context/ChainParamProvider"; import { CustomChainProvider } from "@src/context/CustomChainProvider"; import { ColorModeProvider } from "@src/context/CustomThemeContext"; import { FlagProvider } from "@src/context/FlagProvider/FlagProvider"; -import { JwtProvider } from "@src/context/JwtProvider/JwtProviderContext"; import { LocalNoteProvider } from "@src/context/LocalNoteProvider"; import { PaymentPollingProvider } from "@src/context/PaymentPollingProvider"; import { PricingProvider } from "@src/context/PricingProvider/PricingProvider"; @@ -81,13 +80,11 @@ const App: React.FunctionComponent = props => { - - - - - - - + + + + + diff --git a/apps/deploy-web/src/types/feature-flags.ts b/apps/deploy-web/src/types/feature-flags.ts index 1aca5dcb0..366269c75 100644 --- a/apps/deploy-web/src/types/feature-flags.ts +++ b/apps/deploy-web/src/types/feature-flags.ts @@ -6,4 +6,5 @@ export type FeatureFlag = | "billing_usage" | "custodial_auto_topup" | "ui_sdl_log_collector_enabled" - | "maintenance_banner"; + | "maintenance_banner" + | "jwt_instead_of_cert"; diff --git a/apps/deploy-web/src/utils/walletUtils.ts b/apps/deploy-web/src/utils/walletUtils.ts index f55f5be78..8226680a1 100644 --- a/apps/deploy-web/src/utils/walletUtils.ts +++ b/apps/deploy-web/src/utils/walletUtils.ts @@ -8,6 +8,7 @@ interface BaseLocalWallet { address: string; cert?: string; certKey?: string; + token?: string; selected: boolean; } From c8bb29e02fe2611e1298923c9b7369be49b38269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= Date: Tue, 7 Oct 2025 17:27:00 +0200 Subject: [PATCH 3/6] fix: use finally instead of a simple throwing catch refs #1946 --- apps/deploy-web/src/hooks/useJwt.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/deploy-web/src/hooks/useJwt.ts b/apps/deploy-web/src/hooks/useJwt.ts index faa165a26..7c73a21d4 100644 --- a/apps/deploy-web/src/hooks/useJwt.ts +++ b/apps/deploy-web/src/hooks/useJwt.ts @@ -102,12 +102,8 @@ export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { label: "Created jwt" }); } - - setIsCreatingToken(false); - } catch (error) { + } finally { setIsCreatingToken(false); - - throw error; } }, [getAccount, signArbitrary, address, signAndBroadcastTx, d, loadLocalToken, analyticsService]); From a540f29b31ffe7f2b8fd40312f8de8353be04b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= Date: Tue, 7 Oct 2025 17:27:59 +0200 Subject: [PATCH 4/6] fix: send address as iss refs #1946 --- apps/deploy-web/src/hooks/useJwt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/deploy-web/src/hooks/useJwt.ts b/apps/deploy-web/src/hooks/useJwt.ts index 7c73a21d4..4a5bbd87e 100644 --- a/apps/deploy-web/src/hooks/useJwt.ts +++ b/apps/deploy-web/src/hooks/useJwt.ts @@ -80,7 +80,7 @@ export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { const token = await jwtToken.createToken({ version: "v1", - iss: "https://example.com", + iss: address, exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000) }); From 799421ff693327236bae08d91b5b87ce92369849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= Date: Tue, 7 Oct 2025 17:28:27 +0200 Subject: [PATCH 5/6] fix: add missing message type refs #1946 --- apps/deploy-web/src/utils/TransactionMessageData.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/deploy-web/src/utils/TransactionMessageData.ts b/apps/deploy-web/src/utils/TransactionMessageData.ts index d4cac4e9a..b3461b2b3 100644 --- a/apps/deploy-web/src/utils/TransactionMessageData.ts +++ b/apps/deploy-web/src/utils/TransactionMessageData.ts @@ -14,6 +14,7 @@ export function setMessageTypes(config: AppConfig) { TransactionMessageData.Types.MSG_CREATE_LEASE = `/akash.market.${config.marketApiVersion}.MsgCreateLease`; TransactionMessageData.Types.MSG_REVOKE_CERTIFICATE = `/akash.cert.${config.networkApiVersion}.MsgRevokeCertificate`; TransactionMessageData.Types.MSG_CREATE_CERTIFICATE = `/akash.cert.${config.networkApiVersion}.MsgCreateCertificate`; + TransactionMessageData.Types.MSG_CREATE_JWT = `/akash.cert.${config.networkApiVersion}.MsgCreateJwt`; TransactionMessageData.Types.MSG_UPDATE_PROVIDER = `/akash.provider.${config.networkApiVersion}.MsgUpdateProvider`; } From d516089f91877fbbb8895d36fd40493eaf2d63e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3zsef=20Kozma?= Date: Wed, 8 Oct 2025 16:33:59 +0200 Subject: [PATCH 6/6] fix: token is offchain, no need to sign and broadcast refs #1946 --- apps/deploy-web/src/hooks/useJwt.ts | 37 ++++++++----------- .../src/utils/TransactionMessageData.ts | 14 ------- 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/apps/deploy-web/src/hooks/useJwt.ts b/apps/deploy-web/src/hooks/useJwt.ts index 4a5bbd87e..b92d0b174 100644 --- a/apps/deploy-web/src/hooks/useJwt.ts +++ b/apps/deploy-web/src/hooks/useJwt.ts @@ -6,7 +6,6 @@ import { useSelectedChain } from "@src/context/CustomChainProvider"; import { useServices } from "@src/context/ServicesProvider"; import { useSettings } from "@src/context/SettingsProvider"; import { useWallet } from "@src/context/WalletProvider"; -import { TransactionMessageData } from "@src/utils/TransactionMessageData"; import { getStorageWallets, updateWallet } from "@src/utils/walletUtils"; export type LocalToken = { @@ -30,7 +29,7 @@ export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { const [isCreatingToken, setIsCreatingToken] = useState(false); const [localToken, setLocalToken] = useState(null); - const { address, signAndBroadcastTx } = d.useWallet(); + const { address } = d.useWallet(); const { isSettingsInit } = d.useSettings(); useEffect(() => { @@ -53,7 +52,7 @@ export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { } catch (error) { return null; } - }, [localToken]); + }, [d.JwtToken, localToken]); const loadLocalToken = useCallback(async () => { const wallets = d.getStorageWallets(); @@ -65,7 +64,7 @@ export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { return true; } }); - }, [address]); + }, [address, d]); const createToken = useCallback(async () => { setIsCreatingToken(true); @@ -86,26 +85,22 @@ export const useJwt = ({ dependencies: d = DEPENDENCIES } = {}) => { }); try { - const message = TransactionMessageData.getCreateJwtMsg(address, token); - const response = await signAndBroadcastTx([message]); - if (response) { - d.updateWallet(address, wallet => { - return { - ...wallet, - token - }; - }); - loadLocalToken(); - - analyticsService.track("create_jwt", { - category: "certificates", - label: "Created jwt" - }); - } + d.updateWallet(address, wallet => { + return { + ...wallet, + token + }; + }); + loadLocalToken(); + + analyticsService.track("create_jwt", { + category: "certificates", + label: "Created jwt" + }); } finally { setIsCreatingToken(false); } - }, [getAccount, signArbitrary, address, signAndBroadcastTx, d, loadLocalToken, analyticsService]); + }, [getAccount, signArbitrary, address, d, loadLocalToken, analyticsService]); return useMemo(() => { return { diff --git a/apps/deploy-web/src/utils/TransactionMessageData.ts b/apps/deploy-web/src/utils/TransactionMessageData.ts index b3461b2b3..07e3018f8 100644 --- a/apps/deploy-web/src/utils/TransactionMessageData.ts +++ b/apps/deploy-web/src/utils/TransactionMessageData.ts @@ -14,7 +14,6 @@ export function setMessageTypes(config: AppConfig) { TransactionMessageData.Types.MSG_CREATE_LEASE = `/akash.market.${config.marketApiVersion}.MsgCreateLease`; TransactionMessageData.Types.MSG_REVOKE_CERTIFICATE = `/akash.cert.${config.networkApiVersion}.MsgRevokeCertificate`; TransactionMessageData.Types.MSG_CREATE_CERTIFICATE = `/akash.cert.${config.networkApiVersion}.MsgCreateCertificate`; - TransactionMessageData.Types.MSG_CREATE_JWT = `/akash.cert.${config.networkApiVersion}.MsgCreateJwt`; TransactionMessageData.Types.MSG_UPDATE_PROVIDER = `/akash.provider.${config.networkApiVersion}.MsgUpdateProvider`; } @@ -32,7 +31,6 @@ export class TransactionMessageData { MSG_CREATE_LEASE: "", MSG_REVOKE_CERTIFICATE: "", MSG_CREATE_CERTIFICATE: "", - MSG_CREATE_JWT: "", MSG_UPDATE_PROVIDER: "", // Cosmos @@ -70,18 +68,6 @@ export class TransactionMessageData { return message; } - static getCreateJwtMsg(address: string, token: string) { - const message = { - typeUrl: TransactionMessageData.Types.MSG_CREATE_JWT, - value: { - owner: address, - token: Buffer.from(token).toString("base64") - } - }; - - return message; - } - static getCreateLeaseMsg(bid: BidDto) { const message = { typeUrl: TransactionMessageData.Types.MSG_CREATE_LEASE,