소스 검색

feat: initial implementation of new staking app

Connor Prussin 1 년 전
부모
커밋
d3e44e3d24
55개의 변경된 파일2658개의 추가작업 그리고 3개의 파일을 삭제
  1. 7 2
      .prettierignore
  2. 1 0
      apps/staking/.gitignore
  3. 7 0
      apps/staking/.prettierignore
  4. 18 0
      apps/staking/eslint.config.js
  5. 1 0
      apps/staking/jest.config.js
  6. 5 0
      apps/staking/next-env.d.ts
  7. 55 0
      apps/staking/next.config.js
  8. 63 0
      apps/staking/package.json
  9. 6 0
      apps/staking/postcss.config.js
  10. 9 0
      apps/staking/prettier.config.js
  11. BIN
      apps/staking/public/android-chrome-192x192.png
  12. BIN
      apps/staking/public/android-chrome-512x512.png
  13. BIN
      apps/staking/public/apple-touch-icon.png
  14. BIN
      apps/staking/public/favicon-16x16.png
  15. BIN
      apps/staking/public/favicon-32x32.png
  16. BIN
      apps/staking/public/favicon-light.ico
  17. BIN
      apps/staking/public/favicon.ico
  18. 205 0
      apps/staking/src/api.ts
  19. 3 0
      apps/staking/src/app/error.tsx
  20. 14 0
      apps/staking/src/app/global-error.tsx
  21. 5 0
      apps/staking/src/app/layout.tsx
  22. 20 0
      apps/staking/src/app/manifest.json
  23. 1 0
      apps/staking/src/app/not-found.tsx
  24. BIN
      apps/staking/src/app/opengraph-image.png
  25. 1 0
      apps/staking/src/app/page.tsx
  26. 11 0
      apps/staking/src/app/robots.ts
  27. 22 0
      apps/staking/src/components/Button/index.tsx
  28. 85 0
      apps/staking/src/components/Dashboard/index.tsx
  29. 613 0
      apps/staking/src/components/Dashboard/loaded.tsx
  30. 13 0
      apps/staking/src/components/Error/index.tsx
  31. 52 0
      apps/staking/src/components/Home/index.tsx
  32. 30 0
      apps/staking/src/components/Home/wallet-button.tsx
  33. 74 0
      apps/staking/src/components/Modal/index.tsx
  34. 15 0
      apps/staking/src/components/NotFound/index.tsx
  35. 26 0
      apps/staking/src/components/Root/amplitude.tsx
  36. 52 0
      apps/staking/src/components/Root/index.tsx
  37. 22 0
      apps/staking/src/components/Root/report-accessibility.tsx
  38. 77 0
      apps/staking/src/components/Root/wallet-provider.tsx
  39. 18 0
      apps/staking/src/components/Styled/index.tsx
  40. 19 0
      apps/staking/src/components/Tokens/index.tsx
  41. 5 0
      apps/staking/src/components/Tokens/pyth.svg
  42. 123 0
      apps/staking/src/components/TransferButton/index.tsx
  43. 13 0
      apps/staking/src/isomorphic-config.ts
  44. 41 0
      apps/staking/src/logger.tsx
  45. 52 0
      apps/staking/src/metadata.ts
  46. 37 0
      apps/staking/src/server-config.ts
  47. 3 0
      apps/staking/src/tailwind.css
  48. 69 0
      apps/staking/src/tokens.test.ts
  49. 27 0
      apps/staking/src/tokens.ts
  50. 11 0
      apps/staking/src/use-is-mounted.ts
  51. 79 0
      apps/staking/src/use-transfer.ts
  52. 6 0
      apps/staking/svg.d.ts
  53. 30 0
      apps/staking/tailwind.config.ts
  54. 5 0
      apps/staking/tsconfig.json
  55. 607 1
      pnpm-lock.yaml

+ 7 - 2
.prettierignore

@@ -1,15 +1,20 @@
 pnpm-lock.yaml
 patches/
 
-# This app has it's own prettier config that uses a later version of prettier
+# These apps have their own prettier config that uses a later version of
+# prettier
 #
 # TODO(cprussin): eventually I'll figure out how to upgrade prettier everywhere
 # and hook it in to pre-commit.  For now, I don't want to downgrade prettier in
-# the pcakage that's using the later version, and pre-commit doesn't support
+# the packages that are using the later version, and pre-commit doesn't support
 # later versions of prettier directly.
 #
 # Ideally, we should probably hook up a pre-commit script to run auto-fixes in a
 # generic way that packages can hook into by defining lifecycle scripts, or by
 # using the nx task graph.  Then, packages can use their own formatters /
 # formatter versions and can also hook up other auto-fixes like eslint, etc.
+#
+# I'll explore doing this when I get around to spending some time on our nx
+# build graph config.
 apps/api-reference
+apps/staking

+ 1 - 0
apps/staking/.gitignore

@@ -0,0 +1 @@
+.env*.local

+ 7 - 0
apps/staking/.prettierignore

@@ -0,0 +1,7 @@
+.next/
+coverage/
+node_modules/
+*.tsbuildinfo
+.env*.local
+.env
+.DS_Store

+ 18 - 0
apps/staking/eslint.config.js

@@ -0,0 +1,18 @@
+import { fileURLToPath } from "node:url";
+
+import { nextjs, tailwind, storybook } from "@cprussin/eslint-config";
+
+const tailwindConfig = fileURLToPath(
+  import.meta.resolve(`./tailwind.config.ts`),
+);
+
+export default [
+  ...nextjs,
+  ...tailwind(tailwindConfig),
+  ...storybook,
+  {
+    rules: {
+      "turbo/no-undeclared-env-vars": "off",
+    },
+  },
+];

+ 1 - 0
apps/staking/jest.config.js

@@ -0,0 +1 @@
+export { nextjs as default } from "@cprussin/jest-config";

+ 5 - 0
apps/staking/next-env.d.ts

@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.

+ 55 - 0
apps/staking/next.config.js

@@ -0,0 +1,55 @@
+export default {
+  reactStrictMode: true,
+
+  pageExtensions: ["ts", "tsx", "mdx"],
+
+  logging: {
+    fetches: {
+      fullUrl: true,
+    },
+  },
+
+  webpack(config) {
+    config.module.rules.push({
+      test: /\.svg$/i,
+      use: ["@svgr/webpack"],
+    });
+
+    config.resolve.extensionAlias = {
+      ".js": [".js", ".ts", ".tsx"],
+    };
+
+    return config;
+  },
+
+  transpilePackages: ["@pythnetwork/*"],
+
+  headers: () => [
+    {
+      source: "/:path*",
+      headers: [
+        {
+          key: "X-XSS-Protection",
+          value: "1; mode=block",
+        },
+        {
+          key: "Referrer-Policy",
+          value: "strict-origin-when-cross-origin",
+        },
+        {
+          key: "Strict-Transport-Security",
+          value: "max-age=2592000",
+        },
+        {
+          key: "X-Content-Type-Options",
+          value: "nosniff",
+        },
+        {
+          key: "Permissions-Policy",
+          value:
+            "vibrate=(), geolocation=(), midi=(), notifications=(), push=(), sync-xhr=(), microphone=(), camera=(), magnetometer=(), gyroscope=(), speaker=(), vibrate=(), fullscreen=self",
+        },
+      ],
+    },
+  ],
+};

+ 63 - 0
apps/staking/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@pythnetwork/staking",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "engines": {
+    "node": "18"
+  },
+  "scripts": {
+    "build": "next build",
+    "fix": "pnpm fix:lint && pnpm fix:format",
+    "fix:format": "prettier --write .",
+    "fix:lint": "eslint --fix .",
+    "pull:env": "VERCEL_ORG_ID=team_BKQrg3JJFLxZyTqpuYtIY0rj VERCEL_PROJECT_ID= vercel env pull",
+    "start:dev": "next dev",
+    "start:prod": "next start",
+    "test": "tsc && jest",
+    "test:format": "jest --selectProjects format",
+    "test:lint": "jest --selectProjects lint",
+    "test:types": "tsc",
+    "test:unit": "jest --selectProjects unit"
+  },
+  "dependencies": {
+    "@amplitude/analytics-browser": "^2.9.3",
+    "@amplitude/plugin-autocapture-browser": "^0.9.0",
+    "@bonfida/spl-name-service": "^3.0.0",
+    "@headlessui/react": "^2.1.2",
+    "@heroicons/react": "^2.1.4",
+    "@next/third-parties": "^14.2.5",
+    "@solana/wallet-adapter-base": "^0.9.20",
+    "@solana/wallet-adapter-react": "^0.15.28",
+    "@solana/wallet-adapter-react-ui": "^0.9.27",
+    "@solana/wallet-adapter-wallets": "0.19.10",
+    "@solana/web3.js": "^1.95.2",
+    "clsx": "^2.1.1",
+    "next": "^14.2.5",
+    "pino": "^9.3.2",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "zod": "^3.23.8"
+  },
+  "devDependencies": {
+    "@axe-core/react": "^4.9.1",
+    "@cprussin/eslint-config": "^3.0.0",
+    "@cprussin/jest-config": "^1.4.1",
+    "@cprussin/prettier-config": "^2.1.1",
+    "@cprussin/tsconfig": "^3.0.1",
+    "@svgr/webpack": "^8.1.0",
+    "@tailwindcss/forms": "^0.5.7",
+    "@types/jest": "^29.5.12",
+    "@types/node": "^22.0.0",
+    "@types/react": "^18.3.3",
+    "@types/react-dom": "^18.3.0",
+    "autoprefixer": "^10.4.19",
+    "eslint": "^9.8.0",
+    "jest": "^29.7.0",
+    "postcss": "^8.4.40",
+    "prettier": "^3.3.2",
+    "tailwindcss": "^3.4.7",
+    "typescript": "^5.5.4",
+    "vercel": "^35.2.2"
+  }
+}

