소스 검색

Merge pull request #1965 from cprussin/add-withdraw-only-mode

feat(staking): add restricted mode for geofenced countries
Connor Prussin 1 년 전
부모
커밋
890a3cbfcb

+ 1 - 0
apps/staking/src/app/restricted-mode/page.tsx

@@ -0,0 +1 @@
+export { RestrictedMode as default } from "../../components/Home";

+ 55 - 47
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) => (
   <section className="relative w-full overflow-hidden sm:border sm:border-neutral-600/50 sm:bg-pythpurple-800">
     <Image
@@ -118,7 +120,9 @@ export const AccountSummary = ({
           </>
         )}
         <div className="mt-3 flex flex-row items-center gap-4 sm:mt-8">
-          <AddTokensButton walletAmount={walletAmount} api={api} />
+          {!restrictedMode && (
+            <AddTokensButton walletAmount={walletAmount} api={api} />
+          )}
           {availableToWithdraw === 0n ? (
             <DialogTrigger>
               <Button variant="secondary" className="xl:hidden">
@@ -145,23 +149,25 @@ export const AccountSummary = ({
               className="xl:hidden"
             />
           )}
-          <DialogTrigger>
-            <Button variant="secondary" className="xl:hidden">
-              Claim
-            </Button>
-            {availableRewards === 0n ||
-            api.type === ApiStateType.LoadedNoStakeAccount ? (
-              <ModalDialog title="No Rewards" closeButtonText="Ok">
-                <p>You have no rewards available to be claimed</p>
-              </ModalDialog>
-            ) : (
-              <ClaimDialog
-                expiringRewards={expiringRewards}
-                availableRewards={availableRewards}
-                api={api}
-              />
-            )}
-          </DialogTrigger>
+          {!restrictedMode && (
+            <DialogTrigger>
+              <Button variant="secondary" className="xl:hidden">
+                Claim
+              </Button>
+              {availableRewards === 0n ||
+              api.type === ApiStateType.LoadedNoStakeAccount ? (
+                <ModalDialog title="No Rewards" closeButtonText="Ok">
+                  <p>You have no rewards available to be claimed</p>
+                </ModalDialog>
+              ) : (
+                <ClaimDialog
+                  expiringRewards={expiringRewards}
+                  availableRewards={availableRewards}
+                  api={api}
+                />
+              )}
+            </DialogTrigger>
+          )}
         </div>
       </div>
       <div className="hidden w-auto items-stretch gap-4 xl:flex">
@@ -173,35 +179,37 @@ export const AccountSummary = ({
             <WithdrawButton api={api} max={availableToWithdraw} size="small" />
           }
         />
-        <BalanceCategory
-          name="Available Rewards"
-          amount={availableRewards}
-          description="Rewards you have earned from OIS"
-          action={
-            api.type === ApiStateType.Loaded ? (
-              <ClaimButton
-                size="small"
-                variant="secondary"
-                isDisabled={availableRewards === 0n}
-                api={api}
-              />
-            ) : (
-              <Button size="small" variant="secondary" isDisabled={true}>
-                Claim
-              </Button>
-            )
-          }
-          {...(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 && (
+          <BalanceCategory
+            name="Available Rewards"
+            amount={availableRewards}
+            description="Rewards you have earned from OIS"
+            action={
+              api.type === ApiStateType.Loaded ? (
+                <ClaimButton
+                  size="small"
+                  variant="secondary"
+                  isDisabled={availableRewards === 0n}
+                  api={api}
+                />
+              ) : (
+                <Button size="small" variant="secondary" isDisabled={true}>
+                  Claim
+                </Button>
+              )
+            }
+            {...(expiringRewards !== undefined &&
+              availableRewards > 0n && {
+                warning: (
+                  <>
+                    Rewards expire one year from the epoch in which they were
+                    earned. You have rewards expiring on{" "}
+                    {expiringRewards.toLocaleDateString()}.
+                  </>
+                ),
+              })}
+          />
+        )}
       </div>
     </div>
   </section>

+ 80 - 60
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<TabId>(TabIds.Empty);
 
@@ -126,7 +128,11 @@ export const Dashboard = ({
   }, [tab]);
 
   return (
-    <main className="flex w-full flex-col gap-8 xl:px-4 xl:py-6">
+    <main
+      className={clsx("flex w-full flex-col gap-8 xl:px-4 xl:py-6", {
+        "sm:gap-0": restrictedMode,
+      })}
+    >
       <AccountSummary
         api={api}
         locked={locked}
@@ -137,66 +143,80 @@ export const Dashboard = ({
         availableToWithdraw={availableToWithdraw}
         availableRewards={availableRewards}
         expiringRewards={expiringRewards}
+        restrictedMode={restrictedMode}
       />
-      <Tabs
-        selectedKey={tab}
-        onSelectionChange={setTab}
-        className="group border-neutral-600/50 data-[empty]:my-[5dvh] data-[empty]:border data-[empty]:bg-white/10 data-[empty]:p-4 sm:p-4 data-[empty]:sm:my-0 data-[empty]:sm:border-0 data-[empty]:sm:bg-transparent data-[empty]:sm:p-0"
-        {...(tab === TabIds.Empty && { "data-empty": true })}
-      >
-        <h1 className="my-4 hidden text-center text-xl/tight font-light group-data-[empty]:mb-10 group-data-[empty]:block sm:mb-6 sm:text-3xl group-data-[empty]:sm:mb-6 lg:my-14 lg:text-5xl">
-          Choose Your Journey
-        </h1>
-        <TabList className="sticky top-header-height z-10 flex flex-row items-stretch justify-center group-data-[empty]:mx-auto group-data-[empty]:max-w-7xl group-data-[empty]:flex-col group-data-[empty]:gap-8 group-data-[empty]:sm:flex-row group-data-[empty]:sm:gap-2">
-          <Tab id={TabIds.Empty} className="hidden" />
-          <Journey
-            longText="Oracle Integrity Staking (OIS)"
-            shortText="OIS"
-            image={ois}
-            id={TabIds.IntegrityStaking}
-          >
-            <span>Secure the Oracle</span>
-            <br />
-            <span className="font-semibold">to Earn Rewards</span>
-          </Journey>
-          <Journey
-            longText="Pyth Governance"
-            shortText="Governance"
-            image={governanceImage}
-            id={TabIds.Governance}
-          >
-            <span>Gain Voting Power</span>
-            <br />
-            <span className="font-semibold">for Governance</span>
-          </Journey>
-        </TabList>
-        <TabPanel id={TabIds.Empty}></TabPanel>
-        <TabPanel id={TabIds.IntegrityStaking}>
-          <OracleIntegrityStaking
-            api={api}
-            currentEpoch={currentEpoch}
-            availableToStake={availableToStakeIntegrity}
-            locked={locked}
-            warmup={integrityStakingWarmup}
-            staked={integrityStakingStaked}
-            cooldown={integrityStakingCooldown}
-            cooldown2={integrityStakingCooldown2}
-            publishers={integrityStakingPublishers}
-            yieldRate={yieldRate}
-          />
-        </TabPanel>
-        <TabPanel id={TabIds.Governance}>
-          <Governance
-            api={api}
-            currentEpoch={currentEpoch}
-            availableToStake={availableToStakeGovernance}
-            warmup={governance.warmup}
-            staked={governance.staked}
-            cooldown={governance.cooldown}
-            cooldown2={governance.cooldown2}
-          />
-        </TabPanel>
-      </Tabs>
+      {restrictedMode ? (
+        <Governance
+          api={api}
+          currentEpoch={currentEpoch}
+          availableToStake={availableToStakeGovernance}
+          warmup={governance.warmup}
+          staked={governance.staked}
+          cooldown={governance.cooldown}
+          cooldown2={governance.cooldown2}
+          restrictedMode
+        />
+      ) : (
+        <Tabs
+          selectedKey={tab}
+          onSelectionChange={setTab}
+          className="group border-neutral-600/50 data-[empty]:my-[5dvh] data-[empty]:border data-[empty]:bg-white/10 data-[empty]:p-4 sm:p-4 data-[empty]:sm:my-0 data-[empty]:sm:border-0 data-[empty]:sm:bg-transparent data-[empty]:sm:p-0"
+          {...(tab === TabIds.Empty && { "data-empty": true })}
+        >
+          <h1 className="my-4 hidden text-center text-xl/tight font-light group-data-[empty]:mb-10 group-data-[empty]:block sm:mb-6 sm:text-3xl group-data-[empty]:sm:mb-6 lg:my-14 lg:text-5xl">
+            Choose Your Journey
+          </h1>
+          <TabList className="sticky top-header-height z-10 flex flex-row items-stretch justify-center group-data-[empty]:mx-auto group-data-[empty]:max-w-7xl group-data-[empty]:flex-col group-data-[empty]:gap-8 group-data-[empty]:sm:flex-row group-data-[empty]:sm:gap-2">
+            <Tab id={TabIds.Empty} className="hidden" />
+            <Journey
+              longText="Oracle Integrity Staking (OIS)"
+              shortText="OIS"
+              image={ois}
+              id={TabIds.IntegrityStaking}
+            >
+              <span>Secure the Oracle</span>
+              <br />
+              <span className="font-semibold">to Earn Rewards</span>
+            </Journey>
+            <Journey
+              longText="Pyth Governance"
+              shortText="Governance"
+              image={governanceImage}
+              id={TabIds.Governance}
+            >
+              <span>Gain Voting Power</span>
+              <br />
+              <span className="font-semibold">for Governance</span>
+            </Journey>
+          </TabList>
+          <TabPanel id={TabIds.Empty}></TabPanel>
+          <TabPanel id={TabIds.IntegrityStaking}>
+            <OracleIntegrityStaking
+              api={api}
+              currentEpoch={currentEpoch}
+              availableToStake={availableToStakeIntegrity}
+              locked={locked}
+              warmup={integrityStakingWarmup}
+              staked={integrityStakingStaked}
+              cooldown={integrityStakingCooldown}
+              cooldown2={integrityStakingCooldown2}
+              publishers={integrityStakingPublishers}
+              yieldRate={yieldRate}
+            />
+          </TabPanel>
+          <TabPanel id={TabIds.Governance}>
+            <Governance
+              api={api}
+              currentEpoch={currentEpoch}
+              availableToStake={availableToStakeGovernance}
+              warmup={governance.warmup}
+              staked={governance.staked}
+              cooldown={governance.cooldown}
+              cooldown2={governance.cooldown2}
+            />
+          </TabPanel>
+        </Tabs>
+      )}
     </main>
   );
 };

+ 10 - 2
apps/staking/src/components/Governance/index.tsx

@@ -1,3 +1,5 @@
+import clsx from "clsx";
+
 import { type States, StateType as ApiStateType } from "../../hooks/use-api";
 import { GovernanceGuide } from "../GovernanceGuide";
 import { ProgramSection } from "../ProgramSection";
@@ -10,6 +12,7 @@ type Props = {
   staked: bigint;
   cooldown: bigint;
   cooldown2: bigint;
+  restrictedMode?: boolean | undefined;
 };
 
 export const Governance = ({
@@ -20,8 +23,10 @@ export const Governance = ({
   staked,
   cooldown,
   cooldown2,
+  restrictedMode,
 }: Props) => (
   <ProgramSection
+    className={clsx({ "border-t sm:border-t-0": restrictedMode })}
     name="Pyth Governance"
     helpDialog={<GovernanceGuide />}
     tagline="Vote and Influence the Network"
@@ -33,8 +38,6 @@ export const Governance = ({
       staked,
       cooldown,
       cooldown2,
-      stake: api.type === ApiStateType.Loaded ? api.stakeGovernance : undefined,
-      stakeDescription: "Stake funds to participate in governance votes",
       cancelWarmup:
         api.type === ApiStateType.Loaded
           ? api.cancelWarmupGovernance
@@ -44,6 +47,11 @@ export const Governance = ({
       unstake:
         api.type === ApiStateType.Loaded ? api.unstakeGovernance : undefined,
       unstakeDescription: "Unstake tokens from the Governance program",
+      ...(!restrictedMode && {
+        stake:
+          api.type === ApiStateType.Loaded ? api.stakeGovernance : undefined,
+        stakeDescription: "Stake funds to participate in governance votes",
+      }),
     }}
   />
 );

+ 5 - 1
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<HTMLDivElement>) => {
+  const segment = useSelectedLayoutSegment();
+  const isBlocked = segment === VPN_BLOCKED_SEGMENT;
   const api = useApi();
 
-  return api.type === ApiStateType.Loaded ? (
+  return api.type === ApiStateType.Loaded && !isBlocked ? (
     <div
       className={clsx(
         "hidden flex-col items-end justify-center text-xs xs:flex md:flex-row md:items-center md:text-sm",

+ 25 - 6
apps/staking/src/components/Home/index.tsx

@@ -20,13 +20,24 @@ const ONE_SECOND_IN_MS = 1000;
 const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
 const REFRESH_INTERVAL = 1 * ONE_MINUTE_IN_MS;
 
-export const Home = () => {
+export const Home = () => <HomeImpl />;
+export const RestrictedMode = () => <HomeImpl restrictedMode />;
+
+type HomeImplProps = {
+  restrictedMode?: boolean | undefined;
+};
+
+export const HomeImpl = ({ restrictedMode }: HomeImplProps) => {
   const isSSR = useIsSSR();
 
-  return isSSR ? <Loading /> : <MountedHome />;
+  return isSSR ? <Loading /> : <MountedHome restrictedMode={restrictedMode} />;
+};
+
+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 <StakeAccountLoadedHome api={api} />;
+      return (
+        <StakeAccountLoadedHome restrictedMode={restrictedMode} api={api} />
+      );
     }
   }
 };
 
 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 <Dashboard {...data.data} api={api} />;
+      return (
+        <Dashboard {...data.data} api={api} restrictedMode={restrictedMode} />
+      );
     }
   }
 };

+ 6 - 3
apps/staking/src/components/OracleIntegrityStaking/index.tsx

@@ -289,7 +289,7 @@ const SelfStaking = ({
                 <PublisherTableHeader>Historical APY</PublisherTableHeader>
                 <PublisherTableHeader>Number of feeds</PublisherTableHeader>
                 <PublisherTableHeader>Quality ranking</PublisherTableHeader>
-                {availableToStake > 0n && <PublisherTableHeader />}
+                <PublisherTableHeader />
               </tr>
             </thead>
             <tbody className="bg-pythpurple-400/10">
@@ -690,7 +690,10 @@ const PublisherList = ({
             onSelectionChange={updateSort}
           >
             <Label className="whitespace-nowrap opacity-80">Sort by</Label>
-            <Button className="group flex flex-row items-center gap-2 text-xs transition">
+            <Button
+              className="group flex flex-row items-center gap-2 px-2 py-3 text-xs transition sm:px-4"
+              size="nopad"
+            >
               {getSortName(sort)}
               <ChevronDownIcon className="size-4 flex-none opacity-60 transition duration-300 group-data-[pressed]:-rotate-180" />
             </Button>
@@ -1396,7 +1399,7 @@ const YourPositionsTable = ({
   currentEpoch,
   publisher,
 }: YourPositionsTableProps) => (
-  <div className="mx-auto mb-0 mt-4 border border-neutral-600/50 bg-pythpurple-800 p-4 text-xs sm:mb-4 sm:px-8 sm:py-6 md:mb-8 md:w-[30rem]">
+  <div className="mx-auto mb-0 mt-4 border border-neutral-600/50 bg-pythpurple-800 p-4 text-xs sm:mb-4 sm:px-8 sm:py-6 md:mb-8 md:w-[25rem] lg:w-[35rem]">
     <table className="w-full text-sm md:text-base">
       <caption className="mb-2 text-left font-light md:text-lg">
         Your Positions

+ 1 - 1
apps/staking/src/components/ProgramSection/index.tsx

@@ -32,7 +32,7 @@ export const ProgramSection = ({
 }: Props) => (
   <section
     className={clsx(
-      "border border-t-0 border-neutral-600/50 bg-pythpurple-800 px-4 py-6 sm:px-8",
+      "border-x border-b border-neutral-600/50 bg-pythpurple-800 px-4 py-6 sm:px-8",
       className,
     )}
     {...props}

+ 3 - 1
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,
             )}
           >
-            <body className="grid min-h-dvh grid-rows-[auto_1fr_auto] text-pythpurple-100 [background:radial-gradient(20rem_50rem_at_50rem_10rem,_rgba(119,_49,_234,_0.20)_0%,_rgba(17,_15,_35,_0.00)_100rem),_#0A0814] selection:bg-pythpurple-600/60">
+            <body className="grid min-h-dvh grid-rows-[auto_auto_1fr_auto] text-pythpurple-100 [background:radial-gradient(20rem_50rem_at_50rem_10rem,_rgba(119,_49,_234,_0.20)_0%,_rgba(17,_15,_35,_0.00)_100rem),_#0A0814] selection:bg-pythpurple-600/60">
               <Header className="z-10" />
+              <RestrictedRegionBanner />
               <MaxWidth className="z-0 min-h-[calc(100dvh_-_var(--header-height))] py-8 sm:min-h-0">
                 {children}
               </MaxWidth>

+ 36 - 0
apps/staking/src/components/Root/restricted-region-banner.tsx

@@ -0,0 +1,36 @@
+"use client";
+
+import { useSelectedLayoutSegment } from "next/navigation";
+
+import { RESTRICTED_MODE_SEGMENT } from "../../config/isomorphic";
+import { Link } from "../Link";
+
+export const RestrictedRegionBanner = () => {
+  const segment = useSelectedLayoutSegment();
+  const isRestrictedMode = segment === RESTRICTED_MODE_SEGMENT;
+
+  return isRestrictedMode ? (
+    <div className="mx-auto mt-8 flex max-w-3xl flex-col gap-2 bg-red-900 px-8 py-6">
+      <h2 className="mb-2 text-2xl font-light">LEGAL NOTICE</h2>
+      <p className="font-medium">
+        Your access to this Website and its Services is restricted.
+      </p>
+      <p className="text-sm font-light">
+        It appears that you are located in a jurisdiction subject to
+        restrictions under our{" "}
+        <Link className="underline" href="/terms-of-service">
+          Terms of Service
+        </Link>
+        . As a result, you are not permitted to use or access certain Services
+        on this Website. However, you are still allowed to use the Unstake and
+        Withdraw functions.
+      </p>
+      <p className="text-sm font-light">
+        Any attempt to bypass these restrictions, including the use of VPNs or
+        similar technologies, is strictly prohibited.
+      </p>
+    </div>
+  ) : (
+    <div />
+  );
+};

+ 2 - 6
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<ComponentProps<typeof Button>, "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 : <WalletButtonImpl {...props} />;

+ 5 - 4
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.
  */

+ 22 - 12
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 transform = <T>(key: string, fn: (value: string | undefined) => T): T => {
+  const value = process.env[key];
+  return fn(value === "" ? undefined : value);
+};
+
+const transformOr = <T>(
+  key: string,
+  fn: (value: string) => T,
+  defaultValue: T,
+): T => transform(key, (value) => (value ? fn(value) : defaultValue));
+
+const getOr = (key: string, defaultValue: string): string =>
+  transform(key, (value) => value ?? defaultValue);
+
 /**
  * Indicates that this server is the live customer-facing production server.
  */
@@ -36,15 +53,8 @@ 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 PROXYCHECK_API_KEY = demandInProduction("PROXYCHECK_API_KEY");
 
 class MissingEnvironmentError extends Error {

+ 8 - 8
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|.*\\.).*)"],
 };