From 328c48b84fd9955ea7eb3f3a6f9f6cc79a533eba Mon Sep 17 00:00:00 2001 From: Connor Prussin Date: Wed, 25 Sep 2024 01:13:02 -0700 Subject: [PATCH] feat(staking): add restricted mode for non-fatf geofenced countries --- apps/staking/src/app/restricted-mode/page.tsx | 1 + .../src/components/AccountSummary/index.tsx | 102 +++++++------ .../src/components/Dashboard/index.tsx | 140 ++++++++++-------- .../src/components/Governance/index.tsx | 9 +- .../Header/current-stake-account.tsx | 6 +- apps/staking/src/components/Home/index.tsx | 31 +++- .../OracleIntegrityStaking/index.tsx | 9 +- apps/staking/src/components/Root/index.tsx | 4 +- .../Root/restricted-region-banner.tsx | 24 +++ .../src/components/WalletButton/index.tsx | 8 +- apps/staking/src/config/isomorphic.ts | 9 +- apps/staking/src/config/server.ts | 35 +++-- apps/staking/src/middleware.ts | 16 +- 13 files changed, 244 insertions(+), 150 deletions(-) create mode 100644 apps/staking/src/app/restricted-mode/page.tsx create mode 100644 apps/staking/src/components/Root/restricted-region-banner.tsx diff --git a/apps/staking/src/app/restricted-mode/page.tsx b/apps/staking/src/app/restricted-mode/page.tsx new file mode 100644 index 000000000..fb09aec3b --- /dev/null +++ b/apps/staking/src/app/restricted-mode/page.tsx @@ -0,0 +1 @@ +export { RestrictedMode as default } from "../../components/Home"; diff --git a/apps/staking/src/components/AccountSummary/index.tsx b/apps/staking/src/components/AccountSummary/index.tsx index 2ac4cca3a..fc0328dd9 100644 --- a/apps/staking/src/components/AccountSummary/index.tsx +++ b/apps/staking/src/components/AccountSummary/index.tsx @@ -42,6 +42,7 @@ type Props = { availableRewards: bigint; expiringRewards: Date | undefined; availableToWithdraw: bigint; + restrictedMode?: boolean | undefined; }; export const AccountSummary = ({ @@ -54,6 +55,7 @@ export const AccountSummary = ({ availableToWithdraw, availableRewards, expiringRewards, + restrictedMode, }: Props) => (
)}
- + {!restrictedMode && ( + + )} {availableToWithdraw === 0n ? ( - {availableRewards === 0n || - api.type === ApiStateType.LoadedNoStakeAccount ? ( - -

You have no rewards available to be claimed

-
- ) : ( - - )} -
+ {!restrictedMode && ( + + + {availableRewards === 0n || + api.type === ApiStateType.LoadedNoStakeAccount ? ( + +

You have no rewards available to be claimed

+
+ ) : ( + + )} +
+ )}
@@ -173,35 +179,37 @@ export const AccountSummary = ({ } /> - - ) : ( - - ) - } - {...(expiringRewards !== undefined && - availableRewards > 0n && { - warning: ( - <> - Rewards expire one year from the epoch in which they were - earned. You have rewards expiring on{" "} - {expiringRewards.toLocaleDateString()}. - - ), - })} - /> + {!restrictedMode && ( + + ) : ( + + ) + } + {...(expiringRewards !== undefined && + availableRewards > 0n && { + warning: ( + <> + Rewards expire one year from the epoch in which they were + earned. You have rewards expiring on{" "} + {expiringRewards.toLocaleDateString()}. + + ), + })} + /> + )}
diff --git a/apps/staking/src/components/Dashboard/index.tsx b/apps/staking/src/components/Dashboard/index.tsx index eb4ea3e31..308503870 100644 --- a/apps/staking/src/components/Dashboard/index.tsx +++ b/apps/staking/src/components/Dashboard/index.tsx @@ -43,6 +43,7 @@ type Props = { integrityStakingPublishers: ComponentProps< typeof OracleIntegrityStaking >["publishers"]; + restrictedMode?: boolean | undefined; }; export const Dashboard = ({ @@ -57,6 +58,7 @@ export const Dashboard = ({ integrityStakingPublishers, unlockSchedule, yieldRate, + restrictedMode, }: Props) => { const [tab, setTab] = useState(TabIds.Empty); @@ -126,7 +128,11 @@ export const Dashboard = ({ }, [tab]); return ( -
+
- -

- Choose Your Journey -

- - - - Secure the Oracle -
- to Earn Rewards -
- - Gain Voting Power -
- for Governance -
-
- - - - - - - -
+ {restrictedMode ? ( + + ) : ( + +

+ Choose Your Journey +

+ + + + Secure the Oracle +
+ to Earn Rewards +
+ + Gain Voting Power +
+ for Governance +
+
+ + + + + + + +
+ )}
); }; diff --git a/apps/staking/src/components/Governance/index.tsx b/apps/staking/src/components/Governance/index.tsx index 07dd92ac2..bdb42a049 100644 --- a/apps/staking/src/components/Governance/index.tsx +++ b/apps/staking/src/components/Governance/index.tsx @@ -10,6 +10,7 @@ type Props = { staked: bigint; cooldown: bigint; cooldown2: bigint; + restrictedMode?: boolean | undefined; }; export const Governance = ({ @@ -20,6 +21,7 @@ export const Governance = ({ staked, cooldown, cooldown2, + restrictedMode, }: Props) => ( ); diff --git a/apps/staking/src/components/Header/current-stake-account.tsx b/apps/staking/src/components/Header/current-stake-account.tsx index 9024688f2..553be7578 100644 --- a/apps/staking/src/components/Header/current-stake-account.tsx +++ b/apps/staking/src/components/Header/current-stake-account.tsx @@ -1,8 +1,10 @@ "use client"; import clsx from "clsx"; +import { useSelectedLayoutSegment } from "next/navigation"; import { type HTMLProps } from "react"; +import { VPN_BLOCKED_SEGMENT } from "../../config/isomorphic"; import { StateType as ApiStateType, useApi } from "../../hooks/use-api"; import { CopyButton } from "../CopyButton"; import { TruncatedKey } from "../TruncatedKey"; @@ -11,9 +13,11 @@ export const CurrentStakeAccount = ({ className, ...props }: HTMLProps) => { + const segment = useSelectedLayoutSegment(); + const isBlocked = segment === VPN_BLOCKED_SEGMENT; const api = useApi(); - return api.type === ApiStateType.Loaded ? ( + return api.type === ApiStateType.Loaded && !isBlocked ? (
{ +export const Home = () => ; +export const RestrictedMode = () => ; + +type HomeImplProps = { + restrictedMode?: boolean | undefined; +}; + +export const HomeImpl = ({ restrictedMode }: HomeImplProps) => { const isSSR = useIsSSR(); - return isSSR ? : ; + return isSSR ? : ; +}; + +type MountedHomeProps = { + restrictedMode?: boolean | undefined; }; -const MountedHome = () => { +const MountedHome = ({ restrictedMode }: MountedHomeProps) => { const api = useApi(); switch (api.type) { @@ -44,16 +55,22 @@ const MountedHome = () => { } case ApiStateType.LoadedNoStakeAccount: case ApiStateType.Loaded: { - return ; + return ( + + ); } } }; type StakeAccountLoadedHomeProps = { api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; + restrictedMode?: boolean | undefined; }; -const StakeAccountLoadedHome = ({ api }: StakeAccountLoadedHomeProps) => { +const StakeAccountLoadedHome = ({ + api, + restrictedMode, +}: StakeAccountLoadedHomeProps) => { const data = useData(api.dashboardDataCacheKey, api.loadData, { refreshInterval: REFRESH_INTERVAL, }); @@ -69,7 +86,9 @@ const StakeAccountLoadedHome = ({ api }: StakeAccountLoadedHomeProps) => { } case DashboardDataStateType.Loaded: { - return ; + return ( + + ); } } }; diff --git a/apps/staking/src/components/OracleIntegrityStaking/index.tsx b/apps/staking/src/components/OracleIntegrityStaking/index.tsx index 702ed71d8..ad585b11e 100644 --- a/apps/staking/src/components/OracleIntegrityStaking/index.tsx +++ b/apps/staking/src/components/OracleIntegrityStaking/index.tsx @@ -289,7 +289,7 @@ const SelfStaking = ({ Historical APY Number of feeds Quality ranking - {availableToStake > 0n && } + @@ -690,7 +690,10 @@ const PublisherList = ({ onSelectionChange={updateSort} > - @@ -1396,7 +1399,7 @@ const YourPositionsTable = ({ currentEpoch, publisher, }: YourPositionsTableProps) => ( -
+
Your Positions diff --git a/apps/staking/src/components/Root/index.tsx b/apps/staking/src/components/Root/index.tsx index 29cd68a83..6686a104d 100644 --- a/apps/staking/src/components/Root/index.tsx +++ b/apps/staking/src/components/Root/index.tsx @@ -4,6 +4,7 @@ import clsx from "clsx"; import { Red_Hat_Text, Red_Hat_Mono } from "next/font/google"; import type { ReactNode, CSSProperties } from "react"; +import { RestrictedRegionBanner } from "./restricted-region-banner"; import { IS_PRODUCTION_SERVER, GOOGLE_ANALYTICS_ID, @@ -64,8 +65,9 @@ export const Root = ({ children }: Props) => ( redHatMono.variable, )} > - +
+ {children} diff --git a/apps/staking/src/components/Root/restricted-region-banner.tsx b/apps/staking/src/components/Root/restricted-region-banner.tsx new file mode 100644 index 000000000..044e22cb9 --- /dev/null +++ b/apps/staking/src/components/Root/restricted-region-banner.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useSelectedLayoutSegment } from "next/navigation"; + +import { RESTRICTED_MODE_SEGMENT } from "../../config/isomorphic"; + +export const RestrictedRegionBanner = () => { + const segment = useSelectedLayoutSegment(); + const isRestrictedMode = segment === RESTRICTED_MODE_SEGMENT; + + return isRestrictedMode ? ( +
+

Notice

+

Limited Access in Your Region.

+

+ You are accessing the Pyth Staking Dashboard from a region with + restricted access. You can still access the Pyth Governance program to + manage or withdraw your tokens. +

+
+ ) : ( +
+ ); +}; diff --git a/apps/staking/src/components/WalletButton/index.tsx b/apps/staking/src/components/WalletButton/index.tsx index 594d3871d..74ad476d3 100644 --- a/apps/staking/src/components/WalletButton/index.tsx +++ b/apps/staking/src/components/WalletButton/index.tsx @@ -28,10 +28,7 @@ import { Collection, } from "react-aria-components"; -import { - REGION_BLOCKED_SEGMENT, - VPN_BLOCKED_SEGMENT, -} from "../../config/isomorphic"; +import { VPN_BLOCKED_SEGMENT } from "../../config/isomorphic"; import { StateType as ApiStateType, type States, @@ -52,8 +49,7 @@ type Props = Omit, "onClick" | "children">; export const WalletButton = (props: Props) => { const segment = useSelectedLayoutSegment(); - const isBlocked = - segment === REGION_BLOCKED_SEGMENT || segment === VPN_BLOCKED_SEGMENT; + const isBlocked = segment === VPN_BLOCKED_SEGMENT; // eslint-disable-next-line unicorn/no-null return isBlocked ? null : ; diff --git a/apps/staking/src/config/isomorphic.ts b/apps/staking/src/config/isomorphic.ts index ca50d0a57..eb77bfdd8 100644 --- a/apps/staking/src/config/isomorphic.ts +++ b/apps/staking/src/config/isomorphic.ts @@ -13,18 +13,19 @@ export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === "production"; /** - * Region-blocked requests will be redirected here. This is used in the + * Region or VPN-blocked requests will be redirected here if they are eligible + * for "restricted mode" (aka only allowing withdrawals). This is used in the * middleware to implement the block, and also consumed in any components that * are part of the page layout but need to know if the request is blocked from * accessing the app, such as the WalletButton in the app header. * * Don't change unless you also change the relevant app route path to match. */ -export const REGION_BLOCKED_SEGMENT = "region-blocked"; +export const RESTRICTED_MODE_SEGMENT = "restricted-mode"; /** - * Similar to `REGION_BLOCKED_SEGMENT`; this is where vpn-blocked traffic will - * be rewritten to. + * Similar to `RESTRICTED_MODE_SEGMENT`; this is where vpn-blocked traffic will + * be rewritten to if it isn't eligible for restricted mode. * * Don't change unless you also change the relevant app route path to match. */ diff --git a/apps/staking/src/config/server.ts b/apps/staking/src/config/server.ts index ef3af27bf..7aa3e3dcf 100644 --- a/apps/staking/src/config/server.ts +++ b/apps/staking/src/config/server.ts @@ -9,13 +9,30 @@ import "server-only"; */ const demand = (key: string): string => { const value = process.env[key]; - if (value && value !== "") { - return value; - } else { + if (value === undefined || value === "") { throw new MissingEnvironmentError(key); + } else { + return value; } }; +const fromCsv = (value: string): string[] => + value.split(",").map((entry) => entry.toLowerCase().trim()); + +const transformOr = ( + key: string, + fn: (value: string) => T, + defaultValue: T, +): T => { + const value = process.env[key]; + return value === undefined || value === "" ? defaultValue : fn(value); +}; + +const identity = (value: T): T => value; + +const getOr = (key: string, defaultValue: string): string => + transformOr(key, identity, defaultValue); + /** * Indicates that this server is the live customer-facing production server. */ @@ -36,15 +53,9 @@ export const WALLETCONNECT_PROJECT_ID = demandInProduction( ); export const RPC = process.env.RPC; export const IS_MAINNET = process.env.IS_MAINNET !== undefined; -export const HERMES_URL = - process.env.HERMES_URL ?? "https://hermes.pyth.network"; -export const BLOCKED_REGIONS = - process.env.BLOCKED_REGIONS === undefined || - process.env.BLOCKED_REGIONS === "" - ? [] - : process.env.BLOCKED_REGIONS.split(",").map((region) => - region.toLowerCase().trim(), - ); +export const HERMES_URL = getOr("HERMES_URL", "https://hermes.pyth.network"); +export const BLOCKED_REGIONS = transformOr("BLOCKED_REGIONS", fromCsv, []); +export const FATF_COUNTRIES = transformOr("FATF_COUNTRIES", fromCsv, []); export const PROXYCHECK_API_KEY = demandInProduction("PROXYCHECK_API_KEY"); class MissingEnvironmentError extends Error { diff --git a/apps/staking/src/middleware.ts b/apps/staking/src/middleware.ts index 2977f0e1e..94ae80914 100644 --- a/apps/staking/src/middleware.ts +++ b/apps/staking/src/middleware.ts @@ -2,12 +2,12 @@ import { type NextRequest, NextResponse } from "next/server"; import ProxyCheck from "proxycheck-ts"; import { - REGION_BLOCKED_SEGMENT, + RESTRICTED_MODE_SEGMENT, VPN_BLOCKED_SEGMENT, } from "./config/isomorphic"; import { BLOCKED_REGIONS, PROXYCHECK_API_KEY } from "./config/server"; -const PROXY_BLOCK_PATH = `/${REGION_BLOCKED_SEGMENT}`; +const RESTRICTED_MODE_PATH = `/${RESTRICTED_MODE_SEGMENT}`; const VPN_BLOCK_PATH = `/${VPN_BLOCKED_SEGMENT}`; const proxyCheckClient = PROXYCHECK_API_KEY @@ -15,10 +15,10 @@ const proxyCheckClient = PROXYCHECK_API_KEY : undefined; export const middleware = async (request: NextRequest) => { - if (isRegionBlocked(request)) { - return rewrite(request, PROXY_BLOCK_PATH); - } else if (await isProxyBlocked(request)) { + if (await isProxyBlocked(request)) { return rewrite(request, VPN_BLOCK_PATH); + } else if (isRegionBlocked(request)) { + return rewrite(request, RESTRICTED_MODE_PATH); } else if (isBlockedSegment(request)) { return rewrite(request, "/not-found"); } else { @@ -43,12 +43,12 @@ const isProxyBlocked = async ({ ip }: NextRequest) => { }; const isBlockedSegment = ({ nextUrl: { pathname } }: NextRequest) => - pathname.startsWith(`/${REGION_BLOCKED_SEGMENT}`) || - pathname.startsWith(`/${VPN_BLOCKED_SEGMENT}`); + pathname.startsWith(`/${VPN_BLOCKED_SEGMENT}`) || + pathname.startsWith(`/${RESTRICTED_MODE_SEGMENT}`); export const config = { // Next.js requires that this is a static string and fails to read it if it's // a String.raw, so let's disable this rule // eslint-disable-next-line unicorn/prefer-string-raw - matcher: ["/((?!_next/static|_next/image|.*\\.).*)"], + matcher: ["/((?!_next/static|_next/image|api/|terms-of-service/|.*\\.).*)"], };