Преглед изворни кода

Merge branch 'main' into parse-price-feed-update-optional-storage

Ayush Suresh пре 5 месеци
родитељ
комит
0636cb10ad
100 измењених фајлова са 1694 додато и 237 уклоњено
  1. 7 0
      .github/CODEOWNERS
  2. 0 1
      .github/workflows/ci-cosmwasm-contract.yml
  3. 0 1
      .github/workflows/ci-fortuna.yml
  4. 0 1
      .github/workflows/ci-hermes-server.yml
  5. 0 1
      .github/workflows/ci-message-buffer.yml
  6. 1 1
      .github/workflows/ci-pythnet-sdk.yml
  7. 0 1
      .github/workflows/ci-remote-executor.yml
  8. 1 1
      .github/workflows/ci-solana-contract.yml
  9. 0 1
      .github/workflows/ci-starknet-tools.yml
  10. 1 1
      .github/workflows/ci-sui-contract.yml
  11. 1 1
      .github/workflows/publish-pyth-price-store.yml
  12. 1 1
      .github/workflows/publish-pyth-solana-receiver-state.yml
  13. 1 0
      .gitignore
  14. 2 0
      apps/developer-hub/.gitignore
  15. 7 0
      apps/developer-hub/.prettierignore
  16. 8 0
      apps/developer-hub/content/docs/entropy/how-to-guides/index.mdx
  17. 10 0
      apps/developer-hub/content/docs/entropy/index.mdx
  18. 7 0
      apps/developer-hub/content/docs/entropy/meta.json
  19. 8 0
      apps/developer-hub/content/docs/express-relay/how-to-guides/index.mdx
  20. 10 0
      apps/developer-hub/content/docs/express-relay/index.mdx
  21. 7 0
      apps/developer-hub/content/docs/express-relay/meta.json
  22. 8 0
      apps/developer-hub/content/docs/lazer/how-to-guides/index.mdx
  23. 10 0
      apps/developer-hub/content/docs/lazer/index.mdx
  24. 7 0
      apps/developer-hub/content/docs/lazer/meta.json
  25. 3 0
      apps/developer-hub/content/docs/meta.json
  26. 8 0
      apps/developer-hub/content/docs/pyth-core/how-to-guides/index.mdx
  27. 10 0
      apps/developer-hub/content/docs/pyth-core/index.mdx
  28. 7 0
      apps/developer-hub/content/docs/pyth-core/meta.json
  29. 1 0
      apps/developer-hub/eslint.config.js
  30. 1 0
      apps/developer-hub/jest.config.js
  31. 5 0
      apps/developer-hub/next-env.d.ts
  32. 54 0
      apps/developer-hub/next.config.js
  33. 63 0
      apps/developer-hub/package.json
  34. 5 0
      apps/developer-hub/postcss.config.mjs
  35. 1 0
      apps/developer-hub/prettier.config.js
  36. BIN
      apps/developer-hub/public/android-chrome-192x192.png
  37. BIN
      apps/developer-hub/public/android-chrome-512x512.png
  38. BIN
      apps/developer-hub/public/apple-touch-icon.png
  39. BIN
      apps/developer-hub/public/favicon-16x16.png
  40. BIN
      apps/developer-hub/public/favicon-32x32.png
  41. BIN
      apps/developer-hub/public/favicon-light.ico
  42. BIN
      apps/developer-hub/public/favicon.ico
  43. 30 0
      apps/developer-hub/source.config.ts
  44. 24 0
      apps/developer-hub/src/app/(docs)/[section]/[...slug]/page.tsx
  45. 23 0
      apps/developer-hub/src/app/(docs)/[section]/page.tsx
  46. 8 0
      apps/developer-hub/src/app/(docs)/layout.tsx
  47. 8 0
      apps/developer-hub/src/app/(homepage)/layout.tsx
  48. 1 0
      apps/developer-hub/src/app/(homepage)/page.tsx
  49. 5 0
      apps/developer-hub/src/app/api/search/route.ts
  50. 2 0
      apps/developer-hub/src/app/layout.ts
  51. 11 0
      apps/developer-hub/src/app/robots.ts
  52. 27 0
      apps/developer-hub/src/components/Pages/BasePage/index.tsx
  53. 10 0
      apps/developer-hub/src/components/Pages/DocumentationPage/index.tsx
  54. 5 0
      apps/developer-hub/src/components/Pages/Homepage/index.module.scss
  55. 9 0
      apps/developer-hub/src/components/Pages/Homepage/index.tsx
  56. 8 0
      apps/developer-hub/src/components/Pages/LandingPage/index.tsx
  57. 4 0
      apps/developer-hub/src/components/Root/global.css
  58. 36 0
      apps/developer-hub/src/components/Root/index.tsx
  59. 336 0
      apps/developer-hub/src/components/Root/theme.css
  60. 35 0
      apps/developer-hub/src/config/layout.config.tsx
  61. 29 0
      apps/developer-hub/src/config/server.ts
  62. 9 0
      apps/developer-hub/src/mdx-components.tsx
  63. 52 0
      apps/developer-hub/src/metadata.ts
  64. 32 0
      apps/developer-hub/src/source.ts
  65. 21 0
      apps/developer-hub/stylelint.config.js
  66. 6 0
      apps/developer-hub/svg.d.ts
  67. 5 0
      apps/developer-hub/tsconfig.json
  68. 44 0
      apps/developer-hub/turbo.json
  69. 4 0
      apps/developer-hub/vercel.json
  70. 12 0
      apps/fortuna/.sqlx/query-03901bcfb28b127d99fe8a53e480b88336dd2aab632411114f02ce8dd8fe07e8.json
  71. 0 12
      apps/fortuna/.sqlx/query-16635b3d9c6f9b743614e0e08bfa2b26d7ec6346f0323d9f16b98c32fd9a91f6.json
  72. 35 17
      apps/fortuna/.sqlx/query-392da9e5fdd212a4a665c86e5fc6d4f619355294490248e656ad0fc97a252471.json
  73. 3 3
      apps/fortuna/.sqlx/query-4c8c05ec08e128d847faafdd3d79fa50da70066f30b74f354e5d3a843ba6a2c0.json
  74. 35 17
      apps/fortuna/.sqlx/query-78be8c62d5eb764995221f927b0f166e38d6fba8eb8fddb07f50c572fd27b4e2.json
  75. 32 14
      apps/fortuna/.sqlx/query-8cd10cd5839b81bd9538aeb10fdfd27c6e36baf5d90a4fb9e61718f021812710.json
  76. 32 14
      apps/fortuna/.sqlx/query-905dbc91cd5319537c5c194277d531689ac5c1338396414467496d0f50ddc3f0.json
  77. 32 14
      apps/fortuna/.sqlx/query-a62e094cee65ae58bd12ce7d3e7df44f5aca31520d1ceced83f492945e850764.json
  78. 12 0
      apps/fortuna/.sqlx/query-b0d9afebb3825c3509ad80e5ebab5d72360326593407518770fe537ac3da1e10.json
  79. 0 12
      apps/fortuna/.sqlx/query-b2baa9f9d46f873a3a7117c38ecab09f56082c5267dbf5180f39c608b6262f5a.json
  80. 34 16
      apps/fortuna/.sqlx/query-c9e3089b1ffd52d20cfcd89e71e051c0f351643dce9be4b84b6343909c816c22.json
  81. 34 16
      apps/fortuna/.sqlx/query-f58bdd3e0ecb30f35356c22e9ab1b3802f8eebda455efabc18d30f02d23787b7.json
  82. 1 1
      apps/fortuna/Cargo.lock
  83. 1 1
      apps/fortuna/Cargo.toml
  84. 3 0
      apps/fortuna/config.sample.yaml
  85. 3 2
      apps/fortuna/migrations/20250502164500_init.up.sql
  86. 4 0
      apps/fortuna/migrations/20250521203448_gas.down.sql
  87. 5 0
      apps/fortuna/migrations/20250521203448_gas.up.sql
  88. 25 5
      apps/fortuna/src/api.rs
  89. 34 13
      apps/fortuna/src/api/explorer.rs
  90. 33 5
      apps/fortuna/src/chain/ethereum.rs
  91. 1 0
      apps/fortuna/src/command/generate.rs
  92. 23 7
      apps/fortuna/src/command/run.rs
  93. 9 7
      apps/fortuna/src/command/setup_provider.rs
  94. 9 0
      apps/fortuna/src/config.rs
  95. 1 1
      apps/fortuna/src/eth_utils/utils.rs
  96. 207 39
      apps/fortuna/src/history.rs
  97. 7 9
      apps/fortuna/src/keeper.rs
  98. 7 1
      apps/fortuna/src/keeper/commitment.rs
  99. 8 0
      apps/fortuna/src/keeper/keeper_metrics.rs
  100. 13 0
      apps/fortuna/src/keeper/process_event.rs

+ 7 - 0
.github/CODEOWNERS

@@ -16,6 +16,13 @@ turbo.json @pyth-network/web-team
 .github/workflows/ci-turbo-build.yml @pyth-network/web-team
 .github/workflows/ci-turbo-test.yml @pyth-network/web-team
 
+/lazer/contracts/aptos @Riateche @ali-bahjati
+/lazer/contracts/evm @Riateche @ali-bahjati
+/lazer/contracts/solana @Riateche @ali-bahjati
+/lazer/publisher_sdk @darunrs @Riateche
+/lazer/sdk/js @ali-bahjati @keyvankhademi
+/lazer/sdk/rust @darunrs @Riateche
+
 flake.lock @cprussin
 *.nix @cprussin
 .envrc @cprussin

+ 0 - 1
.github/workflows/ci-cosmwasm-contract.yml

@@ -23,7 +23,6 @@ jobs:
       - uses: actions/checkout@v2
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.82.0
           components: rustfmt, clippy
           override: true

+ 0 - 1
.github/workflows/ci-fortuna.yml

@@ -21,7 +21,6 @@ jobs:
           workspaces: "apps/fortuna -> target"
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.82.0
           override: true
           components: rustfmt, clippy

+ 0 - 1
.github/workflows/ci-hermes-server.yml

@@ -20,7 +20,6 @@ jobs:
           workspaces: "apps/hermes/server -> target"
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.82.0
           components: rustfmt, clippy
           override: true

+ 0 - 1
.github/workflows/ci-message-buffer.yml

@@ -22,7 +22,6 @@ jobs:
           workspaces: "pythnet/message_buffer -> target"
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.66.1
           components: rustfmt, clippy
       - name: Install Solana

+ 1 - 1
.github/workflows/ci-pythnet-sdk.yml

@@ -20,8 +20,8 @@ jobs:
           workspaces: "pythnet/pythnet_sdk -> target"
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.82.0
+          components: rustfmt, clippy
           override: true
       - name: Format check
         run: cargo fmt --all -- --check

+ 0 - 1
.github/workflows/ci-remote-executor.yml

@@ -18,7 +18,6 @@ jobs:
       - uses: actions/setup-python@v2
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.73.0
           components: rustfmt, clippy
           override: true

+ 1 - 1
.github/workflows/ci-solana-contract.yml

@@ -26,8 +26,8 @@ jobs:
           workspaces: "target_chains/solana -> target"
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.73.0
+          components: rustfmt, clippy
           override: true
       - name: Install Solana
         run: |

+ 0 - 1
.github/workflows/ci-starknet-tools.yml

@@ -13,7 +13,6 @@ jobs:
       - uses: actions/checkout@v2
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.86.0
           components: rustfmt, clippy
           override: true

+ 1 - 1
.github/workflows/ci-sui-contract.yml

@@ -19,8 +19,8 @@ jobs:
       - uses: actions/checkout@v3
       - uses: actions-rs/toolchain@v1
         with:
-          profile: minimal
           toolchain: 1.77.0
+          components: rustfmt, clippy
           override: true
       - uses: taiki-e/cache-cargo-install-action@v2
         with:

+ 1 - 1
.github/workflows/publish-pyth-price-store.yml

@@ -16,7 +16,7 @@ jobs:
         with:
           toolchain: stable
           default: true
-          profile: minimal
+          components: rustfmt, clippy
       - name: Publish
         run: cargo publish --token ${CARGO_REGISTRY_TOKEN}
         env:

+ 1 - 1
.github/workflows/publish-pyth-solana-receiver-state.yml

@@ -16,7 +16,7 @@ jobs:
         with:
           toolchain: stable
           default: true
-          profile: minimal
+          components: rustfmt, clippy
 
       - run: cargo +stable-x86_64-unknown-linux-gnu publish --token ${CARGO_REGISTRY_TOKEN}
         env:

+ 1 - 0
.gitignore

@@ -25,3 +25,4 @@ __pycache__
 .turbo/
 .cursorrules
 .corepack
+justfile

+ 2 - 0
apps/developer-hub/.gitignore

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

+ 7 - 0
apps/developer-hub/.prettierignore

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

+ 8 - 0
apps/developer-hub/content/docs/entropy/how-to-guides/index.mdx

@@ -0,0 +1,8 @@
+---
+title: Entropy How-To Guide
+description: A placeholder docs page
+---
+
+# How To
+
+Build secure smart contracts with provably random numbers from Pyth Entropy. Launch NFTs, games, and other unique experiences that your users trust with seamless UX.

+ 10 - 0
apps/developer-hub/content/docs/entropy/index.mdx

@@ -0,0 +1,10 @@
+---
+title: Overview
+description: A placeholder landing page
+icon: CardsThree
+full: true
+---
+
+# Secure On-Chain Randomness
+
+Build secure smart contracts with provably random numbers from Pyth Entropy. Launch NFTs, games, and other unique experiences that your users trust with seamless UX.

+ 7 - 0
apps/developer-hub/content/docs/entropy/meta.json

@@ -0,0 +1,7 @@
+{
+  "root": true,
+  "title": "Entropy",
+  "description": "Random numbers for smart contracts",
+  "icon": "Shuffle",
+  "pages": ["index", "---Guides---", "how-to-guides"]
+}

+ 8 - 0
apps/developer-hub/content/docs/express-relay/how-to-guides/index.mdx

@@ -0,0 +1,8 @@
+---
+title: Express Relay How-To Guide
+description: A placeholder docs page
+---
+
+# How To
+
+Integrate directly with searchers to recapture MEV. Go to market faster. Accelerate your protocol's growth.

+ 10 - 0
apps/developer-hub/content/docs/express-relay/index.mdx

@@ -0,0 +1,10 @@
+---
+title: Overview
+description: A placeholder landing page
+icon: CardsThree
+full: true
+---
+
+# Take Back Control with Express Relay
+
+Integrate directly with searchers to recapture MEV. Go to market faster. Accelerate your protocol's growth.