+ 6 - 0
apps/staking/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    autoprefixer: {},
+    tailwindcss: {},
+  },
+};

+ 9 - 0
apps/staking/prettier.config.js

@@ -0,0 +1,9 @@
+import { fileURLToPath } from "node:url";
+
+import { base, tailwind, mergeConfigs } from "@cprussin/prettier-config";
+
+const tailwindConfig = fileURLToPath(
+  import.meta.resolve(`./tailwind.config.ts`),
+);
+
+export default mergeConfigs([base, tailwind(tailwindConfig)]);

BIN
apps/staking/public/android-chrome-192x192.png


BIN
apps/staking/public/android-chrome-512x512.png


BIN
apps/staking/public/apple-touch-icon.png


BIN
apps/staking/public/favicon-16x16.png


BIN
apps/staking/public/favicon-32x32.png


BIN
apps/staking/public/favicon-light.ico


BIN
apps/staking/public/favicon.ico


+ 205 - 0
apps/staking/src/api.ts

@@ -0,0 +1,205 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+
+import type { WalletContextState } from "@solana/wallet-adapter-react";
+import type { Connection } from "@solana/web3.js";
+
+const MOCK_DELAY = 500;
+
+const MOCK_DATA: Data = {
+  total: 15_000_000n,
+  availableRewards: 156_000n,
+  locked: 3_000_000n,
+  walletAmount: 200_000_000n,
+  governance: {
+    warmup: 2_670_000n,
+    staked: 4_150_000n,
+    cooldown: 1_850_000n,
+    cooldown2: 4_765_000n,
+  },
+  integrityStakingPublishers: [
+    {
+      name: "Foo Bar",
+      publicKey: "0xF00",
+      selfStake: 5_000_000_000n,
+      poolCapacity: 500_000_000n,
+      poolUtilization: 200_000_000n,
+      apy: 20,
+      numFeeds: 42,
+      qualityRanking: 1,
+      positions: {
+        warmup: 5_000_000n,
+        staked: 4_000_000n,
+        cooldown: 1_000_000n,
+        cooldown2: 460_000n,
+      },
+    },
+    {
+      name: "Jump Trading",
+      publicKey: "0xBA4",
+      selfStake: 400_000_000n,
+      poolCapacity: 500_000_000n,
+      poolUtilization: 600_000_000n,
+      apy: 10,
+      numFeeds: 84,
+      qualityRanking: 2,
+      positions: {
+        staked: 1_000_000n,
+      },
+    },
+  ],
+};
+
+type Data = {
+  total: bigint;
+  availableRewards: bigint;
+  locked: bigint;
+  walletAmount: bigint;
+  governance: {
+    warmup: bigint;
+    staked: bigint;
+    cooldown: bigint;
+    cooldown2: bigint;
+  };
+  integrityStakingPublishers: {
+    name: string;
+    publicKey: string;
+    selfStake: bigint;
+    poolCapacity: bigint;
+    poolUtilization: bigint;
+    apy: number;
+    numFeeds: number;
+    qualityRanking: number;
+    positions?:
+      | {
+          warmup?: bigint | undefined;
+          staked?: bigint | undefined;
+          cooldown?: bigint | undefined;
+          cooldown2?: bigint | undefined;
+        }
+      | undefined;
+  }[];
+};
+
+export const loadData = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  _signal?: AbortSignal | undefined,
+): Promise<Data> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  return MOCK_DATA;
+};
+
+export const deposit = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  MOCK_DATA.total += amount;
+  MOCK_DATA.walletAmount -= amount;
+};
+
+export const withdraw = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  MOCK_DATA.total -= amount;
+  MOCK_DATA.walletAmount += amount;
+};
+
+export const claim = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  MOCK_DATA.total += MOCK_DATA.availableRewards;
+  MOCK_DATA.availableRewards = 0n;
+};
+
+export const stakeGovernance = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  MOCK_DATA.governance.warmup += amount;
+};
+
+export const cancelWarmupGovernance = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  MOCK_DATA.governance.warmup -= amount;
+};
+
+export const unstakeGovernance = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  MOCK_DATA.governance.staked -= amount;
+  MOCK_DATA.governance.cooldown += amount;
+};
+
+export const delegateIntegrityStaking = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  publisherKey: string,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  const publisher = MOCK_DATA.integrityStakingPublishers.find(
+    (publisher) => publisher.publicKey === publisherKey,
+  );
+  if (publisher) {
+    publisher.positions ||= {};
+    publisher.positions.warmup = (publisher.positions.warmup ?? 0n) + amount;
+  } else {
+    throw new Error(`Invalid publisher key: "${publisherKey}"`);
+  }
+};
+
+export const cancelWarmupIntegrityStaking = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  publisherKey: string,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  const publisher = MOCK_DATA.integrityStakingPublishers.find(
+    (publisher) => publisher.publicKey === publisherKey,
+  );
+  if (publisher) {
+    if (publisher.positions?.warmup) {
+      publisher.positions.warmup -= amount;
+    }
+  } else {
+    throw new Error(`Invalid publisher key: "${publisherKey}"`);
+  }
+};
+
+export const unstakeIntegrityStaking = async (
+  _connection: Connection,
+  _wallet: WalletContextState,
+  publisherKey: string,
+  amount: bigint,
+): Promise<void> => {
+  await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
+  const publisher = MOCK_DATA.integrityStakingPublishers.find(
+    (publisher) => publisher.publicKey === publisherKey,
+  );
+  if (publisher) {
+    if (publisher.positions?.staked) {
+      publisher.positions.staked -= amount;
+      publisher.positions.cooldown =
+        (publisher.positions.cooldown ?? 0n) + amount;
+    }
+  } else {
+    throw new Error(`Invalid publisher key: "${publisherKey}"`);
+  }
+};

+ 3 - 0
apps/staking/src/app/error.tsx

@@ -0,0 +1,3 @@
+"use client";
+
+export { Error as default } from "../components/Error";

+ 14 - 0
apps/staking/src/app/global-error.tsx

@@ -0,0 +1,14 @@
+"use client";
+
+import type { ComponentProps } from "react";
+
+import { Error } from "../components/Error";
+
+const GlobalError = (props: ComponentProps<typeof Error>) => (
+  <html lang="en" dir="ltr">
+    <body>
+      <Error {...props} />
+    </body>
+  </html>
+);
+export default GlobalError;

+ 5 - 0
apps/staking/src/app/layout.tsx

@@ -0,0 +1,5 @@
+import "@solana/wallet-adapter-react-ui/styles.css";
+import "../tailwind.css";
+
+export { Root as default } from "../components/Root";
+export { metadata, viewport } from "../metadata";

+ 20 - 0
apps/staking/src/app/manifest.json

