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..83c55506d 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) => (
)} - - - {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 +177,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..73b040b88 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); @@ -137,6 +139,7 @@ export const Dashboard = ({ availableToWithdraw={availableToWithdraw} availableRewards={availableRewards} expiringRewards={expiringRewards} + restrictedMode={restrictedMode} /> diff --git a/apps/staking/src/components/Header/current-stake-account.tsx b/apps/staking/src/components/Header/current-stake-account.tsx index 9024688f2..280a21b6d 100644 --- a/apps/staking/src/components/Header/current-stake-account.tsx +++ b/apps/staking/src/components/Header/current-stake-account.tsx @@ -1,8 +1,13 @@ "use client"; import clsx from "clsx"; +import { useSelectedLayoutSegment } from "next/navigation"; import { type HTMLProps } from "react"; +import { + REGION_BLOCKED_SEGMENT, + 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 +16,12 @@ export const CurrentStakeAccount = ({ className, ...props }: HTMLProps) => { + const segment = useSelectedLayoutSegment(); + const isBlocked = + segment === REGION_BLOCKED_SEGMENT || segment === VPN_BLOCKED_SEGMENT; const api = useApi(); - return api.type === ApiStateType.Loaded ? ( + return api.type === ApiStateType.Loaded && !isBlocked ? (
{ +type Props = { + restrictedMode?: boolean | undefined; +}; + +export const Home = ({ restrictedMode }: Props) => { const isSSR = useIsSSR(); - return isSSR ? : ; + return isSSR ? : ; +}; + +export const RestrictedMode = () => ; + +type MountedHomeProps = { + restrictedMode?: boolean | undefined; }; -const MountedHome = () => { +const MountedHome = ({ restrictedMode }: MountedHomeProps) => { const api = useApi(); switch (api.type) { @@ -44,16 +54,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 +85,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..90eb51099 100644 --- a/apps/staking/src/components/OracleIntegrityStaking/index.tsx +++ b/apps/staking/src/components/OracleIntegrityStaking/index.tsx @@ -70,6 +70,7 @@ type Props = { cooldown2: bigint; publishers: PublisherProps["publisher"][]; yieldRate: bigint; + restrictedMode?: boolean | undefined; }; export const OracleIntegrityStaking = ({ @@ -83,6 +84,7 @@ export const OracleIntegrityStaking = ({ cooldown2, publishers, yieldRate, + restrictedMode, }: Props) => { const self = useMemo( () => @@ -133,6 +135,7 @@ export const OracleIntegrityStaking = ({ currentEpoch={currentEpoch} availableToStake={availableToStake} yieldRate={yieldRate} + restrictedMode={restrictedMode} /> )}
@@ -161,6 +165,7 @@ type SelfStakingProps = { currentEpoch: bigint; availableToStake: bigint; yieldRate: bigint; + restrictedMode?: boolean | undefined; }; const SelfStaking = ({ @@ -169,6 +174,7 @@ const SelfStaking = ({ currentEpoch, availableToStake, yieldRate, + restrictedMode, }: SelfStakingProps) => { const [publisherFaqOpen, setPublisherFaqOpen] = useState(false); const openPublisherFaq = useCallback(() => { @@ -276,6 +282,7 @@ const SelfStaking = ({ publisher={self} totalStaked={self.positions?.staked ?? 0n} yieldRate={yieldRate} + restrictedMode={restrictedMode} isSelf compact /> @@ -289,7 +296,7 @@ const SelfStaking = ({ Historical APY Number of feeds Quality ranking - {availableToStake > 0n && } + {!restrictedMode && } @@ -301,6 +308,7 @@ const SelfStaking = ({ publisher={self} totalStaked={self.positions?.staked ?? 0n} yieldRate={yieldRate} + restrictedMode={restrictedMode} /> @@ -568,6 +576,7 @@ type PublisherListProps = { totalStaked: bigint; publishers: PublisherProps["publisher"][]; yieldRate: bigint; + restrictedMode?: boolean | undefined; }; const PublisherList = ({ @@ -578,6 +587,7 @@ const PublisherList = ({ publishers, totalStaked, yieldRate, + restrictedMode, }: PublisherListProps) => { const scrollTarget = useRef(null); const [search, setSearch] = useState(""); @@ -690,7 +700,10 @@ const PublisherList = ({ onSelectionChange={updateSort} > - @@ -750,6 +763,7 @@ const PublisherList = ({ publisher={publisher} totalStaked={totalStaked} yieldRate={yieldRate} + restrictedMode={restrictedMode} compact /> @@ -806,10 +820,13 @@ const PublisherList = ({ desc={SortOption.QualityRankingDescending} sort={sort} setSort={updateSort} + className={restrictedMode ? "" : "pr-4 sm:pr-10"} > Quality ranking - + {!restrictedMode && ( + + )} @@ -823,6 +840,7 @@ const PublisherList = ({ publisher={publisher} totalStaked={totalStaked} yieldRate={yieldRate} + restrictedMode={restrictedMode} /> ))} @@ -1085,6 +1103,7 @@ type PublisherProps = { }; yieldRate: bigint; compact?: boolean | undefined; + restrictedMode?: boolean | undefined; }; const Publisher = ({ @@ -1096,6 +1115,7 @@ const Publisher = ({ isSelf, yieldRate, compact, + restrictedMode, }: PublisherProps) => { const warmup = useMemo( () => @@ -1157,22 +1177,28 @@ const Publisher = ({ > {publisher} - + {!restrictedMode && ( + + )}
)}
{!isSelf && ( @@ -1183,7 +1209,7 @@ const Publisher = ({ />
)} - {isSelf && ( + {isSelf && !restrictedMode && ( {publisher.numFeeds} - - {publisher.qualityRanking === 0 ? "-" : publisher.qualityRanking} - - + {publisher.qualityRanking === 0 ? "-" : publisher.qualityRanking} + {!restrictedMode && ( + + + + )} {(warmup !== undefined || staked !== undefined) && ( @@ -1396,7 +1428,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/config/isomorphic.ts b/apps/staking/src/config/isomorphic.ts index ca50d0a57..dad563f45 100644 --- a/apps/staking/src/config/isomorphic.ts +++ b/apps/staking/src/config/isomorphic.ts @@ -13,19 +13,28 @@ 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. */ export const VPN_BLOCKED_SEGMENT = "vpn-blocked"; + +/** + * Similar to `RESTRICTED_MODE_SEGMENT`; this is where region-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. + */ +export const REGION_BLOCKED_SEGMENT = "region-blocked"; 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..b95240875 100644 --- a/apps/staking/src/middleware.ts +++ b/apps/staking/src/middleware.ts @@ -2,11 +2,17 @@ import { type NextRequest, NextResponse } from "next/server"; import ProxyCheck from "proxycheck-ts"; import { + RESTRICTED_MODE_SEGMENT, REGION_BLOCKED_SEGMENT, VPN_BLOCKED_SEGMENT, } from "./config/isomorphic"; -import { BLOCKED_REGIONS, PROXYCHECK_API_KEY } from "./config/server"; +import { + BLOCKED_REGIONS, + FATF_COUNTRIES, + PROXYCHECK_API_KEY, +} from "./config/server"; +const RESTRICTED_MODE_PATH = `/${RESTRICTED_MODE_SEGMENT}`; const PROXY_BLOCK_PATH = `/${REGION_BLOCKED_SEGMENT}`; const VPN_BLOCK_PATH = `/${VPN_BLOCKED_SEGMENT}`; @@ -15,10 +21,13 @@ 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, + isFatfCountry(request) ? PROXY_BLOCK_PATH : RESTRICTED_MODE_PATH, + ); } else if (isBlockedSegment(request)) { return rewrite(request, "/not-found"); } else { @@ -29,6 +38,10 @@ export const middleware = async (request: NextRequest) => { const rewrite = (request: NextRequest, path: string) => NextResponse.rewrite(new URL(path, request.url)); +const isFatfCountry = ({ geo }: NextRequest) => + geo?.country !== undefined && + FATF_COUNTRIES.includes(geo.country.toLowerCase()); + const isRegionBlocked = ({ geo }: NextRequest) => geo?.country !== undefined && BLOCKED_REGIONS.includes(geo.country.toLowerCase()); @@ -44,11 +57,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/|.*\\.).*)"], };