+ 7 - 0
apps/developer-hub/content/docs/express-relay/meta.json

@@ -0,0 +1,7 @@
+{
+  "root": true,
+  "title": "Express Relay",
+  "description": "Eliminate MEV",
+  "icon": "Gavel",
+  "pages": ["index", "---Guides---", "how-to-guides"]
+}

+ 8 - 0
apps/developer-hub/content/docs/lazer/how-to-guides/index.mdx

@@ -0,0 +1,8 @@
+---
+title: Lazer How-To Guide
+description: A placeholder docs page
+---
+
+# How To
+
+Pyth Lazer is a low latency, highly customizable price oracle. It offers a customizable set of price feeds, target chains (EVM or Solana) and channels (real time or fixed rate)

+ 10 - 0
apps/developer-hub/content/docs/lazer/index.mdx

@@ -0,0 +1,10 @@
+---
+title: Overview
+description: A placeholder landing page
+icon: CardsThree
+full: true
+---
+
+# Low latency, highly customizable price oracle
+
+Pyth Lazer is a low latency, highly customizable price oracle. It offers a customizable set of price feeds, target chains (EVM or Solana) and channels (real time or fixed rate)

+ 7 - 0
apps/developer-hub/content/docs/lazer/meta.json

@@ -0,0 +1,7 @@
+{
+  "root": true,
+  "title": "Lazer",
+  "description": "Low latency, highly customizable price oracle",
+  "icon": "Lightning",
+  "pages": ["index", "---Guides---", "how-to-guides"]
+}

+ 3 - 0
apps/developer-hub/content/docs/meta.json

@@ -0,0 +1,3 @@
+{
+  "pages": ["pyth-core", "lazer", "express-relay", "entropy"]
+}

+ 8 - 0
apps/developer-hub/content/docs/pyth-core/how-to-guides/index.mdx

@@ -0,0 +1,8 @@
+---
+title: Pyth Core How-To Guide
+description: A placeholder docs page
+---
+
+# Heading One
+
+The fastest and most reliable data powering more transactions than any other oracle. Permissionless integration on every blockchain.

+ 10 - 0
apps/developer-hub/content/docs/pyth-core/index.mdx

@@ -0,0 +1,10 @@
+---
+title: Overview
+description: A placeholder landing page
+icon: CardsThree
+full: true
+---
+
+# Real-time data from financial institutions
+
+The fastest and most reliable data powering more transactions than any other oracle. Permissionless integration on every blockchain.

+ 7 - 0
apps/developer-hub/content/docs/pyth-core/meta.json

@@ -0,0 +1,7 @@
+{
+  "root": true,
+  "title": "Pyth Core",
+  "description": "Real-time data from financial institutions",
+  "icon": "ChartLine",
+  "pages": ["index", "---Guides---", "how-to-guides"]
+}

+ 1 - 0
apps/developer-hub/eslint.config.js

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

+ 1 - 0
apps/developer-hub/jest.config.js

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

+ 5 - 0
apps/developer-hub/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/app/api-reference/config/typescript for more information.

+ 54 - 0
apps/developer-hub/next.config.js

@@ -0,0 +1,54 @@
+import { createMDX } from "fumadocs-mdx/next";
+
+const config = {
+  reactStrictMode: true,
+  pageExtensions: ["ts", "tsx", "mdx"],
+
+  logging: {
+    fetches: {
+      fullUrl: true,
+    },
+  },
+
+  webpack(config) {
+    config.module.rules.push({
+      test: /\.svg$/i,
+      use: ["@svgr/webpack"],
+    });
+
+    return config;
+  },
+
+  headers: async () => [
+    {
+      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",
+        },
+      ],
+    },
+  ],
+};
+
+const withMDX = createMDX();
+
+export default withMDX(config);

+ 63 - 0
apps/developer-hub/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@pythnetwork/developer-hub",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "engines": {
+    "node": "22"
+  },
+  "scripts": {
+    "build": "next build",
+    "build:analyze": "ANALYZE=true next build",
+    "fix:format": "prettier --write .",
+    "fix:lint:eslint": "eslint --fix .",
+    "fix:lint:stylelint": "stylelint --fix 'src/**/*.scss'",
+    "start:dev": "next dev --port 3627",
+    "start:prod": "next start --port 3627",
+    "test:format": "prettier --check .",
+    "test:lint:eslint": "eslint . --max-warnings 0",
+    "test:lint:stylelint": "stylelint 'src/**/*.scss' --max-warnings 0",
+    "test:types": "tsc"
+  },
+  "dependencies": {
+    "@phosphor-icons/react": "catalog:",
+    "@pythnetwork/component-library": "workspace:*",
+    "@react-hookz/web": "catalog:",
+    "clsx": "catalog:",
+    "fumadocs-core": "catalog:",
+    "fumadocs-mdx": "catalog:",
+    "fumadocs-ui": "catalog:",
+    "next": "catalog:",
+    "next-themes": "catalog:",
+    "nuqs": "catalog:",
+    "react": "catalog:",
+    "react-aria": "catalog:",
+    "react-dom": "catalog:",
+    "zod": "catalog:",
+    "zod-validation-error": "catalog:"
+  },
+  "devDependencies": {
+    "@cprussin/eslint-config": "catalog:",
+    "@cprussin/jest-config": "catalog:",
+    "@cprussin/prettier-config": "catalog:",
+    "@cprussin/tsconfig": "catalog:",
+    "@svgr/webpack": "catalog:",
+    "@tailwindcss/postcss": "catalog:",
+    "@types/jest": "catalog:",
+    "@types/mdx": "catalog:",
+    "@types/node": "catalog:",
+    "@types/react": "catalog:",
+    "@types/react-dom": "catalog:",
+    "autoprefixer": "catalog:",
+    "eslint": "catalog:",
+    "jest": "catalog:",
+    "postcss": "catalog:",
+    "prettier": "catalog:",
+    "sass": "catalog:",
+    "stylelint": "catalog:",
+    "stylelint-config-standard-scss": "catalog:",
+    "tailwindcss": "^4.1.6",
+    "typescript": "catalog:",
+    "vercel": "catalog:"
+  }
+}

+ 5 - 0
apps/developer-hub/postcss.config.mjs

@@ -0,0 +1,5 @@
+export default {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};

+ 1 - 0
apps/developer-hub/prettier.config.js

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

BIN
apps/developer-hub/public/android-chrome-192x192.png


BIN
apps/developer-hub/public/android-chrome-512x512.png


BIN
apps/developer-hub/public/apple-touch-icon.png


BIN
apps/developer-hub/public/favicon-16x16.png


BIN
apps/developer-hub/public/favicon-32x32.png


BIN
apps/developer-hub/public/favicon-light.ico


BIN
apps/developer-hub/public/favicon.ico


+ 30 - 0
apps/developer-hub/source.config.ts

@@ -0,0 +1,30 @@
+import { defineConfig, defineDocs } from "fumadocs-mdx/config";
+import { z } from "zod";
+
+export const docs = defineDocs({
+  docs: {
+    schema: z.object({
+      title: z.string(),
+      description: z.string(),
+      icon: z.string().optional(),
+      full: z.boolean().default(false),
+      index: z.boolean().default(false),
+    }),
+  },
+  meta: {
+    schema: z.object({
+      title: z.string().optional(),
+      pages: z.array(z.string()).optional(),
+      description: z.string().optional(),
+      root: z.boolean().optional(),
+      defaultOpen: z.boolean().optional(),
+      icon: z.string().optional(),
+    }),
+  },
+});
+
+export default defineConfig({
+  mdxOptions: {
+    // MDX options
+  },
+});

+ 24 - 0
apps/developer-hub/src/app/(docs)/[section]/[...slug]/page.tsx

@@ -0,0 +1,24 @@
+export { DocumentationPage as default } from "../../../../components/Pages/DocumentationPage";
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+
+import { source } from "../../../../source";
+
+export function generateStaticParams() {
+  return source.generateParams();
+}
+
+export async function generateMetadata(props: {
+  params: Promise<{ section: string; slug: string[] }>;
+}) {
+  const params = await props.params;
+
+  const page = source.getPage([params.section, ...params.slug]);
+
+  if (!page) notFound();
+
+  return {
+    title: page.data.title,
+    description: page.data.description,
+  } satisfies Metadata;
+}

+ 23 - 0
apps/developer-hub/src/app/(docs)/[section]/page.tsx

@@ -0,0 +1,23 @@
+export { LandingPage as default } from "../../../components/Pages/LandingPage";
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+
+import { source } from "../../../source";
+
+export function generateStaticParams() {
+  return source.generateParams();
+}
+
+export async function generateMetadata(props: {
+  params: Promise<{ section: string }>;
+}) {
+  const params = await props.params;
+  const page = source.getPage([params.section]);
+
+  if (!page) notFound();
+
+  return {
+    title: page.data.title,
+    description: page.data.description,
+  } satisfies Metadata;
+}

+ 8 - 0
apps/developer-hub/src/app/(docs)/layout.tsx

@@ -0,0 +1,8 @@
+import { DocsLayout } from "fumadocs-ui/layouts/docs";
+import type { ReactNode } from "react";
+
+import { docsOptions } from "../../config/layout.config";
+
+export default function Layout({ children }: { children: ReactNode }) {
+  return <DocsLayout {...docsOptions}>{children}</DocsLayout>;
+}

+ 8 - 0
apps/developer-hub/src/app/(homepage)/layout.tsx

@@ -0,0 +1,8 @@
+import { HomeLayout } from "fumadocs-ui/layouts/home";
+import type { ReactNode } from "react";
+
+import { baseOptions } from "../../config/layout.config";
+
+export default function Layout({ children }: { children: ReactNode }) {
+  return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
+}

+ 1 - 0
apps/developer-hub/src/app/(homepage)/page.tsx

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

+ 5 - 0
apps/developer-hub/src/app/api/search/route.ts

@@ -0,0 +1,5 @@
+import { createFromSource } from "fumadocs-core/search/server";
+
+import { source } from "../../../source";
+
+export const { GET } = createFromSource(source);

+ 2 - 0
apps/developer-hub/src/app/layout.ts

@@ -0,0 +1,2 @@
+export { Root as default } from "../components/Root";
+export { metadata, viewport } from "../metadata";

+ 11 - 0
apps/developer-hub/src/app/robots.ts

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

+ 27 - 0
apps/developer-hub/src/components/Pages/BasePage/index.tsx

@@ -0,0 +1,27 @@
+import {
+  DocsBody,
+  DocsDescription,
+  DocsPage,
+  DocsTitle,
+} from "fumadocs-ui/page";
+import { notFound } from "next/navigation";
+
+import { getMDXComponents } from "../../../mdx-components";
+import { source } from "../../../source";
+
+export function BasePage(props: { params: { slug: string[] } }) {
+  const page = source.getPage(props.params.slug);
+  if (!page) notFound();
+
+  const MDX = page.data.body;
+
+  return (
+    <DocsPage toc={page.data.toc} full={page.data.full}>
+      <DocsTitle>{page.data.title}</DocsTitle>
+      <DocsDescription>{page.data.description}</DocsDescription>
+      <DocsBody>
+        <MDX components={getMDXComponents()} />
+      </DocsBody>
+    </DocsPage>
+  );
+}

+ 10 - 0
apps/developer-hub/src/components/Pages/DocumentationPage/index.tsx

@@ -0,0 +1,10 @@
+import { BasePage } from "../BasePage";
+
+export async function DocumentationPage(props: {
+  params: Promise<{ section: string; slug: string[] }>;
+}) {
+  const params = await props.params;
+  return (
+    <BasePage params={{ ...params, slug: [params.section, ...params.slug] }} />
+  );
+}

+ 5 - 0
apps/developer-hub/src/components/Pages/Homepage/index.module.scss

@@ -0,0 +1,5 @@
+@use "@pythnetwork/component-library/theme";
+
+.landing {
+  @include theme.max-width;
+}

+ 9 - 0
apps/developer-hub/src/components/Pages/Homepage/index.tsx

@@ -0,0 +1,9 @@
+import styles from "./index.module.scss";
+
+export const Homepage = () => {
+  return (
+    <div className={styles.landing}>
+      <h2>Homepage Landing Page</h2>
+    </div>
+  );
+};

+ 8 - 0
apps/developer-hub/src/components/Pages/LandingPage/index.tsx

@@ -0,0 +1,8 @@
+import { BasePage } from "../BasePage";
+
+export async function LandingPage(props: {
+  params: Promise<{ section: string }>;
+}) {
+  const params = await props.params;
+  return <BasePage params={{ ...params, slug: [params.section] }} />;
+}

+ 4 - 0
apps/developer-hub/src/components/Root/global.css

@@ -0,0 +1,4 @@
+@import "tailwindcss";
+@import "fumadocs-ui/css/neutral.css";
+@import "fumadocs-ui/css/preset.css";
+@import "./theme.css";

+ 36 - 0
apps/developer-hub/src/components/Root/index.tsx

@@ -0,0 +1,36 @@
+import { AppShell } from "@pythnetwork/component-library/AppShell";
+import { RootProvider as FumadocsRootProvider } from "fumadocs-ui/provider";
+import { NuqsAdapter } from "nuqs/adapters/next/app";
+import type { ReactNode } from "react";
+import "./global.css";
+
+import {
+  AMPLITUDE_API_KEY,
+  ENABLE_ACCESSIBILITY_REPORTING,
+  GOOGLE_ANALYTICS_ID,
+} from "../../config/server";
+
+export const TABS = [
+  { segment: "", children: "Home" },
+  { segment: "pyth-core", children: "Pyth Core" },
+  { segment: "lazer", children: "Lazer" },
+  { segment: "express-relay", children: "Express Relay" },
+  { segment: "entropy", children: "Entropy" },
+];
+
+type Props = {
+  children: ReactNode;
+};
+
+export const Root = ({ children }: Props) => (
+  <AppShell
+    appName="Developer Hub"
+    amplitudeApiKey={AMPLITUDE_API_KEY}
+    googleAnalyticsId={GOOGLE_ANALYTICS_ID}
+    enableAccessibilityReporting={ENABLE_ACCESSIBILITY_REPORTING}
+    providers={[NuqsAdapter]}
+    tabs={TABS}
+  >
+    <FumadocsRootProvider>{children}</FumadocsRootProvider>
+  </AppShell>
+);

+ 336 - 0
apps/developer-hub/src/components/Root/theme.css