@@ -0,0 +1,20 @@
+{
+  "name": "Pyth Network Staking & Delegation",
+  "short_name": "Pyth Network Staking & Delegation",
+  "description": "Stake PYTH tokens to participate in governance or earn yield and protect DeFi by staking to publishers.",
+  "icons": [
+    {
+      "src": "/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png"
+    },
+    {
+      "src": "/android-chrome-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ],
+  "theme_color": "#242235",
+  "background_color": "#242235",
+  "display": "standalone"
+}

+ 1 - 0
apps/staking/src/app/not-found.tsx

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

BIN
apps/staking/src/app/opengraph-image.png


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

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

+ 11 - 0
apps/staking/src/app/robots.ts

@@ -0,0 +1,11 @@
+import type { MetadataRoute } from "next";
+
+import { IS_PRODUCTION_SERVER } from "../server-config";
+
+const robots = (): MetadataRoute.Robots => ({
+  rules: {
+    userAgent: "*",
+    ...(IS_PRODUCTION_SERVER ? { allow: "/" } : { disallow: "/" }),
+  },
+});
+export default robots;

+ 22 - 0
apps/staking/src/components/Button/index.tsx

@@ -0,0 +1,22 @@
+import type { ButtonHTMLAttributes } from "react";
+
+import { Styled } from "../Styled";
+
+type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
+  loading?: boolean | undefined;
+};
+
+const ButtonBase = ({ loading, disabled, children, ...props }: Props) => (
+  <button
+    disabled={loading === true || disabled === true}
+    {...(loading && { "data-loading": true })}
+    {...props}
+  >
+    {children}
+  </button>
+);
+
+export const Button = Styled(
+  ButtonBase,
+  "border border-pythpurple-600 px-2 py-0.5 bg-black/10 disabled:cursor-not-allowed disabled:bg-black/20 disabled:border-black/40 disabled:text-neutral-700 disabled:data-[loading]:cursor-wait",
+);

+ 85 - 0
apps/staking/src/components/Dashboard/index.tsx

@@ -0,0 +1,85 @@
+"use client";
+
+import { ArrowPathIcon } from "@heroicons/react/24/outline";
+import { useWallet, useConnection } from "@solana/wallet-adapter-react";
+import { type ComponentProps, useCallback, useEffect, useState } from "react";
+
+import { DashboardLoaded } from "./loaded";
+import { loadData } from "../../api";
+
+export const Dashboard = () => {
+  const { data, replaceData } = useDashboardData();
+
+  switch (data.type) {
+    case DataStateType.NotLoaded:
+    case DataStateType.Loading: {
+      return <ArrowPathIcon className="size-6 animate-spin" />;
+    }
+    case DataStateType.Error: {
+      return <p>Uh oh, an error occured!</p>;
+    }
+    case DataStateType.Loaded: {
+      return <DashboardLoaded {...data.data} replaceData={replaceData} />;
+    }
+  }
+};
+
+type DashboardData = Omit<
+  ComponentProps<typeof DashboardLoaded>,
+  "replaceData"
+>;
+
+const useDashboardData = () => {
+  const [data, setData] = useState<DataState>(DataState.NotLoaded());
+  const wallet = useWallet();
+  const { connection } = useConnection();
+
+  const replaceData = useCallback(
+    (newData: DashboardData) => {
+      setData(DataState.Loaded(newData));
+    },
+    [setData],
+  );
+
+  useEffect(() => {
+    if (data.type === DataStateType.NotLoaded) {
+      setData(DataState.Loading());
+      const abortController = new AbortController();
+      loadData(connection, wallet, abortController.signal)
+        .then((data) => {
+          setData(DataState.Loaded(data));
+        })
+        .catch((error: unknown) => {
+          setData(DataState.ErrorState(error));
+        });
+      return () => {
+        abortController.abort();
+      };
+    } else {
+      return;
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  return { data, replaceData };
+};
+
+enum DataStateType {
+  NotLoaded,
+  Loading,
+  Loaded,
+  Error,
+}
+const DataState = {
+  NotLoaded: () => ({ type: DataStateType.NotLoaded as const }),
+  Loading: () => ({ type: DataStateType.Loading as const }),
+  Loaded: (data: DashboardData) => ({
+    type: DataStateType.Loaded as const,
+    data,
+  }),
+  ErrorState: (error: unknown) => ({
+    type: DataStateType.Error as const,
+    error,
+  }),
+};
+type DataState = ReturnType<(typeof DataState)[keyof typeof DataState]>;

+ 613 - 0
apps/staking/src/components/Dashboard/loaded.tsx

@@ -0,0 +1,613 @@
+import type { WalletContextState } from "@solana/wallet-adapter-react";
+import type { Connection } from "@solana/web3.js";
+import clsx from "clsx";
+import { type ReactNode, useMemo, useCallback } from "react";
+
+import {
+  deposit,
+  withdraw,
+  stakeGovernance,
+  cancelWarmupGovernance,
+  unstakeGovernance,
+  delegateIntegrityStaking,
+  cancelWarmupIntegrityStaking,
+  unstakeIntegrityStaking,
+  claim,
+} from "../../api";
+import { StateType, useTransfer } from "../../use-transfer";
+import { Button } from "../Button";
+import { Tokens } from "../Tokens";
+import { TransferButton } from "../TransferButton";
+
+type Props = {
+  replaceData: (newData: Omit<Props, "replaceData">) => void;
+  total: bigint;
+  walletAmount: bigint;
+  availableRewards: bigint;
+  locked: bigint;
+  governance: {
+    warmup: bigint;
+    staked: bigint;
+    cooldown: bigint;
+    cooldown2: bigint;
+  };
+  integrityStakingPublishers: Omit<
+    PublisherProps,
+    "availableToStake" | "replaceData"
+  >[];
+};
+
+export const DashboardLoaded = ({
+  total,
+  walletAmount,
+  availableRewards,
+  governance,
+  integrityStakingPublishers,
+  locked,
+  replaceData,
+}: Props) => {
+  const availableToStakeGovernance = useMemo(
+    () =>
+      total -
+      governance.warmup -
+      governance.staked -
+      governance.cooldown -
+      governance.cooldown2,
+    [
+      total,
+      governance.warmup,
+      governance.staked,
+      governance.cooldown,
+      governance.cooldown2,
+    ],
+  );
+
+  const integrityStakingWarmup = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "warmup",
+  );
+  const integrityStakingStaked = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "staked",
+  );
+  const integrityStakingCooldown = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "cooldown",
+  );
+  const integrityStakingCooldown2 = useIntegrityStakingSum(
+    integrityStakingPublishers,
+    "cooldown2",
+  );
+
+  const availableToStakeIntegrity = useMemo(
+    () =>
+      total -
+      locked -
+      integrityStakingWarmup -
+      integrityStakingStaked -
+      integrityStakingCooldown -
+      integrityStakingCooldown2,
+    [
+      total,
+      locked,
+      integrityStakingWarmup,
+      integrityStakingStaked,
+      integrityStakingCooldown,
+      integrityStakingCooldown2,
+    ],
+  );
+
+  const availableToWithdraw = useMemo(
+    () => bigIntMin(availableToStakeGovernance, availableToStakeIntegrity),
+    [availableToStakeGovernance, availableToStakeIntegrity],
+  );
+
+  return (
+    <>
+      <div className="flex w-full flex-col gap-8 bg-pythpurple-100 p-8">
+        <div className="flex flex-row gap-16">
+          <BalanceCategory
+            name="Total balance"
+            actions={
+              <TransferButton
+                actionDescription="Add funds to your balance"
+                actionName="Deposit"
+                max={walletAmount}
+                replaceData={replaceData}
+                transfer={deposit}
+              >
+                <strong>In wallet:</strong> <Tokens>{walletAmount}</Tokens>
+              </TransferButton>
+            }
+          >
+            {total}
+          </BalanceCategory>
+          <BalanceCategory
+            name="Available to withdraw"
+            description="The lesser of the amount you have available to stake in governance & integrity staking"
+            {...(availableToWithdraw > 0 && {
+              actions: (
+                <TransferButton
+                  actionDescription="Move funds from your account back to your wallet"
+                  actionName="Withdraw"
+                  max={availableToWithdraw}
+                  replaceData={replaceData}
+                  transfer={withdraw}
+                >
+                  <strong>Available to withdraw:</strong>{" "}
+                  <Tokens>{availableToWithdraw}</Tokens>
+                </TransferButton>
+              ),
+            })}
+          >
+            {availableToWithdraw}
+          </BalanceCategory>
+          <BalanceCategory
+            name="Claimable rewards"
+            description="Rewards you have earned but not yet claimed from the Integrity Staking program"
+            {...(availableRewards > 0 && {
+              actions: <ClaimButton replaceData={replaceData} />,
+            })}
+          >
+            {availableRewards}
+          </BalanceCategory>
+        </div>
+        <div className="flex flex-col items-stretch justify-between gap-8">
+          <section className="bg-black/10 p-4">
+            <h2 className="text-2xl font-semibold">Governance</h2>
+            <p>Vote and Influence the Network</p>
+            <div className="mt-2 flex flex-row items-stretch justify-center">
+              <Position
+                className="bg-pythpurple-600/10"
+                name="Available to Stake"
+                actions={
+                  <TransferButton
+                    actionDescription="Stake funds to participate in governance votes"
+                    actionName="Stake"
+                    max={availableToStakeGovernance}
+                    replaceData={replaceData}
+                    transfer={stakeGovernance}
+                  >
+                    <strong>Available to stake:</strong>{" "}
+                    <Tokens>{availableToStakeGovernance}</Tokens>
+                  </TransferButton>
+                }
+              >
+                {availableToStakeGovernance}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/15"
+                name="Warmup"
+                actions={
+                  <TransferButton
+                    actionDescription="Cancel staking tokens for governance that are currently in warmup"
+                    actionName="Cancel"
+                    submitButtonText="Cancel Warmup"
+                    title="Cancel Governance Staking"
+                    max={governance.warmup}
+                    replaceData={replaceData}
+                    transfer={cancelWarmupGovernance}
+                  >
+                    <strong>Max:</strong> <Tokens>{governance.warmup}</Tokens>
+                  </TransferButton>
+                }
+                details={
+                  <div className="text-xs">Staking 2024-08-01T00:00Z</div>
+                }
+              >
+                {governance.warmup}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/20"
+                name="Staked"
+                actions={
+                  <TransferButton
+                    actionDescription="Unstake tokens from the Governance program"
+                    actionName="Unstake"
+                    title="Unstake From Governance"
+                    max={governance.staked}
+                    replaceData={replaceData}
+                    transfer={unstakeGovernance}
+                  >
+                    <strong>Max:</strong> <Tokens>{governance.staked}</Tokens>
+                  </TransferButton>
+                }
+              >
+                {governance.staked}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/25"
+                name="Cooldown (next epoch)"
+                details={<div className="text-xs">End 2024-08-01T00:00Z</div>}
+              >
+                {governance.cooldown}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/30"
+                name="Cooldown (this epoch)"
+                details={<div className="text-xs">End 2024-08-08T00:00Z</div>}
+              >
+                {governance.cooldown2}
+              </Position>
+            </div>
+          </section>
+          <section className="bg-black/10 p-4">
+            <h2 className="text-2xl font-semibold">Integrity Staking</h2>
+            <p>Protect DeFi, Earn Yield</p>
+            <div className="mt-2 flex flex-row items-stretch justify-center">
+              <Position className="bg-pythpurple-600/5" name="Locked">
+                {locked}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/10"
+                name="Available to Stake"
+              >
+                {availableToStakeIntegrity}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/15"
+                name="Warmup"
+                details={
+                  <div className="text-xs">Staking 2024-08-01T00:00Z</div>
+                }
+              >
+                {integrityStakingWarmup}
+              </Position>
+              <Position className="bg-pythpurple-600/20" name="Staked">
+                {integrityStakingStaked}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/25"
+                name="Cooldown (next epoch)"
+                details={<div className="text-xs">End 2024-08-01T00:00Z</div>}
+              >
+                {integrityStakingCooldown}
+              </Position>
+              <Position
+                className="bg-pythpurple-600/30"
+                name="Cooldown (this epoch)"
+                details={<div className="text-xs">End 2024-08-08T00:00Z</div>}
+              >
+                {integrityStakingCooldown2}
+              </Position>
+            </div>
+            <table className="mt-8 w-full text-left">
+              <caption className="mb-4 text-left text-xl">Publishers</caption>
+              <thead>
+                <tr>
+                  <th>Publisher</th>
+                  <th>Self stake</th>
+                  <th className="text-center">Pool</th>
+                  <th>Number of feeds</th>
+                  <th>Quality ranking</th>
+                </tr>
+              </thead>
+              <tbody>
+                {integrityStakingPublishers.map((publisher) => (
+                  <Publisher
+                    key={publisher.publicKey}
+                    availableToStake={availableToStakeIntegrity}
+                    replaceData={replaceData}
+                    {...publisher}
+                  />
+                ))}
+              </tbody>
+            </table>
+          </section>
+        </div>
+      </div>
+    </>
+  );
+};
+
+const useIntegrityStakingSum = (
+  publishers: Props["integrityStakingPublishers"],
+  field: "warmup" | "staked" | "cooldown" | "cooldown2",
+): bigint =>
+  useMemo(
+    () =>
+      publishers
+        .map((publisher) => publisher.positions?.[field] ?? 0n)
+        .reduce((acc, cur) => acc + cur, 0n),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    publishers.map((publisher) => publisher.positions?.[field]),
+  );
+
+type BalanceCategoryProps = {
+  children: bigint;
+  name: string;
+  description?: string | undefined;
+  actions?: ReactNode | ReactNode[];
+};
+
+const BalanceCategory = ({
+  children,
+  name,
+  description,
+  actions,
+}: BalanceCategoryProps) => (
+  <div className="flex w-1/3 flex-col items-start justify-between gap-2">
+    <div>
+      <div className="text-4xl font-semibold">
+        <Tokens>{children}</Tokens>
+      </div>
+      <div className="text-lg">{name}</div>
+      {description && (
+        <p className="max-w-xs text-xs font-light">{description}</p>
+      )}
+    </div>
+    {actions && <div>{actions}</div>}
+  </div>
+);
+
+type PositionProps = {
+  name: string;
+  className?: string | undefined;
+  children: bigint;
+  actions?: ReactNode | ReactNode[];
+  details?: ReactNode;
+};
+
+const Position = ({
+  name,
+  details,
+  className,
+  children,
+  actions,
+}: PositionProps) =>
+  children > 0n && (
+    <div
+      // style={{ width: `${100 * tokens / tokenData.total}%` }}
+      className={clsx(
+        "flex w-full flex-col justify-between gap-2 overflow-hidden p-2",
+        className,
+      )}
+    >
+      <div>
+        <div className="text-sm font-bold">{name}</div>
+        <div className="text-sm">
+          <Tokens>{children}</Tokens>
+        </div>
+        {details}
+      </div>
+      {actions && <div>{actions}</div>}
+    </div>
+  );
+
+type PublisherProps = {
+  availableToStake: bigint;
+  replaceData: Props["replaceData"];
+  name: string;
+  publicKey: string;
+  selfStake: bigint;
+  poolCapacity: bigint;
+  poolUtilization: bigint;
+  apy: number;
+  numFeeds: number;
+  qualityRanking: number;
+  positions?:
+    | {
+        warmup?: bigint | undefined;
+        staked?: bigint | undefined;
+        cooldown?: bigint | undefined;
+        cooldown2?: bigint | undefined;
+      }
+    | undefined;
+};
+
+const Publisher = ({
+  name,
+  publicKey,
+  selfStake,
+  poolUtilization,
+  poolCapacity,
+  apy,
+  numFeeds,
+  qualityRanking,
+  positions,
+  availableToStake,
+  replaceData,
+}: PublisherProps) => {
+  const delegate = useTransferActionForPublisher(
+    delegateIntegrityStaking,
+    publicKey,
+  );
+  const cancelWarmup = useTransferActionForPublisher(
+    cancelWarmupIntegrityStaking,
+    publicKey,
+  );
+  const unstake = useTransferActionForPublisher(
+    unstakeIntegrityStaking,
+    publicKey,
+  );
+  const utilizationPercent = useMemo(
+    () => Number((100n * poolUtilization) / poolCapacity),
+    [poolUtilization, poolCapacity],
+  );
+
+  return (
+    <>
+      <tr>
+        <td className="py-4">{name}</td>
+        <td>
+          <Tokens>{selfStake}</Tokens>
+        </td>
+        <td className="flex flex-row items-center justify-center gap-2 py-4">
+          <div className="relative grid h-8 w-60 place-content-center border border-black bg-pythpurple-600/10">
+            <div
+              style={{
+                width: `${utilizationPercent.toString()}%`,
+              }}
+              className={clsx(
+                "absolute inset-0 max-w-full",
+                poolUtilization > poolCapacity
+                  ? "bg-red-500"
+                  : "bg-pythpurple-400",
+              )}
+            />
+            <div
+              className={clsx(
+                "isolate flex flex-row items-center justify-center gap-1 text-sm",
+                { "text-white": poolUtilization > poolCapacity },
+              )}
+            >
+              <span>
+                <Tokens>{poolUtilization}</Tokens>
+              </span>
+              <span>/</span>
+              <span>
+                <Tokens>{poolCapacity}</Tokens>
+              </span>
+              <span>({utilizationPercent.toFixed(2)}%)</span>
+            </div>
+          </div>
+          <div className="flex flex-row items-center gap-1">
+            <div className="font-medium">APY:</div>
+            <div>{apy}%</div>
+          </div>
+        </td>
+        <td>{numFeeds}</td>
+        <td>{qualityRanking}</td>
+        {availableToStake > 0 && (
+          <td>
+            <TransferButton
+              actionDescription={`Stake to ${name}`}
+              actionName="Stake"
+              max={availableToStake}
+              replaceData={replaceData}
+              transfer={delegate}
+            >
+              <strong>Available to stake:</strong>{" "}
+              <Tokens>{availableToStake}</Tokens>
+            </TransferButton>
+          </td>
+        )}
+      </tr>
+      {positions && (
+        <tr>
+          <td colSpan={6} className="border-separate border-spacing-8">
+            <div className="mx-auto mb-8 w-fit bg-black/5 p-4">
+              <table className="w-full">
+                <caption className="mb-2 text-left text-xl">
+                  Your Positions
+                </caption>
+                <thead>
+                  <tr>
+                    <th>Status</th>
+                    <th>Amount</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <PublisherPosition
+                    name="Warmup"
+                    actions={
+                      <TransferButton
+                        actionDescription={`Cancel tokens that are in warmup for staking to ${name}`}
+                        actionName="Cancel"
+                        submitButtonText="Cancel Warmup"
+                        title="Cancel Staking"
+                        max={positions.warmup ?? 0n}
+                        replaceData={replaceData}
+                        transfer={cancelWarmup}
+                      >
+                        <strong>Max:</strong>{" "}
+                        <Tokens>{positions.warmup ?? 0n}</Tokens>
+                      </TransferButton>
+                    }
+                  >
+                    {positions.warmup}
+                  </PublisherPosition>
+                  <PublisherPosition
+                    name="Staked"
+                    actions={
+                      <TransferButton
+                        actionDescription={`Unstake tokens from ${name}`}
+                        actionName="Unstake"
+                        title="Unstake"
+                        max={positions.staked ?? 0n}
+                        replaceData={replaceData}
+                        transfer={unstake}
+                      >
+                        <strong>Max:</strong>{" "}
+                        <Tokens>{positions.staked ?? 0n}</Tokens>
+                      </TransferButton>
+                    }
+                  >
+                    {positions.staked}
+                  </PublisherPosition>
+                  <PublisherPosition name="Cooldown (this epoch)">
+                    {positions.cooldown}
+                  </PublisherPosition>
+                  <PublisherPosition name="Cooldown (next epoch)">
+                    {positions.cooldown2}
+                  </PublisherPosition>
+                </tbody>
+              </table>
+            </div>
+          </td>
+        </tr>
+      )}
+    </>
+  );
+};
+
+const useTransferActionForPublisher = (
+  action: (
+    connection: Connection,
+    wallet: WalletContextState,
+    publicKey: string,
+    amount: bigint,
+  ) => Promise<void>,
+  publicKey: string,
+) =>
+  useCallback(
+    (connection: Connection, wallet: WalletContextState, amount: bigint) =>
+      action(connection, wallet, publicKey, amount),
+    [action, publicKey],
+  );
+
+type PublisherPositionProps = {
+  name: string;
+  children: bigint | undefined;
+  actions?: ReactNode | ReactNode[];
+};
+
+const PublisherPosition = ({
+  children,
+  name,
+  actions,
+}: PublisherPositionProps) =>
+  children &&
+  children !== 0n && (
+    <tr>
+      <td className="pr-8">{name}</td>
+      <td className="pr-8">
+        <Tokens>{children}</Tokens>
+      </td>
+      {actions && <td>{actions}</td>}
+    </tr>
+  );
+
+// eslint-disable-next-line unicorn/no-array-reduce
+const bigIntMin = (...args: bigint[]) => args.reduce((m, e) => (e < m ? e : m));
+
+type ClaimButtonProps = {
+  replaceData: Props["replaceData"];
+};
+
+const ClaimButton = ({ replaceData }: ClaimButtonProps) => {
+  const { state, execute } = useTransfer(claim, replaceData);
+
+  return (
+    <Button
+      onClick={execute}
+      disabled={state.type !== StateType.Base}
+      loading={
+        state.type === StateType.LoadingData ||
+        state.type === StateType.Submitting
+      }
+    >
+      Claim
+    </Button>
+  );
+};

+ 13 - 0
apps/staking/src/components/Error/index.tsx

@@ -0,0 +1,13 @@
+type Props = {
+  error: Error & { digest?: string };
+  reset?: () => void;
+};
+
+export const Error = ({ error, reset }: Props) => (
+  <main>
+    <h1>Uh oh!</h1>
+    <h2>Something went wrong</h2>
+    <p>Error Code: {error.digest}</p>
+    {reset && <button onClick={reset}>Reset</button>}
+  </main>
+);

+ 52 - 0
apps/staking/src/components/Home/index.tsx

@@ -0,0 +1,52 @@
+"use client";
+
+import { ArrowPathIcon } from "@heroicons/react/24/outline";
+import { useWallet } from "@solana/wallet-adapter-react";
+import { useWalletModal } from "@solana/wallet-adapter-react-ui";
+import { useCallback } from "react";
+
+import { WalletButton } from "./wallet-button";
+import { useIsMounted } from "../../use-is-mounted";
+import { Button } from "../Button";
+import { Dashboard } from "../Dashboard";
+
+export const Home = () => (
+  <main className="px-8 py-16">
+    <h1 className="mb-8 text-4xl font-semibold text-pythpurple-600 dark:text-pythpurple-400">
+      Staking & Delegating
+    </h1>
+    <HomeContents />
+  </main>
+);
+
+const HomeContents = () => {
+  const isMounted = useIsMounted();
+  const wallet = useWallet();
+  const modal = useWalletModal();
+  const showModal = useCallback(() => {
+    modal.setVisible(true);
+  }, [modal]);
+  if (isMounted) {
+    return wallet.connected ? (
+      <>
+        <WalletButton />
+        <Dashboard />
+      </>
+    ) : (
+      <>
+        <p className="mx-auto mb-8 max-w-prose text-center">
+          The Pyth staking program allows you to stake tokens to participate in
+          governance, or to earn yield and protect DeFi by delegating to
+          publishers.
+        </p>
+        <div className="grid w-full place-content-center">
+          <Button onClick={showModal}>
+            Connect your wallet to participate
+          </Button>
+        </div>
+      </>
+    );
+  } else {
+    return <ArrowPathIcon className="size-6 animate-spin" />;
+  }
+};

+ 30 - 0
apps/staking/src/components/Home/wallet-button.tsx

@@ -0,0 +1,30 @@
+"use client";
+
+import { getPrimaryDomain } from "@bonfida/spl-name-service";
+import { useConnection, useWallet } from "@solana/wallet-adapter-react";
+import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
+import { type ComponentProps, useEffect, useState } from "react";
+
+export const WalletButton = (
+  props: ComponentProps<typeof WalletMultiButton>,
+) => {
+  const wallet = useWallet();
+  const { connection } = useConnection();
+  const [primaryDomain, setPrimaryDomain] = useState<string | undefined>(
+    undefined,
+  );
+
+  useEffect(() => {
+    if (wallet.publicKey) {
+      getPrimaryDomain(connection, wallet.publicKey)
+        .then((domain) => {
+          setPrimaryDomain(`${domain.reverse}.sol`);
+        })
+        .catch(() => {
+          /* no-op, no worries if we can't show a SNS domain */
+        });
+    }
+  }, [wallet, connection]);
+
+  return <WalletMultiButton {...props}>{primaryDomain}</WalletMultiButton>;
+};

+ 74 - 0
apps/staking/src/components/Modal/index.tsx

@@ -0,0 +1,74 @@
+import {
+  Dialog,
+  DialogBackdrop,
+  DialogTitle,
+  Description,
+  DialogPanel,
+  CloseButton,
+} from "@headlessui/react";
+import { XMarkIcon } from "@heroicons/react/24/outline";
+import type { ReactNode } from "react";
+
+import { Button } from "../Button";
+
+type Props = {
+  open: boolean;
+  onClose: () => void;
+  closeDisabled?: boolean | undefined;
+  afterLeave?: (() => void) | undefined;
+  children?: ReactNode | ReactNode[] | undefined;
+  title: string;
+  description?: string;
+  additionalButtons?: ReactNode | ReactNode[] | undefined;
+};
+
+export const Modal = ({
+  open,
+  onClose,
+  closeDisabled,
+  children,
+  title,
+  description,
+  additionalButtons,
+}: Props) => (
+  <Dialog open={open} onClose={onClose} className="relative z-50">
+    <DialogBackdrop
+      transition
+      className="fixed inset-0 bg-black/30 duration-300 ease-out data-[closed]:opacity-0"
+    />
+    <div className="fixed inset-0 flex w-screen items-center justify-center p-4">
+      <DialogPanel
+        transition
+        className="relative max-w-lg rounded-md bg-white p-8 duration-300 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
+      >
+        <DialogTitle
+          as="h1"
+          className="text-lg font-medium leading-6 text-neutral-800 dark:text-neutral-200 md:text-xl lg:text-2xl"
+        >
+          {title}
+        </DialogTitle>
+        {closeDisabled !== true && (
+          <CloseButton className="absolute right-3 top-3 rounded-md p-2 text-neutral-500 transition hover:bg-black/10 dark:hover:bg-white/5">
+            <XMarkIcon className="size-5" />
+          </CloseButton>
+        )}
+        {description && (
+          <Description className="mb-10 mt-2 text-sm text-neutral-500 dark:text-neutral-400">
+            {description}
+          </Description>
+        )}
+        {children}
+        <div className="mt-8 flex flex-row justify-end gap-4 text-right">
+          <CloseButton
+            as={Button}
+            className="px-4 py-2"
+            disabled={closeDisabled ?? false}
+          >
+            Close
+          </CloseButton>
+          {additionalButtons}
+        </div>
+      </DialogPanel>
+    </div>
+  </Dialog>
+);

+ 15 - 0
apps/staking/src/components/NotFound/index.tsx

@@ -0,0 +1,15 @@
+import Link from "next/link";
+
+export const NotFound = () => (
+  <main className="grid size-full place-content-center text-center">
+    <h1 className="mb-8 text-6xl font-semibold text-pythpurple-600 dark:text-pythpurple-400">
+      Not Found
+    </h1>
+    <p className="mb-20 text-lg font-medium">
+      {"The page you're looking for isn't here"}
+    </p>
+    <Link className="place-self-center px-24 py-3" href="/">
+      Go Home
+    </Link>
+  </main>
+);

+ 26 - 0
apps/staking/src/components/Root/amplitude.tsx

@@ -0,0 +1,26 @@
+"use client";
+
+import * as amplitude from "@amplitude/analytics-browser";
+import { autocapturePlugin } from "@amplitude/plugin-autocapture-browser";
+import { useEffect, useRef } from "react";
+
+type Props = {
+  apiKey: string | undefined;
+};
+
+export const Amplitude = ({ apiKey }: Props) => {
+  const amplitudeInitialized = useRef(false);
+
+  useEffect(() => {
+    if (!amplitudeInitialized.current && apiKey) {
+      amplitude.add(autocapturePlugin());
+      amplitude.init(apiKey, {
+        defaultTracking: true,
+      });
+      amplitudeInitialized.current = true;
+    }
+  }, [apiKey]);
+
+  // eslint-disable-next-line unicorn/no-null
+  return null;
+};

+ 52 - 0
apps/staking/src/components/Root/index.tsx

@@ -0,0 +1,52 @@
+import { GoogleAnalytics } from "@next/third-parties/google";
+import clsx from "clsx";
+import { Red_Hat_Text, Red_Hat_Mono } from "next/font/google";
+import type { ReactNode } from "react";
+
+import { Amplitude } from "./amplitude";
+import { ReportAccessibility } from "./report-accessibility";
+import { WalletProvider } from "./wallet-provider";
+import { LoggerProvider } from "../../logger";
+import {
+  IS_PRODUCTION_SERVER,
+  GOOGLE_ANALYTICS_ID,
+  AMPLITUDE_API_KEY,
+  WALLETCONNECT_PROJECT_ID,
+  MAINNET_RPC,
+} from "../../server-config";
+
+const redHatText = Red_Hat_Text({
+  subsets: ["latin"],
+  variable: "--font-sans",
+});
+
+const redHatMono = Red_Hat_Mono({
+  subsets: ["latin"],
+  variable: "--font-mono",
+});
+
+type Props = {
+  children: ReactNode;
+};
+
+export const Root = ({ children }: Props) => (
+  <LoggerProvider>
+    <WalletProvider
+      walletConnectProjectId={WALLETCONNECT_PROJECT_ID}
+      rpc={MAINNET_RPC}
+    >
+      <html
+        lang="en"
+        dir="ltr"
+        className={clsx("h-dvh", redHatText.variable, redHatMono.variable)}
+      >
+        <body className="grid size-full grid-cols-1 grid-rows-[max-content_1fr_max-content] bg-white text-pythpurple-950 dark:bg-pythpurple-900 dark:text-white">
+          {children}
+        </body>
+        {GOOGLE_ANALYTICS_ID && <GoogleAnalytics gaId={GOOGLE_ANALYTICS_ID} />}
+        {AMPLITUDE_API_KEY && <Amplitude apiKey={AMPLITUDE_API_KEY} />}
+        {!IS_PRODUCTION_SERVER && <ReportAccessibility />}
+      </html>
+    </WalletProvider>
+  </LoggerProvider>
+);

+ 22 - 0
apps/staking/src/components/Root/report-accessibility.tsx

@@ -0,0 +1,22 @@
+"use client";
+
+import { useEffect } from "react";
+import * as React from "react";
+import * as ReactDOM from "react-dom";
+
+import { useLogger } from "../../logger";
+
+export const ReportAccessibility = () => {
+  const logger = useLogger();
+
+  useEffect(() => {
+    import("@axe-core/react")
+      .then((axe) => axe.default(React, ReactDOM, 1000))
+      .catch((error: unknown) => {
+        logger.error("Error setting up axe for accessibility testing", error);
+      });
+  }, [logger]);
+
+  // eslint-disable-next-line unicorn/no-null
+  return null;
+};

+ 77 - 0
apps/staking/src/components/Root/wallet-provider.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
+import {
+  ConnectionProvider,
+  WalletProvider as WalletProviderImpl,
+} from "@solana/wallet-adapter-react";
+import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
+import {
+  GlowWalletAdapter,
+  LedgerWalletAdapter,
+  PhantomWalletAdapter,
+  SolflareWalletAdapter,
+  SolletExtensionWalletAdapter,
+  SolletWalletAdapter,
+  TorusWalletAdapter,
+  WalletConnectWalletAdapter,
+} from "@solana/wallet-adapter-wallets";
+import { clusterApiUrl } from "@solana/web3.js";
+import { type ReactNode, useMemo } from "react";
+
+import { metadata } from "../../metadata";
+
+type Props = {
+  children?: ReactNode | ReactNode[] | undefined;
+  walletConnectProjectId?: string | undefined;
+  rpc?: string | undefined;
+};
+
+export const WalletProvider = ({
+  children,
+  walletConnectProjectId,
+  rpc,
+}: Props) => {
+  const endpoint = useMemo(
+    () => rpc ?? clusterApiUrl(WalletAdapterNetwork.Mainnet),
+    [rpc],
+  );
+
+  const wallets = useMemo(
+    () => [
+      new GlowWalletAdapter(),
+      new LedgerWalletAdapter(),
+      new PhantomWalletAdapter(),
+      new SolflareWalletAdapter(),
+      new SolletExtensionWalletAdapter(),
+      new SolletWalletAdapter(),
+      new TorusWalletAdapter(),
+      ...(walletConnectProjectId
+        ? [
+            new WalletConnectWalletAdapter({
+              network: WalletAdapterNetwork.Mainnet,
+              options: {
+                relayUrl: "wss://relay.walletconnect.com",
+                projectId: walletConnectProjectId,
+                metadata: {
+                  name: metadata.applicationName,
+                  description: metadata.description,
+                  url: metadata.metadataBase.toString(),
+                  icons: ["https://pyth.network/token.svg"],
+                },
+              },
+            }),
+          ]
+        : []),
+    ],
+    [walletConnectProjectId],
+  );
+
+  return (
+    <ConnectionProvider endpoint={endpoint}>
+      <WalletProviderImpl wallets={wallets} autoConnect>
+        <WalletModalProvider>{children}</WalletModalProvider>
+      </WalletProviderImpl>
+    </ConnectionProvider>
+  );
+};

+ 18 - 0
apps/staking/src/components/Styled/index.tsx

@@ -0,0 +1,18 @@
+import clsx from "clsx";
+import type { ComponentProps, ElementType } from "react";
+
+type StyledProps<T extends ElementType> = Omit<ComponentProps<T>, "as"> & {
+  as?: T;
+};
+
+export const Styled = (defaultElement: ElementType, classes: string) => {
+  const StyledComponent = <T extends ElementType = typeof defaultElement>({
+    as,
+    className,
+    ...props
+  }: StyledProps<T>) => {
+    const Component = as ?? defaultElement;
+    return <Component className={clsx(classes, className)} {...props} />;
+  };
+  return StyledComponent;
+};

+ 19 - 0
apps/staking/src/components/Tokens/index.tsx

@@ -0,0 +1,19 @@
+import { useMemo } from "react";
+
+import Pyth from "./pyth.svg";
+import { tokensToString } from "../../tokens";
+
+type Props = {
+  children: bigint;
+};
+
+export const Tokens = ({ children }: Props) => {
+  const value = useMemo(() => tokensToString(children), [children]);
+
+  return (
+    <span className="inline-flex items-center gap-0.5 align-top">
+      <Pyth className="aspect-square h-[1em]" />
+      <span>{value}</span>
+    </span>
+  );
+};

+ 5 - 0
apps/staking/src/components/Tokens/pyth.svg

@@ -0,0 +1,5 @@
+<svg viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1000 500C1000 776.142 776.142 1000 500 1000C223.858 1000 0 776.142 0 500C0 223.858 223.858 0 500 0C776.142 0 1000 223.858 1000 500Z" fill="#E6DAFE"/>
+<path d="M575.336 459.157C575.336 494.675 546.559 523.473 511.068 523.473V587.789C582.05 587.789 639.604 530.193 639.604 459.157C639.604 388.122 582.05 330.526 511.068 330.526C487.669 330.526 465.695 336.78 446.801 347.746C408.374 369.97 382.533 411.539 382.533 459.157V780.736L446.801 845.052V459.157C446.801 423.64 475.577 394.841 511.068 394.841C546.559 394.841 575.336 423.64 575.336 459.157Z" fill="#242235"/>
+<path d="M511.07 201.904C464.243 201.904 420.352 214.442 382.535 236.346C358.322 250.338 336.638 268.169 318.268 289.026C278.271 334.376 254 393.95 254 459.168V652.115L318.268 716.431V459.168C318.268 402.037 343.091 350.695 382.535 315.351C401.08 298.771 422.851 285.681 446.803 277.245C466.888 270.089 488.543 266.22 511.07 266.22C617.543 266.22 703.873 352.614 703.873 459.168C703.873 565.721 617.543 652.115 511.07 652.115V716.431C653.063 716.431 768.14 601.238 768.14 459.168C768.14 317.097 653.063 201.904 511.07 201.904Z" fill="#242235"/>
+</svg>

+ 123 - 0
apps/staking/src/components/TransferButton/index.tsx

@@ -0,0 +1,123 @@
+import { type WalletContextState } from "@solana/wallet-adapter-react";
+import type { Connection } from "@solana/web3.js";
+import {
+  type ChangeEvent,
+  type ComponentProps,
+  type ReactNode,
+  useCallback,
+  useMemo,
+  useState,
+} from "react";
+
+import { stringToTokens } from "../../tokens";
+import { StateType, useTransfer } from "../../use-transfer";
+import { Button } from "../Button";
+import type { DashboardLoaded } from "../Dashboard/loaded";
+import { Modal } from "../Modal";
+
+type Props = {
+  actionName: string;
+  actionDescription: string;
+  title?: string | undefined;
+  submitButtonText?: string | undefined;
+  max: bigint;
+  replaceData: ComponentProps<typeof DashboardLoaded>["replaceData"];
+  children?: ReactNode | ReactNode[] | undefined;
+  transfer: (
+    connection: Connection,
+    wallet: WalletContextState,
+    amount: bigint,
+  ) => Promise<void>;
+};
+
+export const TransferButton = ({
+  actionName,
+  submitButtonText,
+  actionDescription,
+  title,
+  max,
+  replaceData,
+  transfer,
+  children,
+}: Props) => {
+  const [isOpen, setIsOpen] = useState(false);
+  const [amountInput, setAmountInput] = useState<string>("");
+
+  const updateAmount = useCallback(
+    (event: ChangeEvent<HTMLInputElement>) => {
+      setAmountInput(event.target.value);
+    },
+    [setAmountInput],
+  );
+
+  const amount = useMemo(() => {
+    const amount = stringToTokens(amountInput);
+    return amount !== undefined && amount <= max && amount > 0n
+      ? amount
+      : undefined;
+  }, [amountInput, max]);
+
+  const doTransfer = useCallback(
+    (connection: Connection, wallet: WalletContextState) =>
+      amount === undefined
+        ? Promise.reject(new InvalidAmountError())
+        : transfer(connection, wallet, amount),
+    [amount, transfer],
+  );
+
+  const close = useCallback(() => {
+    setAmountInput("");
+    setIsOpen(false);
+  }, [setAmountInput, setIsOpen]);
+
+  const { state, execute } = useTransfer(doTransfer, replaceData, (reset) => {
+    close();
+    reset();
+  });
+
+  const isLoading = useMemo(
+    () =>
+      state.type === StateType.Submitting ||
+      state.type === StateType.LoadingData,
+    [state],
+  );
+
+  const open = useCallback(() => {
+    setIsOpen(true);
+  }, [setIsOpen]);
+
+  const closeUnlessLoading = useCallback(() => {
+    if (!isLoading) {
+      close();
+    }
+  }, [isLoading, close]);
+
+  return (
+    <>
+      <Button onClick={open}>{actionName}</Button>
+      <Modal
+        open={isOpen}
+        onClose={closeUnlessLoading}
+        closeDisabled={isLoading}
+        title={title ?? actionName}
+        description={actionDescription}
+        additionalButtons={
+          <Button
+            disabled={amount === undefined}
+            onClick={execute}
+            loading={isLoading}
+          >
+            {submitButtonText ?? actionName}
+          </Button>
+        }
+      >
+        <input name="amount" value={amountInput} onChange={updateAmount} />
+        {children && <div>{children}</div>}
+      </Modal>
+    </>
+  );
+};
+
+class InvalidAmountError extends Error {
+  override message = "Invalid amount";
+}

+ 13 - 0
apps/staking/src/isomorphic-config.ts

@@ -0,0 +1,13 @@
+/* eslint-disable n/no-process-env */
+
+/**
+ * Indicates this is a production-optimized build.  Note this does NOT
+ * necessarily indicate that we're running on a cloud machine or the live build
+ * -- use `RUNNING_IN_CLOUD` or `IS_PRODUCTION_SERVER` out of `server-config.ts`
+ * for that (if you need that on the client you'll need to write a client
+ * component that receives that value as a prop).
+ *
+ * Basically this indicates if we're minified, excluding source maps, running
+ * with the optimized React build, etc.
+ */
+export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === "production";

+ 41 - 0
apps/staking/src/logger.tsx

@@ -0,0 +1,41 @@
+"use client";
+
+import pino, { type Logger } from "pino";
+import { type ComponentProps, createContext, useContext, useMemo } from "react";
+
+import { IS_PRODUCTION_BUILD } from "./isomorphic-config";
+
+const LoggerContext = createContext<undefined | Logger<string>>(undefined);
+
+type LoggerContextProps = Omit<
+  ComponentProps<typeof LoggerContext.Provider>,
+  "config" | "value"
+> & {
+  config?: Parameters<typeof pino>[0] | undefined;
+};
+
+export const LoggerProvider = ({ config, ...props }: LoggerContextProps) => {
+  const logger = useMemo(
+    () =>
+      pino({
+        ...config,
+        browser: { ...config?.browser, disabled: IS_PRODUCTION_BUILD },
+      }),
+    [config],
+  );
+  return <LoggerContext.Provider value={logger} {...props} />;
+};
+
+export const useLogger = () => {
+  const logger = useContext(LoggerContext);
+  if (logger) {
+    return logger;
+  } else {
+    throw new NotInitializedError();
+  }
+};
+
+class NotInitializedError extends Error {
+  override message =
+    "This component must be contained within a `LoggerProvider`!";
+}

+ 52 - 0
apps/staking/src/metadata.ts

@@ -0,0 +1,52 @@
+import type { Metadata, Viewport } from "next";
+
+export const metadata = {
+  metadataBase: new URL("https://staking.pyth.network"),
+  title: {
+    default: "Pyth Network Staking & Delegation",
+    template: "%s | Pyth Network Staking & Delegation",
+  },
+  applicationName: "Pyth Network Staking & Delegation",
+  description:
+    "Stake PYTH tokens to participate in governance or earn yield and protect DeFi by staking to publishers.",
+  referrer: "strict-origin-when-cross-origin",
+  openGraph: {
+    type: "website",
+  },
+  twitter: {
+    creator: "@PythNetwork",
+    card: "summary_large_image",
+  },
+  icons: {
+    icon: [
+      {
+        media: "(prefers-color-scheme: light)",
+        type: "image/x-icon",
+        url: "/favicon.ico",
+      },
+      {
+        media: "(prefers-color-scheme: dark)",
+        type: "image/x-icon",
+        url: "/favicon-light.ico",
+      },
+      {
+        type: "image/png",
+        sizes: "32x32",
+        url: "/favicon-32x32.png",
+      },
+      {
+        type: "image/png",
+        sizes: "16x16",
+        url: "/favicon-16x16.png",
+      },
+    ],
+    apple: {
+      url: "/apple-touch-icon.png",
+      sizes: "180x180",
+    },
+  },
+} satisfies Metadata;
+
+export const viewport = {
+  themeColor: "#242235",
+} satisfies Viewport;

+ 37 - 0
apps/staking/src/server-config.ts

@@ -0,0 +1,37 @@
+// Disable the following rule because this file is the intended place to declare
+// and load all env variables.
+/* eslint-disable n/no-process-env */
+
+import "server-only";
+
+/**
+ * Throw if the env var `key` is not set (at either runtime or build time).
+ */
+const demand = (key: string): string => {
+  const value = process.env[key];
+  if (value && value !== "") {
+    return value;
+  } else {
+    throw new Error(`Missing environment variable ${key}!`);
+  }
+};
+
+/**
+ * Indicates that this server is the live customer-facing production server.
+ */
+export const IS_PRODUCTION_SERVER = process.env.VERCEL_ENV === "production";
+
+/**
+ * Throw if the env var `key` is not set in the live customer-facing production
+ * server, but allow it to be unset in any other environment.
+ */
+const demandInProduction = IS_PRODUCTION_SERVER
+  ? demand
+  : (key: string) => process.env[key];
+
+export const GOOGLE_ANALYTICS_ID = demandInProduction("GOOGLE_ANALYTICS_ID");
+export const AMPLITUDE_API_KEY = demandInProduction("AMPLITUDE_API_KEY");
+export const WALLETCONNECT_PROJECT_ID = demandInProduction(
+  "WALLETCONNECT_PROJECT_ID",
+);
+export const MAINNET_RPC = process.env.MAINNET_RPC;

+ 3 - 0
apps/staking/src/tailwind.css

@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;

+ 69 - 0
apps/staking/src/tokens.test.ts

@@ -0,0 +1,69 @@
+import { tokensToString, stringToTokens } from "./tokens";
+
+const BIDIRECTIONAL_TESTS = [
+  [1n, "0.000001"],
+  [10n, "0.00001"],
+  [100n, "0.0001"],
+  [1000n, "0.001"],
+  [10_000n, "0.01"],
+  [100_000n, "0.1"],
+  [1_000_000n, "1"],
+  [10_000_000n, "10"],
+  [11_000_000n, "11"],
+  [11_100_000n, "11.1"],
+  [11_110_000n, "11.11"],
+  [11_111_000n, "11.111"],
+  [11_111_100n, "11.1111"],
+  [11_111_110n, "11.11111"],
+  [11_111_111n, "11.111111"],
+  [11_011_111n, "11.011111"],
+  [11_001_111n, "11.001111"],
+  [11_000_111n, "11.000111"],
+  [11_000_011n, "11.000011"],
+  [11_000_001n, "11.000001"],
+] as const;
+
+const STRING_TO_TOKENS_ONLY_TESTS = [
+  [".1", 100_000n],
+  [".11", 110_000n],
+  ["0.00000111", 1n],
+  ["11.0000011", 11_000_001n],
+] as const;
+
+const INVALID_STRING_TESTS = ["foo", "10bar", "1.5baz", "biz.54"];
+
+describe("tokensToString", () => {
+  BIDIRECTIONAL_TESTS.map(([input, output]) => {
+    describe(`with "${input.toString()}"`, () => {
+      it(`returns "${output}"`, () => {
+        expect(tokensToString(input)).toEqual(output);
+      });
+    });
+  });
+});
+
+describe("stringToTokens", () => {
+  BIDIRECTIONAL_TESTS.map(([output, input]) => {
+    describe(`with "${input}"`, () => {
+      it(`returns "${output.toString()}"`, () => {
+        expect(stringToTokens(input)).toEqual(output);
+      });
+    });
+  });
+
+  STRING_TO_TOKENS_ONLY_TESTS.map(([input, output]) => {
+    describe(`with "${input}"`, () => {
+      it(`returns "${output.toString()}"`, () => {
+        expect(stringToTokens(input)).toEqual(output);
+      });
+    });
+  });
+
+  INVALID_STRING_TESTS.map((str) => {
+    describe(`with "${str}"`, () => {
+      it(`returns undefined`, () => {
+        expect(stringToTokens(str)).toBeUndefined();
+      });
+    });
+  });
+});

+ 27 - 0
apps/staking/src/tokens.ts

@@ -0,0 +1,27 @@
+const DECIMALS = 6;
+
+export const tokensToString = (value: bigint): string => {
+  const asStr = value.toString();
+  const whole =
+    asStr.length > DECIMALS ? asStr.slice(0, asStr.length - DECIMALS) : "0";
+  const decimal =
+    asStr.length > DECIMALS ? asStr.slice(asStr.length - DECIMALS) : asStr;
+  const decimalPadded = decimal.padStart(DECIMALS, "0");
+  const decimalTruncated = decimalPadded.replace(/0+$/, "");
+
+  return [
+    whole,
+    ...(decimalTruncated === "" ? [] : [".", decimalTruncated]),
+  ].join("");
+};
+
+export const stringToTokens = (value: string): bigint | undefined => {
+  const [whole, decimal] = value.split(".");
+  try {
+    return BigInt(
+      `${whole ?? "0"}${(decimal ?? "").slice(0, DECIMALS).padEnd(DECIMALS, "0")}`,
+    );
+  } catch {
+    return undefined;
+  }
+};

+ 11 - 0
apps/staking/src/use-is-mounted.ts

@@ -0,0 +1,11 @@
+import { useState, useEffect } from "react";
+
+export const useIsMounted = () => {
+  const [mounted, setMounted] = useState(false);
+
+  useEffect(() => {
+    setMounted(true);
+  }, []);
+
+  return mounted;
+};

+ 79 - 0
apps/staking/src/use-transfer.ts

@@ -0,0 +1,79 @@
+import {
+  type WalletContextState,
+  useConnection,
+  useWallet,
+} from "@solana/wallet-adapter-react";
+import type { Connection } from "@solana/web3.js";
+import { type ComponentProps, useState, useCallback } from "react";
+
+import { loadData } from "./api";
+import type { DashboardLoaded } from "./components/Dashboard/loaded";
+
+export const useTransfer = (
+  transfer: (
+    connection: Connection,
+    wallet: WalletContextState,
+  ) => Promise<void>,
+  replaceData: ComponentProps<typeof DashboardLoaded>["replaceData"],
+  onFinish?: (reset: () => void) => void,
+) => {
+  const wallet = useWallet();
+  const { connection } = useConnection();
+  const [state, setState] = useState<State>(State.Base());
+
+  const reset = useCallback(() => {
+    setState(State.Base());
+  }, [setState]);
+
+  const execute = useCallback(() => {
+    setState(State.Submitting());
+    transfer(connection, wallet)
+      .then(() => {
+        setState(State.LoadingData());
+        loadData(connection, wallet)
+          .then((data) => {
+            replaceData(data);
+            if (onFinish) {
+              setState(State.Finished());
+              onFinish(reset);
+            } else {
+              setState(State.Base());
+            }
+          })
+          .catch((error: unknown) => {
+            setState(State.ErrorLoadingData(error));
+          });
+      })
+      .catch((error: unknown) => {
+        setState(State.ErrorSubmitting(error));
+      });
+  }, [connection, wallet, transfer, replaceData, onFinish, setState, reset]);
+
+  return { state, execute };
+};
+
+export enum StateType {
+  Base,
+  Submitting,
+  LoadingData,
+  ErrorSubmitting,
+  ErrorLoadingData,
+  Finished,
+}
+
+const State = {
+  Base: () => ({ type: StateType.Base as const }),
+  Submitting: () => ({ type: StateType.Submitting as const }),
+  LoadingData: () => ({ type: StateType.LoadingData as const }),
+  ErrorSubmitting: (error: unknown) => ({
+    type: StateType.ErrorSubmitting as const,
+    error,
+  }),
+  ErrorLoadingData: (error: unknown) => ({
+    type: StateType.ErrorLoadingData as const,
+    error,
+  }),
+  Finished: () => ({ type: StateType.Finished as const }),
+};
+
+type State = ReturnType<(typeof State)[keyof typeof State]>;

+ 6 - 0
apps/staking/svg.d.ts

@@ -0,0 +1,6 @@
+declare module "*.svg" {
+  import type { ReactElement, SVGProps } from "react";
+
+  const content: (props: SVGProps<SVGElement>) => ReactElement;
+  export default content;
+}

+ 30 - 0
apps/staking/tailwind.config.ts

@@ -0,0 +1,30 @@
+import forms from "@tailwindcss/forms";
+import type { Config } from "tailwindcss";
+
+const tailwindConfig = {
+  darkMode: "class",
+  content: ["src/components/**/*.{ts,tsx}", "src/markdown-components.tsx"],
+  plugins: [forms],
+  theme: {
+    extend: {
+      backgroundImage: {
+        "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
+      },
+      fontFamily: {
+        sans: ["var(--font-sans)"],
+        mono: ["var(--font-mono)"],
+      },
+      colors: {
+        pythpurple: {
+          100: "#E6DAFE",
+          400: "#BB86FC",
+          600: "#6200EE",
+          900: "#121212",
+          950: "#0C0B1A",
+        },
+      },
+    },
+  },
+} satisfies Config;
+
+export default tailwindConfig;

+ 5 - 0
apps/staking/tsconfig.json

@@ -0,0 +1,5 @@
+{
+  "extends": "@cprussin/tsconfig/nextjs.json",
+  "include": ["svg.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 607 - 1
pnpm-lock.yaml


이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.