@@ -0,0 +1,336 @@
+:root {
+  --color-black: #000;
+  --color-white: #fff;
+
+  --color-slate-50: #f8fafc;
+  --color-slate-100: #f1f5f9;
+  --color-slate-200: #e2e8f0;
+  --color-slate-300: #cbd5e1;
+  --color-slate-400: #94a3b8;
+  --color-slate-500: #64748b;
+  --color-slate-600: #475569;
+  --color-slate-700: #334155;
+  --color-slate-800: #1e293b;
+  --color-slate-900: #0f172a;
+  --color-slate-950: #020617;
+
+  --color-gray-50: #f9fafb;
+  --color-gray-100: #f3f4f6;
+  --color-gray-200: #e5e7eb;
+  --color-gray-300: #d1d5db;
+  --color-gray-400: #9ca3af;
+  --color-gray-500: #6b7280;
+  --color-gray-600: #4b5563;
+  --color-gray-700: #374151;
+  --color-gray-800: #1f2937;
+  --color-gray-900: #111827;
+  --color-gray-950: #030712;
+
+  --color-zinc-50: #fafafa;
+  --color-zinc-100: #f4f4f5;
+  --color-zinc-200: #e4e4e7;
+  --color-zinc-300: #d4d4d8;
+  --color-zinc-400: #a1a1aa;
+  --color-zinc-500: #71717a;
+  --color-zinc-600: #52525b;
+  --color-zinc-700: #3f3f46;
+  --color-zinc-800: #27272a;
+  --color-zinc-900: #18181b;
+  --color-zinc-950: #09090b;
+
+  --color-neutral-50: #fafafa;
+  --color-neutral-100: #f5f5f5;
+  --color-neutral-200: #e5e5e5;
+  --color-neutral-300: #d4d4d4;
+  --color-neutral-400: #a3a3a3;
+  --color-neutral-500: #737373;
+  --color-neutral-600: #525252;
+  --color-neutral-700: #404040;
+  --color-neutral-800: #262626;
+  --color-neutral-900: #171717;
+  --color-neutral-950: #0a0a0a;
+
+  --color-stone-50: #fafaf9;
+  --color-stone-100: #f5f5f4;
+  --color-stone-200: #e7e5e4;
+  --color-stone-300: #d6d3d1;
+  --color-stone-400: #a8a29e;
+  --color-stone-500: #78716c;
+  --color-stone-600: #57534e;
+  --color-stone-700: #44403c;
+  --color-stone-800: #292524;
+  --color-stone-900: #1c1917;
+  --color-stone-950: #0c0a09;
+
+  --color-red-50: #fef2f2;
+  --color-red-100: #fee2e2;
+  --color-red-200: #fecaca;
+  --color-red-300: #fca5a5;
+  --color-red-400: #f87171;
+  --color-red-500: #ef4444;
+  --color-red-600: #dc2626;
+  --color-red-700: #b91c1c;
+  --color-red-800: #991b1b;
+  --color-red-900: #7f1d1d;
+  --color-red-950: #450a0a;
+
+  --color-orange-50: #fff7ed;
+  --color-orange-100: #ffedd5;
+  --color-orange-200: #fed7aa;
+  --color-orange-300: #fdba74;
+  --color-orange-400: #fb923c;
+  --color-orange-500: #f97316;
+  --color-orange-600: #ea580c;
+  --color-orange-700: #c2410c;
+  --color-orange-800: #9a3412;
+  --color-orange-900: #7c2d12;
+  --color-orange-950: #431407;
+
+  --color-amber-50: #fffbeb;
+  --color-amber-100: #fef3c7;
+  --color-amber-200: #fde68a;
+  --color-amber-300: #fcd34d;
+  --color-amber-400: #fbbf24;
+  --color-amber-500: #f59e0b;
+  --color-amber-600: #d97706;
+  --color-amber-700: #b45309;
+  --color-amber-800: #92400e;
+  --color-amber-900: #78350f;
+  --color-amber-950: #451a03;
+
+  --color-yellow-50: #fefce8;
+  --color-yellow-100: #fef9c3;
+  --color-yellow-200: #fef08a;
+  --color-yellow-300: #fde047;
+  --color-yellow-400: #facc15;
+  --color-yellow-500: #eab308;
+  --color-yellow-600: #ca8a04;
+  --color-yellow-700: #a16207;
+  --color-yellow-800: #854d0e;
+  --color-yellow-900: #713f12;
+  --color-yellow-950: #422006;
+
+  --color-lime-50: #f7fee7;
+  --color-lime-100: #ecfccb;
+  --color-lime-200: #d9f99d;
+  --color-lime-300: #bef264;
+  --color-lime-400: #a3e635;
+  --color-lime-500: #84cc16;
+  --color-lime-600: #65a30d;
+  --color-lime-700: #4d7c0f;
+  --color-lime-800: #3f6212;
+  --color-lime-900: #365314;
+  --color-lime-950: #1a2e05;
+
+  --color-green-50: #f0fdf4;
+  --color-green-100: #dcfce7;
+  --color-green-200: #bbf7d0;
+  --color-green-300: #86efac;
+  --color-green-400: #4ade80;
+  --color-green-500: #22c55e;
+  --color-green-600: #16a34a;
+  --color-green-700: #15803d;
+  --color-green-800: #166534;
+  --color-green-900: #14532d;
+  --color-green-950: #052e16;
+
+  --color-emerald-50: #ecfdf5;
+  --color-emerald-100: #d1fae5;
+  --color-emerald-200: #a7f3d0;
+  --color-emerald-300: #6ee7b7;
+  --color-emerald-400: #34d399;
+  --color-emerald-500: #10b981;
+  --color-emerald-600: #059669;
+  --color-emerald-700: #047857;
+  --color-emerald-800: #065f46;
+  --color-emerald-900: #064e3b;
+  --color-emerald-950: #022c22;
+
+  --color-teal-50: #f0fdfa;
+  --color-teal-100: #ccfbf1;
+  --color-teal-200: #99f6e4;
+  --color-teal-300: #5eead4;
+  --color-teal-400: #2dd4bf;
+  --color-teal-500: #14b8a6;
+  --color-teal-600: #0d9488;
+  --color-teal-700: #0f766e;
+  --color-teal-800: #115e59;
+  --color-teal-900: #134e4a;
+  --color-teal-950: #042f2e;
+
+  --color-cyan-50: #ecfeff;
+  --color-cyan-100: #cffafe;
+  --color-cyan-200: #a5f3fc;
+  --color-cyan-300: #67e8f9;
+  --color-cyan-400: #22d3ee;
+  --color-cyan-500: #06b6d4;
+  --color-cyan-600: #0891b2;
+  --color-cyan-700: #0e7490;
+  --color-cyan-800: #155e75;
+  --color-cyan-900: #164e63;
+  --color-cyan-950: #083344;
+
+  --color-sky-50: #f0f9ff;
+  --color-sky-100: #e0f2fe;
+  --color-sky-200: #bae6fd;
+  --color-sky-300: #7dd3fc;
+  --color-sky-400: #38bdf8;
+  --color-sky-500: #0ea5e9;
+  --color-sky-600: #0284c7;
+  --color-sky-700: #0369a1;
+  --color-sky-800: #075985;
+  --color-sky-900: #0c4a6e;
+  --color-sky-950: #082f49;
+
+  --color-blue-50: #eff6ff;
+  --color-blue-100: #dbeafe;
+  --color-blue-200: #bfdbfe;
+  --color-blue-300: #93c5fd;
+  --color-blue-400: #60a5fa;
+  --color-blue-500: #3b82f6;
+  --color-blue-600: #2563eb;
+  --color-blue-700: #1d4ed8;
+  --color-blue-800: #1e40af;
+  --color-blue-900: #1e3a8a;
+  --color-blue-950: #172554;
+
+  --color-indigo-50: #eef2ff;
+  --color-indigo-100: #e0e7ff;
+  --color-indigo-200: #c7d2fe;
+  --color-indigo-300: #a5b4fc;
+  --color-indigo-400: #818cf8;
+  --color-indigo-500: #6366f1;
+  --color-indigo-600: #4f46e5;
+  --color-indigo-700: #4338ca;
+  --color-indigo-800: #3730a3;
+  --color-indigo-900: #312e81;
+  --color-indigo-950: #1e1b4b;
+
+  --color-violet-50: #f5f3ff;
+  --color-violet-100: #ede9fe;
+  --color-violet-200: #ddd6fe;
+  --color-violet-300: #c4b5fd;
+  --color-violet-400: #a78bfa;
+  --color-violet-500: #8b5cf6;
+  --color-violet-600: #7c3aed;
+  --color-violet-700: #6d28d9;
+  --color-violet-800: #5b21b6;
+  --color-violet-900: #4c1d95;
+  --color-violet-950: #2e1065;
+
+  --color-purple-50: #faf5ff;
+  --color-purple-100: #f3e8ff;
+  --color-purple-200: #e9d5ff;
+  --color-purple-300: #d8b4fe;
+  --color-purple-400: #c084fc;
+  --color-purple-500: #a855f7;
+  --color-purple-600: #9333ea;
+  --color-purple-700: #7e22ce;
+  --color-purple-800: #6b21a8;
+  --color-purple-900: #581c87;
+  --color-purple-950: #3b0764;
+
+  --color-fuchsia-50: #fdf4ff;
+  --color-fuchsia-100: #fae8ff;
+  --color-fuchsia-200: #f5d0fe;
+  --color-fuchsia-300: #f0abfc;
+  --color-fuchsia-400: #e879f9;
+  --color-fuchsia-500: #d946ef;
+  --color-fuchsia-600: #c026d3;
+  --color-fuchsia-700: #a21caf;
+  --color-fuchsia-800: #86198f;
+  --color-fuchsia-900: #701a75;
+  --color-fuchsia-950: #4a044e;
+
+  --color-pink-50: #fdf2f8;
+  --color-pink-100: #fce7f3;
+  --color-pink-200: #fbcfe8;
+  --color-pink-300: #f9a8d4;
+  --color-pink-400: #f472b6;
+  --color-pink-500: #ec4899;
+  --color-pink-600: #db2777;
+  --color-pink-700: #be185d;
+  --color-pink-800: #9d174d;
+  --color-pink-900: #831843;
+  --color-pink-950: #500724;
+
+  --color-rose-50: #fff1f2;
+  --color-rose-100: #ffe4e6;
+  --color-rose-200: #fecdd3;
+  --color-rose-300: #fda4af;
+  --color-rose-400: #fb7185;
+  --color-rose-500: #f43f5e;
+  --color-rose-600: #e11d48;
+  --color-rose-700: #be123c;
+  --color-rose-800: #9f1239;
+  --color-rose-900: #881337;
+  --color-rose-950: #4c0519;
+
+  --color-beige-50: #f7f4f4;
+  --color-beige-100: #f3eded;
+  --color-beige-200: #e9dfdf;
+  --color-beige-300: #d9c8c8;
+  --color-beige-400: #c1a8a8;
+  --color-beige-500: #a98a8a;
+  --color-beige-600: #927070;
+  --color-beige-700: #795c5c;
+  --color-beige-800: #664e4e;
+  --color-beige-900: #574545;
+  --color-beige-950: #2d2222;
+
+  --color-steel-50: #f8f9fc;
+  --color-steel-100: #f1f2f9;
+  --color-steel-200: #e2e3f0;
+  --color-steel-300: #cbcee1;
+  --color-steel-400: #9497b8;
+  --color-steel-500: #64678b;
+  --color-steel-600: #474a69;
+  --color-steel-700: #333655;
+  --color-steel-800: #25253e;
+  --color-steel-900: #27253d;
+  --color-steel-950: #100e23;
+
+  --color-fd-background: light-dark(var(--color-white), var(--color-steel-950));
+  --color-fd-primary: light-dark(var(--color-steel-900), var(--color-steel-50));
+  --color-fd-border: light-dark(var(--color-stone-300), var(--color-steel-600));
+  --color-fd-accent: light-dark(
+    var(--color-violet-600),
+    var(--color-violet-500)
+  );
+  --color-fd-accent-foreground: light-dark(
+    var(--color-steel-900),
+    var(--color-steel-50)
+  );
+  --color-fd-muted: light-dark(var(--color-stone-700), var(--color-steel-400));
+  --color-fd-muted-foreground: light-dark(
+    var(--color-steel-600),
+    var(--color-steel-400)
+  );
+  --color-fd-foreground: light-dark(
+    var(--color-steel-900),
+    var(--color-steel-50)
+  );
+  --color-fd-secondary: light-dark(
+    var(--color-beige-100),
+    var(--color-steel-900)
+  );
+  --color-fd-secondary-foreground: light-dark(
+    var(--color-steel-800),
+    var(--color-steel-50)
+  );
+  --color-fd-card: light-dark(var(--color-white), var(--color-steel-950));
+  --color-fd-card-foreground: light-dark(
+    var(--color-steel-900),
+    var(--color-steel-50)
+  );
+  --color-fd-popover-foreground: light-dark(
+    var(--color-steel-900),
+    var(--color-steel-50)
+  );
+  --color-fd-popover: light-dark(var(--color-white), var(--color-steel-950));
+  --color-fd-primary-foreground: light-dark(
+    var(--color-steel-900),
+    var(--color-steel-50)
+  );
+  --color-fd-ring: light-dark(var(--color-violet-600), var(--color-violet-400));
+}

+ 35 - 0
apps/developer-hub/src/config/layout.config.tsx

@@ -0,0 +1,35 @@
+import type { DocsLayoutProps } from "fumadocs-ui/layouts/docs";
+import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
+
+import { source } from "../source";
+
+export const baseOptions: BaseLayoutProps = {
+  nav: {
+    enabled: false,
+  },
+  themeSwitch: {
+    enabled: false,
+  },
+};
+
+export const docsOptions: DocsLayoutProps = {
+  ...baseOptions,
+  tree: source.pageTree,
+  sidebar: {
+    tabs: {
+      transform(option, node) {
+        const meta = source.getNodeMeta(node);
+        if (!meta || !node.icon) return option;
+
+        return {
+          ...option,
+          icon: (
+            <div className="[&_svg]:size-6.5 md:[&_svg]:size-5">
+              {node.icon}
+            </div>
+          ),
+        };
+      },
+    },
+  },
+};

+ 29 - 0
apps/developer-hub/src/config/server.ts

@@ -0,0 +1,29 @@
+// 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";
+
+const getEnvOrDefault = (key: string, defaultValue: string) =>
+  process.env[key] ?? defaultValue;
+
+/**
+ * Indicates that this server is the live customer-facing production server.
+ */
+export const IS_PRODUCTION_SERVER = process.env.VERCEL_ENV === "production";
+
+const defaultInProduction = IS_PRODUCTION_SERVER
+  ? getEnvOrDefault
+  : (key: string) => process.env[key];
+
+export const GOOGLE_ANALYTICS_ID = defaultInProduction(
+  "GOOGLE_ANALYTICS_ID",
+  "G-E1QSY256EQ",
+);
+export const AMPLITUDE_API_KEY = defaultInProduction(
+  "AMPLITUDE_API_KEY",
+  "6faa78c51eff33087eb19f0f3dc76f33",
+);
+
+export const ENABLE_ACCESSIBILITY_REPORTING =
+  !IS_PRODUCTION_SERVER && !process.env.DISABLE_ACCESSIBILITY_REPORTING;

+ 9 - 0
apps/developer-hub/src/mdx-components.tsx

@@ -0,0 +1,9 @@
+import defaultMdxComponents from "fumadocs-ui/mdx";
+import type { MDXComponents } from "mdx/types";
+
+export function getMDXComponents(components?: MDXComponents): MDXComponents {
+  return {
+    ...defaultMdxComponents,
+    ...components,
+  };
+}

+ 52 - 0
apps/developer-hub/src/metadata.ts

@@ -0,0 +1,52 @@
+import type { Metadata, Viewport } from "next";
+
+export const metadata = {
+  metadataBase: new URL("https://developer.pyth.network"),
+  title: {
+    default: "Pyth Developer Hub",
+    template: "%s | Pyth Developer Hub",
+  },
+  applicationName: "Pyth Developer Hub",
+  description:
+    "Learn more about Pyth and how to integrate into your application.",
+  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;

+ 32 - 0
apps/developer-hub/src/source.ts

@@ -0,0 +1,32 @@
+import {
+  CardsThree,
+  ChartLine,
+  FolderSimpleDashed,
+  Gavel,
+  Lightning,
+  Shuffle,
+} from "@phosphor-icons/react/dist/ssr";
+import type { InferMetaType, InferPageType } from "fumadocs-core/source";
+import { loader } from "fumadocs-core/source";
+import { createElement } from "react";
+
+import { docs } from "../.source";
+
+const icons: Record<string, React.ComponentType> = {
+  CardsThree,
+  ChartLine,
+  Gavel,
+  Lightning,
+  Shuffle,
+};
+
+export const source = loader({
+  baseUrl: "/",
+  icon(icon) {
+    return icon ? createElement(icons[icon] ?? FolderSimpleDashed) : undefined;
+  },
+  source: docs.toFumadocsSource(),
+});
+
+export type Page = InferPageType<typeof source>;
+export type Meta = InferMetaType<typeof source>;

+ 21 - 0
apps/developer-hub/stylelint.config.js

@@ -0,0 +1,21 @@
+import standardScss from "stylelint-config-standard-scss";
+
+const config = {
+  extends: standardScss,
+  rules: {
+    "selector-class-pattern": [
+      "^[a-z][a-zA-Z0-9]+$",
+      {
+        message: (selector) =>
+          `Expected class selector "${selector}" to be camel-case`,
+      },
+    ],
+    "selector-pseudo-class-no-unknown": [
+      true,
+      {
+        ignorePseudoClasses: ["global", "export"],
+      },
+    ],
+  },
+};
+export default config;

+ 6 - 0
apps/developer-hub/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;
+}

+ 5 - 0
apps/developer-hub/tsconfig.json

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

+ 44 - 0
apps/developer-hub/turbo.json

@@ -0,0 +1,44 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "build": {
+      "env": [
+        "VERCEL_ENV",
+        "GOOGLE_ANALYTICS_ID",
+        "AMPLITUDE_API_KEY",
+        "DISABLE_ACCESSIBILITY_REPORTING"
+      ]
+    },
+    "fix:lint": {
+      "dependsOn": [
+        "//#install:modules",
+        "fix:lint:eslint",
+        "fix:lint:stylelint"
+      ]
+    },
+    "fix:lint:eslint": {
+      "dependsOn": ["//#install:modules", "^build"],
+      "cache": false
+    },
+    "fix:lint:stylelint": {
+      "dependsOn": ["//#install:modules"],
+      "cache": false
+    },
+    "start:prod": {
+      "dependsOn": ["//#install:modules", "build"]
+    },
+    "test:lint": {
+      "dependsOn": ["test:lint:eslint", "test:lint:stylelint"]
+    },
+    "test:lint:eslint": {
+      "dependsOn": ["//#install:modules", "^build"]
+    },
+    "test:lint:stylelint": {
+      "dependsOn": ["//#install:modules"]
+    },
+    "test:types": {
+      "dependsOn": ["//#install:modules", "^build", "build"]
+    }
+  }
+}

+ 4 - 0
apps/developer-hub/vercel.json

@@ -0,0 +1,4 @@
+{
+  "$schema": "https://openapi.vercel.sh/vercel.json",
+  "buildCommand": "turbo run build --filter @pythnetwork/developer-hub"
+}

+ 12 - 0
apps/fortuna/.sqlx/query-03901bcfb28b127d99fe8a53e480b88336dd2aab632411114f02ce8dd8fe07e8.json

@@ -0,0 +1,12 @@
+{
+  "db_name": "SQLite",
+  "query": "UPDATE request SET state = ?, last_updated_at = ?, info = ?, provider_random_number = ? WHERE network_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ? AND state = 'Pending'",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Right": 8
+    },
+    "nullable": []
+  },
+  "hash": "03901bcfb28b127d99fe8a53e480b88336dd2aab632411114f02ce8dd8fe07e8"
+}

+ 0 - 12
apps/fortuna/.sqlx/query-16635b3d9c6f9b743614e0e08bfa2b26d7ec6346f0323d9f16b98c32fd9a91f6.json

@@ -1,12 +0,0 @@
-{
-  "db_name": "SQLite",
-  "query": "INSERT INTO request(chain_id, provider, sequence, created_at, last_updated_at, state, request_block_number, request_tx_hash, user_random_number, sender) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-  "describe": {
-    "columns": [],
-    "parameters": {
-      "Right": 10
-    },
-    "nullable": []
-  },
-  "hash": "16635b3d9c6f9b743614e0e08bfa2b26d7ec6346f0323d9f16b98c32fd9a91f6"
-}

+ 35 - 17
apps/fortuna/.sqlx/query-b848d03ffc893e1719d364beb32976ef879e79727c660c973bdad670082f5c36.json → apps/fortuna/.sqlx/query-392da9e5fdd212a4a665c86e5fc6d4f619355294490248e656ad0fc97a252471.json

@@ -1,6 +1,6 @@
 {
   "db_name": "SQLite",
-  "query": "SELECT * FROM request WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ?",
+  "query": "SELECT * FROM request WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
   "describe": {
     "columns": [
       {
@@ -9,73 +9,88 @@
         "type_info": "Text"
       },
       {
-        "name": "provider",
+        "name": "network_id",
         "ordinal": 1,
+        "type_info": "Integer"
+      },
+      {
+        "name": "provider",
+        "ordinal": 2,
         "type_info": "Text"
       },
       {
         "name": "sequence",
-        "ordinal": 2,
+        "ordinal": 3,
         "type_info": "Integer"
       },
       {
         "name": "created_at",
-        "ordinal": 3,
+        "ordinal": 4,
         "type_info": "Datetime"
       },
       {
         "name": "last_updated_at",
-        "ordinal": 4,
+        "ordinal": 5,
         "type_info": "Datetime"
       },
       {
         "name": "state",
-        "ordinal": 5,
+        "ordinal": 6,
         "type_info": "Text"
       },
       {
         "name": "request_block_number",
-        "ordinal": 6,
+        "ordinal": 7,
         "type_info": "Integer"
       },
       {
         "name": "request_tx_hash",
-        "ordinal": 7,
+        "ordinal": 8,
         "type_info": "Text"
       },
       {
         "name": "user_random_number",
-        "ordinal": 8,
+        "ordinal": 9,
         "type_info": "Text"
       },
       {
         "name": "sender",
-        "ordinal": 9,
+        "ordinal": 10,
         "type_info": "Text"
       },
       {
         "name": "reveal_block_number",
-        "ordinal": 10,
+        "ordinal": 11,
         "type_info": "Integer"
       },
       {
         "name": "reveal_tx_hash",
-        "ordinal": 11,
+        "ordinal": 12,
         "type_info": "Text"
       },
       {
         "name": "provider_random_number",
-        "ordinal": 12,
+        "ordinal": 13,
         "type_info": "Text"
       },
       {
         "name": "info",
-        "ordinal": 13,
+        "ordinal": 14,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_used",
+        "ordinal": 15,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_limit",
+        "ordinal": 16,
         "type_info": "Text"
       }
     ],
     "parameters": {
-      "Right": 3
+      "Right": 4
     },
     "nullable": [
       false,
@@ -88,11 +103,14 @@
       false,
       false,
       false,
+      false,
+      true,
+      true,
       true,
       true,
       true,
-      true
+      false
     ]
   },
-  "hash": "b848d03ffc893e1719d364beb32976ef879e79727c660c973bdad670082f5c36"
+  "hash": "392da9e5fdd212a4a665c86e5fc6d4f619355294490248e656ad0fc97a252471"
 }

+ 3 - 3
apps/fortuna/.sqlx/query-9d7448c9bbad50d6242dfc0ba7d5ad4837201a1585bd56cc9a65fe75d0fa5952.json → apps/fortuna/.sqlx/query-4c8c05ec08e128d847faafdd3d79fa50da70066f30b74f354e5d3a843ba6a2c0.json

@@ -1,12 +1,12 @@
 {
   "db_name": "SQLite",
-  "query": "UPDATE request SET state = ?, last_updated_at = ?, reveal_block_number = ?, reveal_tx_hash = ?, provider_random_number = ? WHERE chain_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ?",
+  "query": "UPDATE request SET state = ?, last_updated_at = ?, reveal_block_number = ?, reveal_tx_hash = ?, provider_random_number =?, gas_used = ? WHERE network_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ?",
   "describe": {
     "columns": [],
     "parameters": {
-      "Right": 9
+      "Right": 10
     },
     "nullable": []
   },
-  "hash": "9d7448c9bbad50d6242dfc0ba7d5ad4837201a1585bd56cc9a65fe75d0fa5952"
+  "hash": "4c8c05ec08e128d847faafdd3d79fa50da70066f30b74f354e5d3a843ba6a2c0"
 }

+ 35 - 17
apps/fortuna/.sqlx/query-ba011bb5690ad6821689bec939c5303c8619b6302ef33145db3bf62259492783.json → apps/fortuna/.sqlx/query-78be8c62d5eb764995221f927b0f166e38d6fba8eb8fddb07f50c572fd27b4e2.json

@@ -1,6 +1,6 @@
 {
   "db_name": "SQLite",
-  "query": "SELECT * FROM request WHERE chain_id = ? AND created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ?",
+  "query": "SELECT * FROM request WHERE network_id = ? AND created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
   "describe": {
     "columns": [
       {
@@ -9,73 +9,88 @@
         "type_info": "Text"
       },
       {
-        "name": "provider",
+        "name": "network_id",
         "ordinal": 1,
+        "type_info": "Integer"
+      },
+      {
+        "name": "provider",
+        "ordinal": 2,
         "type_info": "Text"
       },
       {
         "name": "sequence",
-        "ordinal": 2,
+        "ordinal": 3,
         "type_info": "Integer"
       },
       {
         "name": "created_at",
-        "ordinal": 3,
+        "ordinal": 4,
         "type_info": "Datetime"
       },
       {
         "name": "last_updated_at",
-        "ordinal": 4,
+        "ordinal": 5,
         "type_info": "Datetime"
       },
       {
         "name": "state",
-        "ordinal": 5,
+        "ordinal": 6,
         "type_info": "Text"
       },
       {
         "name": "request_block_number",
-        "ordinal": 6,
+        "ordinal": 7,
         "type_info": "Integer"
       },
       {
         "name": "request_tx_hash",
-        "ordinal": 7,
+        "ordinal": 8,
         "type_info": "Text"
       },
       {
         "name": "user_random_number",
-        "ordinal": 8,
+        "ordinal": 9,
         "type_info": "Text"
       },
       {
         "name": "sender",
-        "ordinal": 9,
+        "ordinal": 10,
         "type_info": "Text"
       },
       {
         "name": "reveal_block_number",
-        "ordinal": 10,
+        "ordinal": 11,
         "type_info": "Integer"
       },
       {
         "name": "reveal_tx_hash",
-        "ordinal": 11,
+        "ordinal": 12,
         "type_info": "Text"
       },
       {
         "name": "provider_random_number",
-        "ordinal": 12,
+        "ordinal": 13,
         "type_info": "Text"
       },
       {
         "name": "info",
-        "ordinal": 13,
+        "ordinal": 14,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_used",
+        "ordinal": 15,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_limit",
+        "ordinal": 16,
         "type_info": "Text"
       }
     ],
     "parameters": {
-      "Right": 4
+      "Right": 5
     },
     "nullable": [
       false,
@@ -88,11 +103,14 @@
       false,
       false,
       false,
+      false,
+      true,
+      true,
       true,
       true,
       true,
-      true
+      false
     ]
   },
-  "hash": "ba011bb5690ad6821689bec939c5303c8619b6302ef33145db3bf62259492783"
+  "hash": "78be8c62d5eb764995221f927b0f166e38d6fba8eb8fddb07f50c572fd27b4e2"
 }

+ 32 - 14
apps/fortuna/.sqlx/query-8cd10cd5839b81bd9538aeb10fdfd27c6e36baf5d90a4fb9e61718f021812710.json

@@ -9,68 +9,83 @@
         "type_info": "Text"
       },
       {
-        "name": "provider",
+        "name": "network_id",
         "ordinal": 1,
+        "type_info": "Integer"
+      },
+      {
+        "name": "provider",
+        "ordinal": 2,
         "type_info": "Text"
       },
       {
         "name": "sequence",
-        "ordinal": 2,
+        "ordinal": 3,
         "type_info": "Integer"
       },
       {
         "name": "created_at",
-        "ordinal": 3,
+        "ordinal": 4,
         "type_info": "Datetime"
       },
       {
         "name": "last_updated_at",
-        "ordinal": 4,
+        "ordinal": 5,
         "type_info": "Datetime"
       },
       {
         "name": "state",
-        "ordinal": 5,
+        "ordinal": 6,
         "type_info": "Text"
       },
       {
         "name": "request_block_number",
-        "ordinal": 6,
+        "ordinal": 7,
         "type_info": "Integer"
       },
       {
         "name": "request_tx_hash",
-        "ordinal": 7,
+        "ordinal": 8,
         "type_info": "Text"
       },
       {
         "name": "user_random_number",
-        "ordinal": 8,
+        "ordinal": 9,
         "type_info": "Text"
       },
       {
         "name": "sender",
-        "ordinal": 9,
+        "ordinal": 10,
         "type_info": "Text"
       },
       {
         "name": "reveal_block_number",
-        "ordinal": 10,
+        "ordinal": 11,
         "type_info": "Integer"
       },
       {
         "name": "reveal_tx_hash",
-        "ordinal": 11,
+        "ordinal": 12,
         "type_info": "Text"
       },
       {
         "name": "provider_random_number",
-        "ordinal": 12,
+        "ordinal": 13,
         "type_info": "Text"
       },
       {
         "name": "info",
-        "ordinal": 13,
+        "ordinal": 14,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_used",
+        "ordinal": 15,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_limit",
+        "ordinal": 16,
         "type_info": "Text"
       }
     ],
@@ -88,10 +103,13 @@
       false,
       false,
       false,
+      false,
+      true,
+      true,
       true,
       true,
       true,
-      true
+      false
     ]
   },
   "hash": "8cd10cd5839b81bd9538aeb10fdfd27c6e36baf5d90a4fb9e61718f021812710"

+ 32 - 14
apps/fortuna/.sqlx/query-905dbc91cd5319537c5c194277d531689ac5c1338396414467496d0f50ddc3f0.json

@@ -9,68 +9,83 @@
         "type_info": "Text"
       },
       {
-        "name": "provider",
+        "name": "network_id",
         "ordinal": 1,
+        "type_info": "Integer"
+      },
+      {
+        "name": "provider",
+        "ordinal": 2,
         "type_info": "Text"
       },
       {
         "name": "sequence",
-        "ordinal": 2,
+        "ordinal": 3,
         "type_info": "Integer"
       },
       {
         "name": "created_at",
-        "ordinal": 3,
+        "ordinal": 4,
         "type_info": "Datetime"
       },
       {
         "name": "last_updated_at",
-        "ordinal": 4,
+        "ordinal": 5,
         "type_info": "Datetime"
       },
       {
         "name": "state",
-        "ordinal": 5,
+        "ordinal": 6,
         "type_info": "Text"
       },
       {
         "name": "request_block_number",
-        "ordinal": 6,
+        "ordinal": 7,
         "type_info": "Integer"
       },
       {
         "name": "request_tx_hash",
-        "ordinal": 7,
+        "ordinal": 8,
         "type_info": "Text"
       },
       {
         "name": "user_random_number",
-        "ordinal": 8,
+        "ordinal": 9,
         "type_info": "Text"
       },
       {
         "name": "sender",
-        "ordinal": 9,
+        "ordinal": 10,
         "type_info": "Text"
       },
       {
         "name": "reveal_block_number",
-        "ordinal": 10,
+        "ordinal": 11,
         "type_info": "Integer"
       },
       {
         "name": "reveal_tx_hash",
-        "ordinal": 11,
+        "ordinal": 12,
         "type_info": "Text"
       },
       {
         "name": "provider_random_number",
-        "ordinal": 12,
+        "ordinal": 13,
         "type_info": "Text"
       },
       {
         "name": "info",
-        "ordinal": 13,
+        "ordinal": 14,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_used",
+        "ordinal": 15,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_limit",
+        "ordinal": 16,
         "type_info": "Text"
       }
     ],
@@ -88,10 +103,13 @@
       false,
       false,
       false,
+      false,
+      true,
+      true,
       true,
       true,
       true,
-      true
+      false
     ]
   },
   "hash": "905dbc91cd5319537c5c194277d531689ac5c1338396414467496d0f50ddc3f0"

+ 32 - 14
apps/fortuna/.sqlx/query-a62e094cee65ae58bd12ce7d3e7df44f5aca31520d1ceced83f492945e850764.json

@@ -9,68 +9,83 @@
         "type_info": "Text"
       },
       {
-        "name": "provider",
+        "name": "network_id",
         "ordinal": 1,
+        "type_info": "Integer"
+      },
+      {
+        "name": "provider",
+        "ordinal": 2,
         "type_info": "Text"
       },
       {
         "name": "sequence",
-        "ordinal": 2,
+        "ordinal": 3,
         "type_info": "Integer"
       },
       {
         "name": "created_at",
-        "ordinal": 3,
+        "ordinal": 4,
         "type_info": "Datetime"
       },
       {
         "name": "last_updated_at",
-        "ordinal": 4,
+        "ordinal": 5,
         "type_info": "Datetime"
       },
       {
         "name": "state",
-        "ordinal": 5,
+        "ordinal": 6,
         "type_info": "Text"
       },
       {
         "name": "request_block_number",
-        "ordinal": 6,
+        "ordinal": 7,
         "type_info": "Integer"
       },
       {
         "name": "request_tx_hash",
-        "ordinal": 7,
+        "ordinal": 8,
         "type_info": "Text"
       },
       {
         "name": "user_random_number",
-        "ordinal": 8,
+        "ordinal": 9,
         "type_info": "Text"
       },
       {
         "name": "sender",
-        "ordinal": 9,
+        "ordinal": 10,
         "type_info": "Text"
       },
       {
         "name": "reveal_block_number",
-        "ordinal": 10,
+        "ordinal": 11,
         "type_info": "Integer"
       },
       {
         "name": "reveal_tx_hash",
-        "ordinal": 11,
+        "ordinal": 12,
         "type_info": "Text"
       },
       {
         "name": "provider_random_number",
-        "ordinal": 12,
+        "ordinal": 13,
         "type_info": "Text"
       },
       {
         "name": "info",
-        "ordinal": 13,
+        "ordinal": 14,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_used",
+        "ordinal": 15,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_limit",
+        "ordinal": 16,
         "type_info": "Text"
       }
     ],
@@ -88,10 +103,13 @@
       false,
       false,
       false,
+      false,
+      true,
+      true,
       true,
       true,
       true,
-      true
+      false
     ]
   },
   "hash": "a62e094cee65ae58bd12ce7d3e7df44f5aca31520d1ceced83f492945e850764"

+ 12 - 0
apps/fortuna/.sqlx/query-b0d9afebb3825c3509ad80e5ebab5d72360326593407518770fe537ac3da1e10.json

@@ -0,0 +1,12 @@
+{
+  "db_name": "SQLite",
+  "query": "INSERT INTO request(chain_id, network_id, provider, sequence, created_at, last_updated_at, state, request_block_number, request_tx_hash, user_random_number, sender, gas_limit) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Right": 12
+    },
+    "nullable": []
+  },
+  "hash": "b0d9afebb3825c3509ad80e5ebab5d72360326593407518770fe537ac3da1e10"
+}

+ 0 - 12
apps/fortuna/.sqlx/query-b2baa9f9d46f873a3a7117c38ecab09f56082c5267dbf5180f39c608b6262f5a.json

@@ -1,12 +0,0 @@
-{
-  "db_name": "SQLite",
-  "query": "UPDATE request SET state = ?, last_updated_at = ?, info = ? WHERE chain_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ? AND state = 'Pending'",
-  "describe": {
-    "columns": [],
-    "parameters": {
-      "Right": 7
-    },
-    "nullable": []
-  },
-  "hash": "b2baa9f9d46f873a3a7117c38ecab09f56082c5267dbf5180f39c608b6262f5a"
-}

+ 34 - 16
apps/fortuna/.sqlx/query-795b81369e5b039cfa38df06bd6c8da8610d84f19a294fb8d3a8370a47a3f241.json → apps/fortuna/.sqlx/query-c9e3089b1ffd52d20cfcd89e71e051c0f351643dce9be4b84b6343909c816c22.json

@@ -1,6 +1,6 @@
 {
   "db_name": "SQLite",
-  "query": "SELECT * FROM request WHERE sender = ? AND chain_id = ?",
+  "query": "SELECT * FROM request WHERE sequence = ? AND network_id = ?",
   "describe": {
     "columns": [
       {
@@ -9,68 +9,83 @@
         "type_info": "Text"
       },
       {
-        "name": "provider",
+        "name": "network_id",
         "ordinal": 1,
+        "type_info": "Integer"
+      },
+      {
+        "name": "provider",
+        "ordinal": 2,
         "type_info": "Text"
       },
       {
         "name": "sequence",
-        "ordinal": 2,
+        "ordinal": 3,
         "type_info": "Integer"
       },
       {
         "name": "created_at",
-        "ordinal": 3,
+        "ordinal": 4,
         "type_info": "Datetime"
       },
       {
         "name": "last_updated_at",
-        "ordinal": 4,
+        "ordinal": 5,
         "type_info": "Datetime"
       },
       {
         "name": "state",
-        "ordinal": 5,
+        "ordinal": 6,
         "type_info": "Text"
       },
       {
         "name": "request_block_number",
-        "ordinal": 6,
+        "ordinal": 7,
         "type_info": "Integer"
       },
       {
         "name": "request_tx_hash",
-        "ordinal": 7,
+        "ordinal": 8,
         "type_info": "Text"
       },
       {
         "name": "user_random_number",
-        "ordinal": 8,
+        "ordinal": 9,
         "type_info": "Text"
       },
       {
         "name": "sender",
-        "ordinal": 9,
+        "ordinal": 10,
         "type_info": "Text"
       },
       {
         "name": "reveal_block_number",
-        "ordinal": 10,
+        "ordinal": 11,
         "type_info": "Integer"
       },
       {
         "name": "reveal_tx_hash",
-        "ordinal": 11,
+        "ordinal": 12,
         "type_info": "Text"
       },
       {
         "name": "provider_random_number",
-        "ordinal": 12,
+        "ordinal": 13,
         "type_info": "Text"
       },
       {
         "name": "info",
-        "ordinal": 13,
+        "ordinal": 14,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_used",
+        "ordinal": 15,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_limit",
+        "ordinal": 16,
         "type_info": "Text"
       }
     ],
@@ -88,11 +103,14 @@
       false,
       false,
       false,
+      false,
+      true,
+      true,
       true,
       true,
       true,
-      true
+      false
     ]
   },
-  "hash": "795b81369e5b039cfa38df06bd6c8da8610d84f19a294fb8d3a8370a47a3f241"
+  "hash": "c9e3089b1ffd52d20cfcd89e71e051c0f351643dce9be4b84b6343909c816c22"
 }

+ 34 - 16
apps/fortuna/.sqlx/query-7d4365a9cb7c9ec16fd4ca60e1d558419954a0326b29180fa9943605813f04e6.json → apps/fortuna/.sqlx/query-f58bdd3e0ecb30f35356c22e9ab1b3802f8eebda455efabc18d30f02d23787b7.json

@@ -1,6 +1,6 @@
 {
   "db_name": "SQLite",
-  "query": "SELECT * FROM request WHERE sequence = ? AND chain_id = ?",
+  "query": "SELECT * FROM request WHERE sender = ? AND network_id = ?",
   "describe": {
     "columns": [
       {
@@ -9,68 +9,83 @@
         "type_info": "Text"
       },
       {
-        "name": "provider",
+        "name": "network_id",
         "ordinal": 1,
+        "type_info": "Integer"
+      },
+      {
+        "name": "provider",
+        "ordinal": 2,
         "type_info": "Text"
       },
       {
         "name": "sequence",
-        "ordinal": 2,
+        "ordinal": 3,
         "type_info": "Integer"
       },
       {
         "name": "created_at",
-        "ordinal": 3,
+        "ordinal": 4,
         "type_info": "Datetime"
       },
       {
         "name": "last_updated_at",
-        "ordinal": 4,
+        "ordinal": 5,
         "type_info": "Datetime"
       },
       {
         "name": "state",
-        "ordinal": 5,
+        "ordinal": 6,
         "type_info": "Text"
       },
       {
         "name": "request_block_number",
-        "ordinal": 6,
+        "ordinal": 7,
         "type_info": "Integer"
       },
       {
         "name": "request_tx_hash",
-        "ordinal": 7,
+        "ordinal": 8,
         "type_info": "Text"
       },
       {
         "name": "user_random_number",
-        "ordinal": 8,
+        "ordinal": 9,
         "type_info": "Text"
       },
       {
         "name": "sender",
-        "ordinal": 9,
+        "ordinal": 10,
         "type_info": "Text"
       },
       {
         "name": "reveal_block_number",
-        "ordinal": 10,
+        "ordinal": 11,
         "type_info": "Integer"
       },
       {
         "name": "reveal_tx_hash",
-        "ordinal": 11,
+        "ordinal": 12,
         "type_info": "Text"
       },
       {
         "name": "provider_random_number",
-        "ordinal": 12,
+        "ordinal": 13,
         "type_info": "Text"
       },
       {
         "name": "info",
-        "ordinal": 13,
+        "ordinal": 14,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_used",
+        "ordinal": 15,
+        "type_info": "Text"
+      },
+      {
+        "name": "gas_limit",
+        "ordinal": 16,
         "type_info": "Text"
       }
     ],
@@ -88,11 +103,14 @@
       false,
       false,
       false,
+      false,
+      true,
+      true,
       true,
       true,
       true,
-      true
+      false
     ]
   },
-  "hash": "7d4365a9cb7c9ec16fd4ca60e1d558419954a0326b29180fa9943605813f04e6"
+  "hash": "f58bdd3e0ecb30f35356c22e9ab1b3802f8eebda455efabc18d30f02d23787b7"
 }

+ 1 - 1
apps/fortuna/Cargo.lock

@@ -1647,7 +1647,7 @@ dependencies = [
 
 [[package]]
 name = "fortuna"
-version = "7.6.0"
+version = "7.6.3"
 dependencies = [
  "anyhow",
  "axum",

+ 1 - 1
apps/fortuna/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "fortuna"
-version = "7.6.0"
+version = "7.6.3"
 edition = "2021"
 
 [lib]

+ 3 - 0
apps/fortuna/config.sample.yaml

@@ -32,6 +32,9 @@ chains:
     # How much to charge in fees
     fee: 1500000000000000
 
+    # Set this temporarily to false if you have changed the fees and want to apply a new baseline fee.
+    sync_fee_only_on_register: true
+
     # Configuration for dynamic fees under high gas prices. The keeper will set
     # on-chain fees to make between [min_profit_pct, max_profit_pct] of the max callback
     # cost in profit per transaction.

+ 3 - 2
apps/fortuna/migrations/20250502164500_init.up.sql

@@ -1,6 +1,7 @@
 -- we use VARCHAR(40) for addresses and VARCHAR(64) for tx_hashes and 32 byte numbers
 CREATE TABLE request(
                     chain_id VARCHAR(20) NOT NULL,
+                    network_id INTEGER NOT NULL,
                     provider VARCHAR(40) NOT NULL,
                     sequence INTEGER NOT NULL,
                     created_at DATETIME NOT NULL,
@@ -14,11 +15,11 @@ CREATE TABLE request(
                     reveal_tx_hash VARCHAR(64),
                     provider_random_number VARCHAR(64),
                     info TEXT,
-                    PRIMARY KEY (chain_id, sequence, provider, request_tx_hash)
+                    PRIMARY KEY (network_id, sequence, provider, request_tx_hash)
 );
 
 CREATE INDEX idx_request_sequence ON request (sequence);
-CREATE INDEX idx_request_chain_id_created_at ON request (chain_id, created_at);
+CREATE INDEX idx_request_network_id_created_at ON request (network_id, created_at);
 CREATE INDEX idx_request_created_at ON request (created_at);
 CREATE INDEX idx_request_request_tx_hash ON request (request_tx_hash) WHERE request_tx_hash IS NOT NULL;
 CREATE INDEX idx_request_reveal_tx_hash ON request (reveal_tx_hash) WHERE reveal_tx_hash IS NOT NULL;

+ 4 - 0
apps/fortuna/migrations/20250521203448_gas.down.sql

@@ -0,0 +1,4 @@
+ALTER TABLE request
+DROP COLUMN gas_used;
+ALTER TABLE request
+DROP COLUMN gas_limit;

+ 5 - 0
apps/fortuna/migrations/20250521203448_gas.up.sql

@@ -0,0 +1,5 @@
+-- U256 max value is 78 digits, so 100 is a safe upper bound
+ALTER TABLE request
+ADD COLUMN gas_used VARCHAR(100);
+ALTER TABLE request
+ADD COLUMN gas_limit VARCHAR(100) NOT NULL;

+ 25 - 5
apps/fortuna/src/api.rs

@@ -2,7 +2,7 @@ use {
     crate::{
         chain::reader::{BlockNumber, BlockStatus, EntropyReader},
         history::History,
-        state::HashChainState,
+        state::MonitoredHashChainState,
     },
     anyhow::Result,
     axum::{
@@ -33,6 +33,7 @@ mod ready;
 mod revelation;
 
 pub type ChainId = String;
+pub type NetworkId = u64;
 
 #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]
 pub struct RequestLabel {
@@ -86,8 +87,11 @@ impl ApiState {
 pub struct BlockchainState {
     /// The chain id for this blockchain, useful for logging
     pub id: ChainId,
+    /// The network id for this blockchain
+    /// Obtained from the response of eth_chainId rpc call
+    pub network_id: u64,
     /// The hash chain(s) required to serve random numbers for this blockchain
-    pub state: Arc<HashChainState>,
+    pub state: Arc<MonitoredHashChainState>,
     /// The contract that the server is fulfilling requests for.
     pub contract: Arc<dyn EntropyReader>,
     /// The address of the provider that this server is operating for.
@@ -208,7 +212,7 @@ mod test {
             },
             chain::reader::{mock::MockEntropyReader, BlockStatus},
             history::History,
-            state::{HashChainState, PebbleHashChain},
+            state::{HashChainState, MonitoredHashChainState, PebbleHashChain},
         },
         axum::http::StatusCode,
         axum_test::{TestResponse, TestServer},
@@ -237,9 +241,17 @@ mod test {
     async fn test_server() -> (TestServer, Arc<MockEntropyReader>, Arc<MockEntropyReader>) {
         let eth_read = Arc::new(MockEntropyReader::with_requests(10, &[]));
 
+        let eth_state = MonitoredHashChainState::new(
+            ETH_CHAIN.clone(),
+            Default::default(),
+            "ethereum".into(),
+            PROVIDER,
+        );
+
         let eth_state = BlockchainState {
             id: "ethereum".into(),
-            state: ETH_CHAIN.clone(),
+            network_id: 1,
+            state: Arc::new(eth_state),
             contract: eth_read.clone(),
             provider_address: PROVIDER,
             reveal_delay_blocks: 1,
@@ -250,9 +262,17 @@ mod test {
 
         let avax_read = Arc::new(MockEntropyReader::with_requests(10, &[]));
 
+        let avax_state = MonitoredHashChainState::new(
+            AVAX_CHAIN.clone(),
+            Default::default(),
+            "avalanche".into(),
+            PROVIDER,
+        );
+
         let avax_state = BlockchainState {
             id: "avalanche".into(),
-            state: AVAX_CHAIN.clone(),
+            network_id: 43114,
+            state: Arc::new(avax_state),
             contract: avax_read.clone(),
             provider_address: PROVIDER,
             reveal_delay_blocks: 2,

+ 34 - 13
apps/fortuna/src/api/explorer.rs

@@ -1,6 +1,6 @@
 use {
     crate::{
-        api::{ChainId, RestError},
+        api::{ApiBlockChainState, NetworkId, RestError},
         history::RequestStatus,
     },
     axum::{
@@ -16,24 +16,30 @@ use {
 #[derive(Debug, serde::Serialize, serde::Deserialize, IntoParams)]
 #[into_params(parameter_in=Query)]
 pub struct ExplorerQueryParams {
-    /// Only return logs that are newer or equal to this timestamp.
+    /// Only return logs that are newer or equal to this timestamp. Timestamp is in ISO 8601 format with UTC timezone.
     #[param(value_type = Option<String>, example = "2023-10-01T00:00:00Z")]
     pub min_timestamp: Option<DateTime<Utc>>,
-    /// Only return logs that are older or equal to this timestamp.
+    /// Only return logs that are older or equal to this timestamp. Timestamp is in ISO 8601 format with UTC timezone.
     #[param(value_type = Option<String>, example = "2033-10-01T00:00:00Z")]
     pub max_timestamp: Option<DateTime<Utc>>,
     /// The query string to search for. This can be a transaction hash, sender address, or sequence number.
     pub query: Option<String>,
-    #[param(value_type = Option<String>)]
-    /// The chain ID to filter the results by.
-    pub chain_id: Option<ChainId>,
+    /// The network ID to filter the results by.
+    #[param(value_type = Option<u64>)]
+    pub network_id: Option<NetworkId>,
+    /// The maximum number of logs to return. Max value is 1000.
+    #[param(default = 1000)]
+    pub limit: Option<u64>,
+    /// The offset to start returning logs from.
+    #[param(default = 0)]
+    pub offset: Option<u64>,
 }
 
 const LOG_RETURN_LIMIT: u64 = 1000;
 
 /// Returns the logs of all requests captured by the keeper.
 ///
-/// This endpoint allows you to filter the logs by a specific chain ID, a query string (which can be a transaction hash, sender address, or sequence number), and a time range.
+/// This endpoint allows you to filter the logs by a specific network ID, a query string (which can be a transaction hash, sender address, or sequence number), and a time range.
 /// This is useful for debugging and monitoring the requests made to the Entropy contracts on various chains.
 #[utoipa::path(
     get,
@@ -45,11 +51,25 @@ pub async fn explorer(
     State(state): State<crate::api::ApiState>,
     Query(query_params): Query<ExplorerQueryParams>,
 ) -> anyhow::Result<Json<Vec<RequestStatus>>, RestError> {
-    if let Some(chain_id) = &query_params.chain_id {
-        if !state.chains.read().await.contains_key(chain_id) {
+    if let Some(network_id) = &query_params.network_id {
+        if !state
+            .chains
+            .read()
+            .await
+            .iter()
+            .any(|(_, state)| match state {
+                ApiBlockChainState::Uninitialized => false,
+                ApiBlockChainState::Initialized(state) => state.network_id == *network_id,
+            })
+        {
             return Err(RestError::InvalidChainId);
         }
     }
+    if let Some(limit) = query_params.limit {
+        if limit > LOG_RETURN_LIMIT || limit == 0 {
+            return Err(RestError::InvalidQueryString);
+        }
+    }
     if let Some(query) = query_params.query {
         if let Ok(tx_hash) = TxHash::from_str(&query) {
             return Ok(Json(
@@ -64,7 +84,7 @@ pub async fn explorer(
             return Ok(Json(
                 state
                     .history
-                    .get_requests_by_sender(sender, query_params.chain_id)
+                    .get_requests_by_sender(sender, query_params.network_id)
                     .await
                     .map_err(|_| RestError::TemporarilyUnavailable)?,
             ));
@@ -73,7 +93,7 @@ pub async fn explorer(
             return Ok(Json(
                 state
                     .history
-                    .get_requests_by_sequence(sequence_number, query_params.chain_id)
+                    .get_requests_by_sequence(sequence_number, query_params.network_id)
                     .await
                     .map_err(|_| RestError::TemporarilyUnavailable)?,
             ));
@@ -84,8 +104,9 @@ pub async fn explorer(
         state
             .history
             .get_requests_by_time(
-                query_params.chain_id,
-                LOG_RETURN_LIMIT,
+                query_params.network_id,
+                query_params.limit.unwrap_or(LOG_RETURN_LIMIT),
+                query_params.offset.unwrap_or(0),
                 query_params.min_timestamp,
                 query_params.max_timestamp,
             )

+ 33 - 5
apps/fortuna/src/chain/ethereum.rs

@@ -160,17 +160,17 @@ impl<T: JsonRpcClient + 'static + Clone> SignablePythContractInner<T> {
         }
     }
 
-    pub async fn from_config_and_provider(
+    pub fn from_config_and_provider_and_network_id(
         chain_config: &EthereumConfig,
         private_key: &str,
         provider: Provider<T>,
+        network_id: u64,
     ) -> Result<SignablePythContractInner<T>> {
-        let chain_id = provider.get_chainid().await?;
         let gas_oracle =
             EthProviderOracle::new(provider.clone(), chain_config.priority_fee_multiplier_pct);
         let wallet__ = private_key
             .parse::<LocalWallet>()?
-            .with_chain_id(chain_id.as_u64());
+            .with_chain_id(network_id);
 
         let address = wallet__.address();
 
@@ -185,6 +185,20 @@ impl<T: JsonRpcClient + 'static + Clone> SignablePythContractInner<T> {
             )),
         ))
     }
+
+    pub async fn from_config_and_provider(
+        chain_config: &EthereumConfig,
+        private_key: &str,
+        provider: Provider<T>,
+    ) -> Result<SignablePythContractInner<T>> {
+        let network_id = provider.get_chainid().await?.as_u64();
+        Self::from_config_and_provider_and_network_id(
+            chain_config,
+            private_key,
+            provider,
+            network_id,
+        )
+    }
 }
 
 impl SignablePythContract {
@@ -195,14 +209,20 @@ impl SignablePythContract {
 }
 
 impl InstrumentedSignablePythContract {
-    pub async fn from_config(
+    pub fn from_config(
         chain_config: &EthereumConfig,
         private_key: &str,
         chain_id: ChainId,
         metrics: Arc<RpcMetrics>,
+        network_id: u64,
     ) -> Result<Self> {
         let provider = TracedClient::new(chain_id, &chain_config.geth_rpc_addr, metrics)?;
-        Self::from_config_and_provider(chain_config, private_key, provider).await
+        Self::from_config_and_provider_and_network_id(
+            chain_config,
+            private_key,
+            provider,
+            network_id,
+        )
     }
 }
 
@@ -232,6 +252,13 @@ impl InstrumentedPythContract {
     }
 }
 
+impl<T: JsonRpcClient + 'static> PythRandom<Provider<T>> {
+    pub async fn get_network_id(&self) -> Result<U256> {
+        let chain_id = self.client().get_chainid().await?;
+        Ok(chain_id)
+    }
+}
+
 #[async_trait]
 impl<T: JsonRpcClient + 'static> EntropyReader for PythRandom<Provider<T>> {
     async fn get_request(
@@ -282,6 +309,7 @@ impl<T: JsonRpcClient + 'static> EntropyReader for PythRandom<Provider<T>> {
         let mut event = self.requested_with_callback_filter();
         event.filter = event
             .filter
+            .address(self.address())
             .from_block(from_block)
             .to_block(to_block)
             .topic1(provider);

+ 1 - 0
apps/fortuna/src/command/generate.rs

@@ -48,6 +48,7 @@ pub async fn generate(opts: &GenerateOptions) -> Result<()> {
         let mut event = contract.revealed_with_callback_filter();
         event.filter = event
             .filter
+            .address(contract.address())
             .from_block(last_block_number)
             .to_block(current_block_number);
 

+ 23 - 7
apps/fortuna/src/command/run.rs

@@ -7,7 +7,7 @@ use {
         eth_utils::traced_client::RpcMetrics,
         history::History,
         keeper::{self, keeper_metrics::KeeperMetrics},
-        state::{HashChainState, PebbleHashChain},
+        state::{HashChainState, MonitoredHashChainState, PebbleHashChain},
     },
     anyhow::{anyhow, Error, Result},
     axum::Router,
@@ -183,6 +183,7 @@ async fn setup_chain_and_run_keeper(
         chain_id,
         &chain_config,
         rpc_metrics.clone(),
+        keeper_metrics.clone(),
     )
     .await?;
     chains.write().await.insert(
@@ -210,19 +211,29 @@ async fn setup_chain_state(
     chain_id: &ChainId,
     chain_config: &EthereumConfig,
     rpc_metrics: Arc<RpcMetrics>,
+    keeper_metrics: Arc<KeeperMetrics>,
 ) -> Result<BlockchainState> {
     let contract = Arc::new(InstrumentedPythContract::from_config(
         chain_config,
         chain_id.clone(),
         rpc_metrics,
     )?);
+    let network_id: u64 = contract
+        .get_network_id()
+        .await
+        .map_err(|e| anyhow!("Failed to get network id: {}. Chain id: {}", &chain_id, e))?
+        .as_u64();
     let mut provider_commitments = chain_config.commitments.clone().unwrap_or_default();
     provider_commitments.sort_by(|c1, c2| {
         c1.original_commitment_sequence_number
             .cmp(&c2.original_commitment_sequence_number)
     });
 
-    let provider_info = contract.get_provider_info(*provider).call().await?;
+    let provider_info = contract
+        .get_provider_info(*provider)
+        .call()
+        .await
+        .map_err(|e| anyhow!("Failed to get provider info: {}", e))?;
     let latest_metadata = bincode::deserialize::<CommitmentMetadata>(
         &provider_info.commitment_metadata,
     )
@@ -275,10 +286,7 @@ async fn setup_chain_state(
         hash_chains.push(pebble_hash_chain);
     }
 
-    let chain_state = HashChainState {
-        offsets,
-        hash_chains,
-    };
+    let chain_state = HashChainState::new(offsets, hash_chains)?;
 
     if chain_state.reveal(provider_info.original_commitment_sequence_number)?
         != provider_info.original_commitment
@@ -288,9 +296,17 @@ async fn setup_chain_state(
         tracing::info!("Root of chain id {} matches commitment", &chain_id);
     }
 
+    let monitored_chain_state = MonitoredHashChainState::new(
+        Arc::new(chain_state),
+        keeper_metrics.clone(),
+        chain_id.clone(),
+        *provider,
+    );
+
     let state = BlockchainState {
         id: chain_id.clone(),
-        state: Arc::new(chain_state),
+        state: Arc::new(monitored_chain_state),
+        network_id,
         contract,
         provider_address: *provider,
         reveal_delay_blocks: chain_config.reveal_delay_blocks,

+ 9 - 7
apps/fortuna/src/command/setup_provider.rs

@@ -122,12 +122,12 @@ async fn setup_chain_provider(
                 provider_config.chain_sample_interval,
             )
             .await?;
-            let chain_state = HashChainState {
-                offsets: vec![provider_info
+            let chain_state = HashChainState::new(
+                vec![provider_info
                     .original_commitment_sequence_number
                     .try_into()?],
-                hash_chains: vec![hash_chain],
-            };
+                vec![hash_chain],
+            )?;
 
             if chain_state.reveal(provider_info.original_commitment_sequence_number)?
                 != provider_info.original_commitment
@@ -149,9 +149,11 @@ async fn setup_chain_provider(
 
     let provider_info = contract.get_provider_info(provider_address).call().await?;
 
-    sync_fee(&contract, &provider_info, chain_config.fee)
-        .in_current_span()
-        .await?;
+    if register || !chain_config.sync_fee_only_on_register {
+        sync_fee(&contract, &provider_info, chain_config.fee)
+            .in_current_span()
+            .await?;
+    }
 
     let uri = get_register_uri(&provider_config.uri, chain_id)?;
     sync_uri(&contract, &provider_info, uri)

+ 9 - 0
apps/fortuna/src/config.rs

@@ -172,6 +172,11 @@ pub struct EthereumConfig {
     #[serde(default)]
     pub fee: u128,
 
+    /// Only set the provider's fee when the provider is registered for the first time. Default is true.
+    /// This is useful to avoid resetting the fees on service restarts.
+    #[serde(default = "default_sync_fee_only_on_register")]
+    pub sync_fee_only_on_register: bool,
+
     /// Historical commitments made by the provider.
     pub commitments: Option<Vec<Commitment>>,
 
@@ -186,6 +191,10 @@ pub struct EthereumConfig {
     pub block_delays: Vec<u64>,
 }
 
+fn default_sync_fee_only_on_register() -> bool {
+    true
+}
+
 fn default_block_delays() -> Vec<u64> {
     vec![5]
 }

+ 1 - 1
apps/fortuna/src/eth_utils/utils.rs

@@ -187,7 +187,7 @@ pub async fn submit_tx_with_backoff<T: Middleware + NonceManaged + 'static>(
         },
         |e, dur| {
             let retry_number = num_retries.load(std::sync::atomic::Ordering::Relaxed);
-            tracing::error!(
+            tracing::warn!(
                 "Error on retry {} at duration {:?}: {}",
                 retry_number,
                 dur,

+ 207 - 39
apps/fortuna/src/history.rs

@@ -1,8 +1,13 @@
 use {
-    crate::api::ChainId,
+    crate::api::{ChainId, NetworkId},
     anyhow::Result,
     chrono::{DateTime, NaiveDateTime},
-    ethers::{core::utils::hex::ToHex, prelude::TxHash, types::Address},
+    ethers::{
+        core::utils::hex::ToHex,
+        prelude::TxHash,
+        types::{Address, U256},
+        utils::keccak256,
+    },
     serde::Serialize,
     serde_with::serde_as,
     sqlx::{migrate, Pool, Sqlite, SqlitePool},
@@ -17,6 +22,7 @@ use {
 pub enum RequestEntryState {
     Pending,
     Completed {
+        /// The block number of the reveal transaction.
         reveal_block_number: u64,
         /// The transaction hash of the reveal transaction.
         #[schema(example = "0xfe5f880ac10c0aae43f910b5a17f98a93cdd2eb2dce3a5ae34e5827a3a071a32", value_type = String)]
@@ -25,9 +31,22 @@ pub enum RequestEntryState {
         #[schema(example = "a905ab56567d31a7fda38ed819d97bc257f3ebe385fc5c72ce226d3bb855f0fe")]
         #[serde_as(as = "serde_with::hex::Hex")]
         provider_random_number: [u8; 32],
+        /// The gas used for the reveal transaction in the smallest unit of the chain.
+        /// For example, if the native currency is ETH, this will be in wei.
+        #[schema(example = "567890", value_type = String)]
+        #[serde(with = "crate::serde::u256")]
+        gas_used: U256,
+        /// The combined random number generated from the user and provider contributions.
+        #[schema(example = "a905ab56567d31a7fda38ed819d97bc257f3ebe385fc5c72ce226d3bb855f0fe")]
+        #[serde_as(as = "serde_with::hex::Hex")]
+        combined_random_number: [u8; 32],
     },
     Failed {
         reason: String,
+        /// The provider contribution to the random number.
+        #[schema(example = "a905ab56567d31a7fda38ed819d97bc257f3ebe385fc5c72ce226d3bb855f0fe")]
+        #[serde_as(as = "Option<serde_with::hex::Hex>")]
+        provider_random_number: Option<[u8; 32]>,
     },
 }
 
@@ -37,6 +56,9 @@ pub struct RequestStatus {
     /// The chain ID of the request.
     #[schema(example = "ethereum", value_type = String)]
     pub chain_id: ChainId,
+    /// The network ID of the request. This is the response of eth_chainId rpc call.
+    #[schema(example = "1", value_type = u64)]
+    pub network_id: NetworkId,
     #[schema(example = "0x6cc14824ea2918f5de5c2f75a9da968ad4bd6344", value_type = String)]
     pub provider: Address,
     pub sequence: u64,
@@ -48,6 +70,11 @@ pub struct RequestStatus {
     /// The transaction hash of the request transaction.
     #[schema(example = "0x5a3a984f41bb5443f5efa6070ed59ccb25edd8dbe6ce7f9294cf5caa64ed00ae", value_type = String)]
     pub request_tx_hash: TxHash,
+    /// Gas limit for the callback in the smallest unit of the chain.
+    /// For example, if the native currency is ETH, this will be in wei.
+    #[schema(example = "500000", value_type = String)]
+    #[serde(with = "crate::serde::u256")]
+    pub gas_limit: U256,
     /// The user contribution to the random number.
     #[schema(example = "a905ab56567d31a7fda38ed819d97bc257f3ebe385fc5c72ce226d3bb855f0fe")]
     #[serde_as(as = "serde_with::hex::Hex")]
@@ -58,9 +85,22 @@ pub struct RequestStatus {
     pub state: RequestEntryState,
 }
 
+impl RequestStatus {
+    pub fn generate_combined_random_number(
+        user_random_number: &[u8; 32],
+        provider_random_number: &[u8; 32],
+    ) -> [u8; 32] {
+        let mut concat: [u8; 96] = [0; 96]; // last 32 bytes are for the block hash which is not used here
+        concat[0..32].copy_from_slice(user_random_number);
+        concat[32..64].copy_from_slice(provider_random_number);
+        keccak256(concat)
+    }
+}
+
 #[derive(Clone, Debug, Serialize, ToSchema, PartialEq)]
 struct RequestRow {
     chain_id: String,
+    network_id: i64,
     provider: String,
     sequence: i64,
     created_at: NaiveDateTime,
@@ -70,9 +110,11 @@ struct RequestRow {
     request_tx_hash: String,
     user_random_number: String,
     sender: String,
+    gas_limit: String,
     reveal_block_number: Option<i64>,
     reveal_tx_hash: Option<String>,
     provider_random_number: Option<String>,
+    gas_used: Option<String>,
     info: Option<String>,
 }
 
@@ -81,6 +123,7 @@ impl TryFrom<RequestRow> for RequestStatus {
 
     fn try_from(row: RequestRow) -> Result<Self, Self::Error> {
         let chain_id = row.chain_id;
+        let network_id = row.network_id as u64;
         let provider = row.provider.parse()?;
         let sequence = row.sequence as u64;
         let created_at = row.created_at.and_utc();
@@ -89,6 +132,8 @@ impl TryFrom<RequestRow> for RequestStatus {
         let user_random_number = hex::FromHex::from_hex(row.user_random_number)?;
         let request_tx_hash = row.request_tx_hash.parse()?;
         let sender = row.sender.parse()?;
+        let gas_limit = U256::from_dec_str(&row.gas_limit)
+            .map_err(|_| anyhow::anyhow!("Failed to parse gas limit"))?;
 
         let state = match row.state.as_str() {
             "Pending" => RequestEntryState::Pending,
@@ -107,19 +152,36 @@ impl TryFrom<RequestRow> for RequestStatus {
                 ))?;
                 let provider_random_number: [u8; 32] =
                     hex::FromHex::from_hex(provider_random_number)?;
+                let gas_used = row
+                    .gas_used
+                    .ok_or(anyhow::anyhow!("Gas used is missing for completed request"))?;
+                let gas_used = U256::from_dec_str(&gas_used)
+                    .map_err(|_| anyhow::anyhow!("Failed to parse gas used"))?;
                 RequestEntryState::Completed {
                     reveal_block_number,
                     reveal_tx_hash,
                     provider_random_number,
+                    gas_used,
+                    combined_random_number: Self::generate_combined_random_number(
+                        &user_random_number,
+                        &provider_random_number,
+                    ),
                 }
             }
             "Failed" => RequestEntryState::Failed {
                 reason: row.info.unwrap_or_default(),
+                provider_random_number: match row.provider_random_number {
+                    Some(provider_random_number) => {
+                        Some(hex::FromHex::from_hex(provider_random_number)?)
+                    }
+                    None => None,
+                },
             },
             _ => return Err(anyhow::anyhow!("Unknown request state: {}", row.state)),
         };
         Ok(Self {
             chain_id,
+            network_id,
             provider,
             sequence,
             created_at,
@@ -129,6 +191,7 @@ impl TryFrom<RequestRow> for RequestStatus {
             request_tx_hash,
             user_random_number,
             sender,
+            gas_limit,
         })
     }
 }
@@ -185,15 +248,18 @@ impl History {
     async fn update_request_status(pool: &Pool<Sqlite>, new_status: RequestStatus) {
         let sequence = new_status.sequence as i64;
         let chain_id = new_status.chain_id;
+        let network_id = new_status.network_id as i64;
         let request_tx_hash: String = new_status.request_tx_hash.encode_hex();
         let provider: String = new_status.provider.encode_hex();
+        let gas_limit = new_status.gas_limit.to_string();
         let result = match new_status.state {
             RequestEntryState::Pending => {
                 let block_number = new_status.request_block_number as i64;
                 let sender: String = new_status.sender.encode_hex();
                 let user_random_number: String = new_status.user_random_number.encode_hex();
-                sqlx::query!("INSERT INTO request(chain_id, provider, sequence, created_at, last_updated_at, state, request_block_number, request_tx_hash, user_random_number, sender) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+                sqlx::query!("INSERT INTO request(chain_id, network_id, provider, sequence, created_at, last_updated_at, state, request_block_number, request_tx_hash, user_random_number, sender, gas_limit) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                     chain_id,
+                    network_id,
                     provider,
                     sequence,
                     new_status.created_at,
@@ -202,7 +268,9 @@ impl History {
                     block_number,
                     request_tx_hash,
                     user_random_number,
-                    sender)
+                    sender,
+                    gas_limit
+            )
                     .execute(pool)
                     .await
             }
@@ -210,29 +278,45 @@ impl History {
                 reveal_block_number,
                 reveal_tx_hash,
                 provider_random_number,
+                gas_used,
+                combined_random_number: _,
             } => {
                 let reveal_block_number = reveal_block_number as i64;
                 let reveal_tx_hash: String = reveal_tx_hash.encode_hex();
                 let provider_random_number: String = provider_random_number.encode_hex();
-                sqlx::query!("UPDATE request SET state = ?, last_updated_at = ?, reveal_block_number = ?, reveal_tx_hash = ?, provider_random_number = ? WHERE chain_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ?",
+                let gas_used: String = gas_used.to_string();
+                let result = sqlx::query!("UPDATE request SET state = ?, last_updated_at = ?, reveal_block_number = ?, reveal_tx_hash = ?, provider_random_number =?, gas_used = ? WHERE network_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ?",
                     "Completed",
                     new_status.last_updated_at,
                     reveal_block_number,
                     reveal_tx_hash,
                     provider_random_number,
-                    chain_id,
+                    gas_used,
+                    network_id,
                     sequence,
                     provider,
                     request_tx_hash)
                     .execute(pool)
-                    .await
+                    .await;
+                if let Ok(query_result) = &result {
+                    if query_result.rows_affected() == 0 {
+                        tracing::error!("Failed to update request status to complete: No rows affected. Chain ID: {}, Sequence: {}, Request TX Hash: {}", network_id, sequence, request_tx_hash);
+                    }
+                }
+                result
             }
-            RequestEntryState::Failed { reason } => {
-                sqlx::query!("UPDATE request SET state = ?, last_updated_at = ?, info = ? WHERE chain_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ? AND state = 'Pending'",
+            RequestEntryState::Failed {
+                reason,
+                provider_random_number,
+            } => {
+                let provider_random_number: Option<String> = provider_random_number
+                    .map(|provider_random_number| provider_random_number.encode_hex());
+                sqlx::query!("UPDATE request SET state = ?, last_updated_at = ?, info = ?, provider_random_number = ? WHERE network_id = ? AND sequence = ? AND provider = ? AND request_tx_hash = ? AND state = 'Pending'",
                     "Failed",
                     new_status.last_updated_at,
                     reason,
-                    chain_id,
+                    provider_random_number,
+                    network_id,
                     sequence,
                     provider,
                     request_tx_hash)
@@ -271,16 +355,17 @@ impl History {
     pub async fn get_requests_by_sender(
         &self,
         sender: Address,
-        chain_id: Option<ChainId>,
+        network_id: Option<NetworkId>,
     ) -> Result<Vec<RequestStatus>> {
         let sender: String = sender.encode_hex();
-        let rows = match chain_id {
-            Some(chain_id) => {
+        let rows = match network_id {
+            Some(network_id) => {
+                let network_id = network_id as i64;
                 sqlx::query_as!(
                     RequestRow,
-                    "SELECT * FROM request WHERE sender = ? AND chain_id = ?",
+                    "SELECT * FROM request WHERE sender = ? AND network_id = ?",
                     sender,
-                    chain_id,
+                    network_id,
                 )
                 .fetch_all(&self.pool)
                 .await
@@ -301,16 +386,17 @@ impl History {
     pub async fn get_requests_by_sequence(
         &self,
         sequence: u64,
-        chain_id: Option<ChainId>,
+        network_id: Option<NetworkId>,
     ) -> Result<Vec<RequestStatus>> {
         let sequence = sequence as i64;
-        let rows = match chain_id {
-            Some(chain_id) => {
+        let rows = match network_id {
+            Some(network_id) => {
+                let network_id = network_id as i64;
                 sqlx::query_as!(
                     RequestRow,
-                    "SELECT * FROM request WHERE sequence = ? AND chain_id = ?",
+                    "SELECT * FROM request WHERE sequence = ? AND network_id = ?",
                     sequence,
-                    chain_id,
+                    network_id,
                 )
                 .fetch_all(&self.pool)
                 .await
@@ -334,8 +420,9 @@ impl History {
 
     pub async fn get_requests_by_time(
         &self,
-        chain_id: Option<ChainId>,
+        network_id: Option<NetworkId>,
         limit: u64,
+        offset: u64,
         min_timestamp: Option<DateTime<chrono::Utc>>,
         max_timestamp: Option<DateTime<chrono::Utc>>,
     ) -> Result<Vec<RequestStatus>> {
@@ -352,20 +439,23 @@ impl History {
                 .unwrap(),
         );
         let limit = limit as i64;
-        let rows = match chain_id {
-            Some(chain_id) => {
-                let chain_id = chain_id.to_string();
-                sqlx::query_as!(RequestRow, "SELECT * FROM request WHERE chain_id = ? AND created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ?",
-                    chain_id,
+        let offset = offset as i64;
+        let rows = match network_id {
+            Some(network_id) => {
+                let network_id = network_id as i64;
+                sqlx::query_as!(RequestRow, "SELECT * FROM request WHERE network_id = ? AND created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
+                    network_id,
                     min_timestamp,
                     max_timestamp,
-                    limit).fetch_all(&self.pool).await
+                    limit,
+                offset).fetch_all(&self.pool).await
             }
             None => {
-                sqlx::query_as!(RequestRow, "SELECT * FROM request WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ?",
+                sqlx::query_as!(RequestRow, "SELECT * FROM request WHERE created_at >= ? AND created_at <= ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
                     min_timestamp,
                     max_timestamp,
-                    limit).fetch_all(&self.pool).await
+                    limit,
+                offset).fetch_all(&self.pool).await
             }
         }.map_err(|e| {
             tracing::error!("Failed to fetch request by time: {}", e);
@@ -382,6 +472,7 @@ mod test {
     fn get_random_request_status() -> RequestStatus {
         RequestStatus {
             chain_id: "ethereum".to_string(),
+            network_id: 121,
             provider: Address::random(),
             sequence: 1,
             created_at: chrono::Utc::now(),
@@ -391,6 +482,7 @@ mod test {
             user_random_number: [20; 32],
             sender: Address::random(),
             state: RequestEntryState::Pending,
+            gas_limit: U256::from(500_000),
         }
     }
 
@@ -404,11 +496,16 @@ mod test {
             reveal_block_number: 1,
             reveal_tx_hash,
             provider_random_number: [40; 32],
+            gas_used: U256::from(567890),
+            combined_random_number: RequestStatus::generate_combined_random_number(
+                &status.user_random_number,
+                &[40; 32],
+            ),
         };
         History::update_request_status(&history.pool, status.clone()).await;
 
         let logs = history
-            .get_requests_by_sequence(status.sequence, Some(status.chain_id.clone()))
+            .get_requests_by_sequence(status.sequence, Some(status.network_id))
             .await
             .unwrap();
         assert_eq!(logs, vec![status.clone()]);
@@ -432,7 +529,7 @@ mod test {
         assert_eq!(logs, vec![status.clone()]);
 
         let logs = history
-            .get_requests_by_sender(status.sender, Some(status.chain_id.clone()))
+            .get_requests_by_sender(status.sender, Some(status.network_id))
             .await
             .unwrap();
         assert_eq!(logs, vec![status.clone()]);
@@ -445,14 +542,82 @@ mod test {
     }
 
     #[tokio::test]
+    async fn test_no_transition_from_completed_to_failed() {
+        let history = History::new_in_memory().await.unwrap();
+        let reveal_tx_hash = TxHash::random();
+        let mut status = get_random_request_status();
+        History::update_request_status(&history.pool, status.clone()).await;
+        status.state = RequestEntryState::Completed {
+            reveal_block_number: 1,
+            reveal_tx_hash,
+            provider_random_number: [40; 32],
+            gas_used: U256::from(567890),
+            combined_random_number: RequestStatus::generate_combined_random_number(
+                &status.user_random_number,
+                &[40; 32],
+            ),
+        };
+        History::update_request_status(&history.pool, status.clone()).await;
+        let mut failed_status = status.clone();
+        failed_status.state = RequestEntryState::Failed {
+            reason: "Failed".to_string(),
+            provider_random_number: None,
+        };
+        History::update_request_status(&history.pool, failed_status).await;
+
+        let logs = history
+            .get_requests_by_tx_hash(reveal_tx_hash)
+            .await
+            .unwrap();
+        assert_eq!(logs, vec![status.clone()]);
+    }
+
+    #[tokio::test]
+    async fn test_failed_state() {
+        let history = History::new_in_memory().await.unwrap();
+        let mut status = get_random_request_status();
+        History::update_request_status(&history.pool, status.clone()).await;
+        status.state = RequestEntryState::Failed {
+            reason: "Failed".to_string(),
+            provider_random_number: Some([40; 32]),
+        };
+        History::update_request_status(&history.pool, status.clone()).await;
+        let logs = history
+            .get_requests_by_tx_hash(status.request_tx_hash)
+            .await
+            .unwrap();
+        assert_eq!(logs, vec![status.clone()]);
+    }
+
+    #[tokio::test]
+    async fn test_generate_combined_random_number() {
+        let user_random_number = hex::FromHex::from_hex(
+            "0000000000000000000000006c8ac03d388d5572f77aca84573628ee87a7a4da",
+        )
+        .unwrap();
+        let provider_random_number = hex::FromHex::from_hex(
+            "deeb67cb894c33f7b20ae484228a9096b51e8db11461fcb0975c681cf0875d37",
+        )
+        .unwrap();
+        let combined_random_number = RequestStatus::generate_combined_random_number(
+            &user_random_number,
+            &provider_random_number,
+        );
+        let expected_combined_random_number: [u8; 32] = hex::FromHex::from_hex(
+            "1c26ffa1f8430dc91cb755a98bf37ce82ac0e2cfd961e10111935917694609d5",
+        )
+        .unwrap();
+        assert_eq!(combined_random_number, expected_combined_random_number,);
+    }
 
+    #[tokio::test]
     async fn test_history_filter_irrelevant_logs() {
         let history = History::new_in_memory().await.unwrap();
         let status = get_random_request_status();
         History::update_request_status(&history.pool, status.clone()).await;
 
         let logs = history
-            .get_requests_by_sequence(status.sequence, Some("not-ethereum".to_string()))
+            .get_requests_by_sequence(status.sequence, Some(123))
             .await
             .unwrap();
         assert_eq!(logs, vec![]);
@@ -470,7 +635,7 @@ mod test {
         assert_eq!(logs, vec![]);
 
         let logs = history
-            .get_requests_by_sender(Address::zero(), Some(status.chain_id.clone()))
+            .get_requests_by_sender(Address::zero(), Some(status.network_id))
             .await
             .unwrap();
         assert_eq!(logs, vec![]);
@@ -487,12 +652,13 @@ mod test {
         let history = History::new_in_memory().await.unwrap();
         let status = get_random_request_status();
         History::update_request_status(&history.pool, status.clone()).await;
-        for chain_id in [None, Some("ethereum".to_string())] {
+        for network_id in [None, Some(121)] {
             // min = created_at = max
             let logs = history
                 .get_requests_by_time(
-                    chain_id.clone(),
+                    network_id,
                     10,
+                    0,
                     Some(status.created_at),
                     Some(status.created_at),
                 )
@@ -503,8 +669,9 @@ mod test {
             // min = created_at + 1
             let logs = history
                 .get_requests_by_time(
-                    chain_id.clone(),
+                    network_id,
                     10,
+                    0,
                     Some(status.created_at + Duration::seconds(1)),
                     None,
                 )
@@ -515,8 +682,9 @@ mod test {
             // max = created_at - 1
             let logs = history
                 .get_requests_by_time(
-                    chain_id.clone(),
+                    network_id,
                     10,
+                    0,
                     None,
                     Some(status.created_at - Duration::seconds(1)),
                 )
@@ -526,7 +694,7 @@ mod test {
 
             // no min or max
             let logs = history
-                .get_requests_by_time(chain_id.clone(), 10, None, None)
+                .get_requests_by_time(network_id, 10, 0, None, None)
                 .await
                 .unwrap();
             assert_eq!(logs, vec![status.clone()]);
@@ -541,7 +709,7 @@ mod test {
         // wait for the writer thread to write to the db
         sleep(std::time::Duration::from_secs(1)).await;
         let logs = history
-            .get_requests_by_sequence(1, Some("ethereum".to_string()))
+            .get_requests_by_sequence(1, Some(121))
             .await
             .unwrap();
         assert_eq!(logs, vec![status]);

+ 7 - 9
apps/fortuna/src/keeper.rs

@@ -67,15 +67,13 @@ pub async fn run_keeper_threads(
     let latest_safe_block = get_latest_safe_block(&chain_state).in_current_span().await;
     tracing::info!("Latest safe block: {}", &latest_safe_block);
 
-    let contract = Arc::new(
-        InstrumentedSignablePythContract::from_config(
-            &chain_eth_config,
-            &private_key,
-            chain_state.id.clone(),
-            rpc_metrics.clone(),
-        )
-        .await?,
-    );
+    let contract = Arc::new(InstrumentedSignablePythContract::from_config(
+        &chain_eth_config,
+        &private_key,
+        chain_state.id.clone(),
+        rpc_metrics.clone(),
+        chain_state.network_id,
+    )?);
     let keeper_address = contract.wallet().address();
 
     let fulfilled_requests_cache = Arc::new(RwLock::new(HashSet::<u64>::new()));

+ 7 - 1
apps/fortuna/src/keeper/commitment.rs

@@ -42,7 +42,13 @@ pub async fn update_commitments_if_necessary(
         .block(latest_safe_block) // To ensure we are not revealing sooner than we should
         .call()
         .await
-        .map_err(|e| anyhow!("Error while getting provider info. error: {:?}", e))?;
+        .map_err(|e| {
+            anyhow!(
+                "Error while getting provider info at block {}. error: {:?}",
+                latest_safe_block,
+                e
+            )
+        })?;
     if provider_info.max_num_hashes == 0 {
         return Ok(());
     }

+ 8 - 0
apps/fortuna/src/keeper/keeper_metrics.rs

@@ -41,6 +41,7 @@ pub struct KeeperMetrics {
     pub final_gas_multiplier: Family<AccountLabel, Histogram>,
     pub final_fee_multiplier: Family<AccountLabel, Histogram>,
     pub gas_price_estimate: Family<AccountLabel, Gauge<f64, AtomicU64>>,
+    pub highest_revealed_sequence_number: Family<AccountLabel, Gauge>,
     pub accrued_pyth_fees: Family<ChainIdLabel, Gauge<f64, AtomicU64>>,
     pub block_timestamp_lag: Family<ChainIdLabel, Gauge>,
     pub latest_block_timestamp: Family<ChainIdLabel, Gauge>,
@@ -88,6 +89,7 @@ impl Default for KeeperMetrics {
                 Histogram::new(vec![100.0, 110.0, 120.0, 140.0, 160.0, 180.0, 200.0].into_iter())
             }),
             gas_price_estimate: Family::default(),
+            highest_revealed_sequence_number: Family::default(),
             accrued_pyth_fees: Family::default(),
             block_timestamp_lag: Family::default(),
             latest_block_timestamp: Family::default(),
@@ -223,6 +225,12 @@ impl KeeperMetrics {
             keeper_metrics.gas_price_estimate.clone(),
         );
 
+        writable_registry.register(
+            "highest_revealed_sequence_number",
+            "The highest sequence number revealed by the keeper either via callbacks or manual reveal",
+            keeper_metrics.highest_revealed_sequence_number.clone(),
+        );
+
         writable_registry.register(
             "accrued_pyth_fees",
             "Accrued Pyth fees on the contract",

+ 13 - 0
apps/fortuna/src/keeper/process_event.rs

@@ -42,6 +42,7 @@ pub async fn process_event_with_backoff(
     tracing::info!("Started processing event");
     let mut status = RequestStatus {
         chain_id: chain_state.id.clone(),
+        network_id: chain_state.network_id,
         provider: event.provider_address,
         sequence: event.sequence_number,
         created_at: chrono::Utc::now(),
@@ -51,6 +52,7 @@ pub async fn process_event_with_backoff(
         sender: event.requestor,
         user_random_number: event.user_random_number,
         state: RequestEntryState::Pending,
+        gas_limit,
     };
     history.add(&status);
 
@@ -60,6 +62,7 @@ pub async fn process_event_with_backoff(
         .map_err(|e| {
             status.state = RequestEntryState::Failed {
                 reason: format!("Error revealing: {:?}", e),
+                provider_random_number: None,
             };
             history.add(&status);
             anyhow!("Error revealing: {:?}", e)
@@ -91,6 +94,11 @@ pub async fn process_event_with_backoff(
                 reveal_block_number: result.receipt.block_number.unwrap_or_default().as_u64(),
                 reveal_tx_hash: result.receipt.transaction_hash,
                 provider_random_number: provider_revelation,
+                gas_used: result.receipt.gas_used.unwrap_or_default(),
+                combined_random_number: RequestStatus::generate_combined_random_number(
+                    &event.user_random_number,
+                    &provider_revelation,
+                ),
             };
             history.add(&status);
             tracing::info!(
@@ -160,6 +168,11 @@ pub async fn process_event_with_backoff(
                     .requests_processed_failure
                     .get_or_create(&account_label)
                     .inc();
+                status.state = RequestEntryState::Failed {
+                    reason: format!("Error revealing: {:?}", e),
+                    provider_random_number: Some(provider_revelation),
+                };
+                history.add(&status);
             }
         }
     }

Неке датотеке нису приказане због велике количине промена