diff --git a/.eslintrc.js b/.eslintrc.js index 5b999efa4..fc519f6d2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,10 +1,5 @@ module.exports = { - root: true, - // This tells ESLint to load the config from the package `eslint-config-custom` + root: true, // This tells ESLint to load the config from the package `eslint-config-custom` extends: ["custom"], - settings: { - next: { - rootDir: ["apps/*/"], - }, - }, + settings: { next: { rootDir: ["apps/*/"] } }, }; diff --git a/.github/workflows/ai-code-review.yml b/.github/workflows/ai-code-review.yml new file mode 100644 index 000000000..ff3f7794d --- /dev/null +++ b/.github/workflows/ai-code-review.yml @@ -0,0 +1,74 @@ +name: "Code Review by Gemini AI" +on: + pull_request: +jobs: + review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v3 + + - name: "Check for !ai keyword in commit message" + id: check_commit_message + run: | + COMMIT_MESSAGE=$(git log -1 --pretty=%B) + echo "Commit message: $COMMIT_MESSAGE" + if [[ "$COMMIT_MESSAGE" == *"!ai"* ]]; then + echo "run_ai=true" >> $GITHUB_ENV + else + echo "run_ai=false" >> $GITHUB_ENV + fi + + - name: "Check if relevant files are modified" + id: check_changes + shell: bash + run: | + # Check for modified .ts, .tsx, or .sol files + git fetch origin "${{ github.event.pull_request.head.ref }}" # fetch the branch with changes + git fetch origin "${{ github.event.pull_request.base.ref }}" # fetch the target branch + git diff --name-only "origin/${{ github.event.pull_request.base.ref }}" "origin/${{ github.event.pull_request.head.ref }}" > modified_files.txt + + # Check if there are any .ts, .tsx, or .sol files modified + if grep -E '\.ts$|\.tsx$|\.sol$' modified_files.txt; then + echo "files_changed=true" >> $GITHUB_ENV + else + echo "files_changed=false" >> $GITHUB_ENV + fi + + - name: "Get diff of the pull request" + if: env.files_changed == 'true' && env.run_ai == 'true' + id: get_diff + shell: bash + env: + PULL_REQUEST_HEAD_REF: "${{ github.event.pull_request.head.ref }}" + PULL_REQUEST_BASE_REF: "${{ github.event.pull_request.base.ref }}" + run: |- + # Only include changes to .ts, .tsx, and .sol files in the diff + git diff "origin/${{ env.PULL_REQUEST_BASE_REF }}" -- '*.ts' '*.tsx' '*.sol' > "diff.txt" + { + echo "pull_request_diff<> $GITHUB_OUTPUT # save the filtered diff to an output variable + + - uses: rubensflinco/gemini-code-review-action@1.0.5 + name: "Code Review by Gemini AI" + if: env.files_changed == 'true' && env.run_ai == 'true' + continue-on-error: true + id: review + with: + gemini_api_key: ${{ secrets.GEMINI_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + github_repository: ${{ github.repository }} + github_pull_request_number: ${{ github.event.pull_request.number }} + git_commit_hash: ${{ github.event.pull_request.head.sha }} + model: "gemini-1.5-pro-latest" + pull_request_diff: |- + ${{ steps.get_diff.outputs.pull_request_diff }} + pull_request_chunk_size: "3500" + extra_prompt: |- + Only review files with extensions: .ts, .tsx, .sol + Focus your review on code logic, security vulnerabilities, and potential improvements in these files. + log_level: "DEBUG" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 23c98aace..6a1c4fb68 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,6 +14,10 @@ jobs: env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + SCORER_ID: ${{ secrets.SCORER_ID }} + GITCOIN_PASSPORT_API_KEY: ${{ secrets.GITCOIN_PASSPORT_API_KEY }} + LIST_MANAGER_PRIVATE_KEY: ${{ secrets.LIST_MANAGER_PRIVATE_KEY }} + FOUNDRY_OUT: pkg/contracts/out steps: - name: Check out code diff --git a/.gitignore b/.gitignore index 188c69bb4..38a27de50 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ lcov.info # some abi should be ignored # pkg/contracts/out/** # !pkg/contracts/out/CVStrategy.sol/CVStrategy.json -# pkg/contracts/out \ No newline at end of file +# pkg/contracts/out +pkg/contracts/script/Playground.s.sol diff --git a/.gitmodules b/.gitmodules index 369c285a0..2c14e885d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,9 +4,15 @@ [submodule "lib/allo-v2"] path = lib/allo-v2 url = https://github.com/allo-protocol/allo-v2 -[submodule "lib/safe-contracts"] - path = lib/safe-contracts - url = https://github.com/safe-global/safe-contracts +[submodule "lib/openzeppelin-foundry-upgrades"] + path = lib/openzeppelin-foundry-upgrades + url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/safe-smart-account"] + path = lib/safe-smart-account + url = https://github.com/safe-global/safe-smart-account diff --git a/.prettierrc.json b/.prettierrc.json index fa6a26e50..c92533ea1 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -14,20 +14,14 @@ "^~/(.*)$", "^[./]" ], - "importOrderParserPlugins": [ - "typescript", - "jsx", - "decorators-legacy" - ], + "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"], "importOrderTypeScriptVersion": "4.4.0", "overrides": [ { - "files": [ - "*.yaml|*.yml" - ], + "files": ["*.yaml|*.yml"], "options": { "bracketSpacing": false } } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 83f5f6101..d6c5b1e1a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,4 +6,6 @@ "editor.formatOnSave": true }, "solidity.formatter": "forge", + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/README.md b/README.md index 98e695e60..6e08e5b7f 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,26 @@ -# Gardens v2 +d# Gardens v2 -> Gardens is a **coordination platform** +> Gardens is a **coordination platform** > that fosters **vibrant ecosystems of shared wealth** -> by providing **healthy funding mechanisms to communities in web3** +> by providing **healthy funding mechanisms to communities in web3** -As a modular governance mechanism, Gardens strategically mixes centralized and decentralized components to take advantage of the efficiency and security benefits of both when needed. +As a modular governance mechdwaawdadwdwadawdawanism, Gardens strategically mixes centralized and decentralized components to take advantage of the efficiency and security benefits of both when needed. +daawd +Project and Ecosystem leaderdwadwadwas can use Gardens to: -Project and Ecosystem leaders can use Gardens to: -* Publish a Covenant to IPFS and Create a Community pinned to its values and purpose. -* Appoint a Council Safe as admin for the Community and a Tribunal Safe to rule on disputes -* Create funding pools and strategies to allocate funding and source collective decisions +- Publish a Covenant to IPFS and Create a Community pinned to its values and purpose. +- Appoint a Council Safe as admin for the Community and a Tribunal Safe to rule on disputes +- Create funding pools and strategies to allocate funding and source collective decisions Community members and Public Goods builders can use Gardens to: -* Support Communities by staking in their Covenant -* Create proposals in funding pools and strategies they're eligible for -* Take part in collective decision-making by voting on Proposals. -For Communities building goods and services whose value subjective to its users (AKA "Public Goods"), Gardens offers a toolset capable of leveraging the _Wisdom of the Crowds_ and that resists value extraction by malicious, abusive, or apathetic parties. +- Support Communities by staking in their Covenant +- Create proposals in funding pools and strategies they're eligible for +- Take part in collective decision-making by voting on Proposals. +For Communities building goods and services whose value subjective to its users (AKA "Public Goods"), Gardens offers a toolset capable of leveraging the _Wisdom of the Crowds_ and that resists value extraction by malicious, abusive, or apathetic parties. -## Turborepo starter for web3 projects +## Turborepo starter for wedadawawdwadb3 projects This turborepo uses [pnpm](https://pnpm.io) as a package manager. It includes the following packages/apps: @@ -57,7 +58,7 @@ pnpm run build ### Develop -To develop all apps and packages, run the following command: +To develop all apps and packadawdwadawawdges, run the following command: ``` cd my-turborepo @@ -66,9 +67,9 @@ pnpm run dev ### Remote Caching -Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines. +Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacdwadawts across machines, enabling you to share build caches with your team and CI/CD pipelines. -By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup), then enter the following commands: +By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an accountddwaaaaaaaaaaawdawdw you can [create one](https://vercel.com/signup), then enter the following commands: ``` cd my-turborepo @@ -77,7 +78,7 @@ pnpm dlx turbo login This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). -Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your turborepo: +Next, you can link yourdawdwdwa Turborepo to your Remote Cache by running the following command from the root of your turborepo: ``` pnpm dlx turbo link @@ -85,7 +86,7 @@ pnpm dlx turbo link ## Useful Links -Learn more about the power of Turborepo: +Learn more about the pdwadawdower of Turborepo: - [Pipelines](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks) - [Caching](https://turbo.build/repo/docs/core-concepts/caching) diff --git a/apps/docs/pages/index.tsx b/apps/docs/pages/index.tsx index 0d1dabc12..1d72a6fef 100644 --- a/apps/docs/pages/index.tsx +++ b/apps/docs/pages/index.tsx @@ -1,3 +1,4 @@ +import _ from "react"; import { Button } from "ui"; export default function Docs() { diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 16cf4ecde..f8cbceacf 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -2,15 +2,89 @@ * @type {import('eslint').Linter.Config} */ module.exports = { - extends: ["next/core-web-vitals", "turbo", "prettier"], - ignorePatterns: ["node_modules", "dist"], + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + "airbnb/hooks", + "airbnb-typescript", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:jsx-a11y/recommended", + "next/core-web-vitals", + "prettier/prettier", + ], + plugins: ["react"], + ignorePatterns: ["node_modules", "dist", "src/generated.ts", "public"], + parser: "@typescript-eslint/parser", parserOptions: { - babelOptions: { - presets: [require.resolve("next/babel")], - }, + project: "./tsconfig.json", }, rules: { + "react/jsx-filename-extension": [ + 2, + { extensions: [".js", ".jsx", ".ts", ".tsx"] }, + ], "react-hooks/exhaustive-deps": "off", - "no-html-link-for-pages": "off", + "@next/next/no-html-link-for-pages": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@next/next/no-page-custom-font": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/prefer-nullish-coalescing": "error", + "react/self-closing-comp": [ + "error", + { + component: true, + html: true, + }, + ], + "no-unreachable": "warn", + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal"], + pathGroups: [ + { + pattern: "react", + group: "external", + position: "before", + }, + ], + pathGroupsExcludedImportTypes: ["react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + "no-console": [ + "warn", + { + allow: ["warn", "error", "info", "debug", "table"], + }, + ], + "prefer-const": "off", + "@typescript-eslint/consistent-type-definitions": "off", + "no-unused-expressions": "error", + "no-unsafe-optional-chaining": "error", + "import/extensions": "off", + "@typescript-eslint/quotes": ["error", "double"], + "@typescript-eslint/no-use-before-define": "off", + "jsx-a11y/no-static-element-interactions": "off", + "react/no-array-index-key": "warn", + indent: "off", + "@typescript-eslint/indent": "off", + "jsx-a11y/label-has-associated-control": "off", }, }; diff --git a/apps/web/.vscode/extensions.json b/apps/web/.vscode/extensions.json new file mode 100644 index 000000000..643dfeccc --- /dev/null +++ b/apps/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["skillhub.daisy-ui-latest-snippets", "bradlc.vscode-tailwindcss"] +} diff --git a/apps/web/.vscode/launch.json b/apps/web/.vscode/launch.json new file mode 100644 index 000000000..70d4de43c --- /dev/null +++ b/apps/web/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "pnpm dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "pnpm dev", + "serverReadyAction": { + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ] +} \ No newline at end of file diff --git a/apps/web/.vscode/settings.json b/apps/web/.vscode/settings.json new file mode 100644 index 000000000..f2d1bb736 --- /dev/null +++ b/apps/web/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "always" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[typescript]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "css.lint.unknownAtRules": "ignore", + "[javascript]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "editor.tabSize": 2, +} \ No newline at end of file diff --git a/apps/web/actions/getProposals.ts b/apps/web/actions/getProposals.ts index 46576594a..b3f7fa7e2 100644 --- a/apps/web/actions/getProposals.ts +++ b/apps/web/actions/getProposals.ts @@ -1,28 +1,14 @@ -import { Address } from "viem"; -import { ProposalTypeVoter } from "@/components/Proposals"; -import { CVProposal, CVStrategy } from "#/subgraph/.graphclient"; +import { ProposalMetadata } from "#/subgraph/.graphclient"; +import { LightCVStrategy } from "@/types"; +import { fetchIpfs } from "@/utils/ipfsUtils"; -export async function getProposals( - accountAddress: Address | undefined, - strategy: CVStrategy, -) { +export async function getProposals(strategy: LightCVStrategy) { try { - async function fetchIPFSDataBatch( - proposals: CVProposal[], + const fetchIPFSDataBatch = async function ( + proposals: (typeof strategy)["proposals"], batchSize = 5, delay = 300, ) { - // Fetch data for a batch of proposals - const fetchBatch = async (batch: any) => - Promise.all( - batch.map((p: CVProposal) => - fetch(`https://ipfs.io/ipfs/${p.metadata}`, { - method: "GET", - headers: { "content-type": "application/json" }, - }).then((res) => res.json()), - ), - ); - // Introduce a delay const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -34,37 +20,46 @@ export async function getProposals( ); // Process each chunk - let results = []; + let results: Array> = []; for (const chunk of chunks) { - const batchResults = await fetchBatch(chunk); - results.push(...batchResults); + chunk.forEach(async (p) => { + if (p.metadata) { + results.push(p.metadata); + } else { + const ipfsRes = await fetchIpfs<(typeof results)[number] | null>( + p.metadataHash, + ); + if (ipfsRes) { + results.push(ipfsRes); + } + } + }); await sleep(delay); } return results; - } + }; - async function transformProposals(strategy: CVStrategy) { - const proposalsData = await fetchIPFSDataBatch(strategy.proposals); - const transformedProposals = proposalsData.map((data, index) => { + const proposalsData = await fetchIPFSDataBatch(strategy.proposals); + const transformedProposals = proposalsData + .map((data, index) => { const p = strategy.proposals[index]; return { ...p, voterStakedPointsPct: 0, + metadata: { + title: data.title, + description: data.description, + }, stakedAmount: strategy.proposals[index].stakedAmount, - title: data.title, type: strategy.config?.proposalType as number, status: strategy.proposals[index].proposalStatus, }; - }); - - return transformedProposals; - } - let transformedProposals: ProposalTypeVoter[] = - await transformProposals(strategy); + }) + .sort((a, b) => +a.proposalNumber - +b.proposalNumber); // Sort by proposal number ascending return transformedProposals; } catch (error) { - console.log(error); + console.error("Error while getting proposal ipfs metadata", error); } } diff --git a/apps/web/app/(app)/create-garden/page.tsx b/apps/web/app/(app)/create-garden/page.tsx deleted file mode 100644 index cf1dfb82e..000000000 --- a/apps/web/app/(app)/create-garden/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import UploadToIpfsSample from "@/components/UploadToIpfsSample"; -import React from "react"; - -export default function CreateGarden() { - return ( - <> -
Create a garden form...
- - - ); -} diff --git a/apps/web/app/(app)/docs/page.tsx b/apps/web/app/(app)/docs/page.tsx index 133632706..ea2a5bca2 100644 --- a/apps/web/app/(app)/docs/page.tsx +++ b/apps/web/app/(app)/docs/page.tsx @@ -1,5 +1,5 @@ import React from "react"; -export default function Docs() { +export default function Page() { return
Docs...
; } diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/[proposalId]/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/[proposalId]/page.tsx new file mode 100644 index 000000000..388a6435d --- /dev/null +++ b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/[proposalId]/page.tsx @@ -0,0 +1,283 @@ +"use client"; +import { useEffect } from "react"; +import { Hashicon } from "@emeraldpay/hashicon-react"; +import { InformationCircleIcon, UserIcon } from "@heroicons/react/24/outline"; +import { toast } from "react-toastify"; +import { Address, encodeAbiParameters, formatUnits } from "viem"; +import { useAccount, useToken } from "wagmi"; +import { + getProposalDataDocument, + getProposalDataQuery, +} from "#/subgraph/.graphclient"; +import { + Badge, + Button, + DisplayNumber, + EthAddress, + Statistic, +} from "@/components"; +import CancelButton from "@/components/CancelButton"; +import { ConvictionBarChart } from "@/components/Charts/ConvictionBarChart"; +import { DisputeButton } from "@/components/DisputeButton"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import MarkdownWrapper from "@/components/MarkdownWrapper"; +import { usePubSubContext } from "@/contexts/pubsub.context"; +import { useChainIdFromPath } from "@/hooks/useChainIdFromPath"; +import { useContractWriteWithConfirmations } from "@/hooks/useContractWriteWithConfirmations"; +import { useConvictionRead } from "@/hooks/useConvictionRead"; +import { useMetadataIpfsFetch } from "@/hooks/useIpfsFetch"; +import { useSubgraphQuery } from "@/hooks/useSubgraphQuery"; +import { alloABI } from "@/src/generated"; +import { PoolTypes, ProposalStatus } from "@/types"; +import { abiWithErrors } from "@/utils/abiWithErrors"; +import { useErrorDetails } from "@/utils/getErrorName"; + +const prettyTimestamp = (timestamp: number) => { + const date = new Date(timestamp * 1000); + + const day = date.getDate(); + const month = date.toLocaleString("default", { month: "short" }); + const year = date.getFullYear(); + + return `${day} ${month} ${year}`; +}; + +export default function Page({ + params: { proposalId, garden, poolId }, +}: { + params: { + proposalId: string; + poolId: string; + chain: string; + garden: string; + }; +}) { + const { isDisconnected, address } = useAccount(); + const [strategyId, proposalNumber] = proposalId.split("-"); + const { data } = useSubgraphQuery({ + query: getProposalDataDocument, + variables: { + garden: garden, + proposalId: proposalId, + }, + changeScope: { + topic: "proposal", + containerId: strategyId, + id: proposalNumber, + type: "update", + }, + }); + + const proposalData = data?.cvproposal; + const proposalIdNumber = + proposalData?.proposalNumber ? + BigInt(proposalData.proposalNumber) + : undefined; + const poolTokenAddr = proposalData?.strategy.token as Address; + + const { publish } = usePubSubContext(); + const chainId = useChainIdFromPath(); + const { data: poolToken } = useToken({ + address: poolTokenAddr, + enabled: !!poolTokenAddr, + chainId, + }); + const { data: ipfsResult } = useMetadataIpfsFetch({ + hash: proposalData?.metadataHash, + enabled: !proposalData?.metadata, + }); + const metadata = proposalData?.metadata ?? ipfsResult; + const isProposerConnected = + proposalData?.submitter === address?.toLowerCase(); + + const { + currentConvictionPct, + thresholdPct, + totalSupportPct, + updatedConviction, + } = useConvictionRead({ + proposalData, + tokenData: data?.tokenGarden, + enabled: proposalData?.proposalNumber != null, + }); + + const proposalType = proposalData?.strategy.config?.proposalType; + const requestedAmount = proposalData?.requestedAmount; + const beneficiary = proposalData?.beneficiary as Address | undefined; + const submitter = proposalData?.submitter as Address | undefined; + const isSignalingType = PoolTypes[proposalType] === "signaling"; + const proposalStatus = ProposalStatus[proposalData?.proposalStatus]; + + //encode proposal id to pass as argument to distribute function + const encodedDataProposalId = (proposalId_: bigint) => { + const encodedProposalId = encodeAbiParameters( + [{ name: "proposalId", type: "uint" }], + [proposalId_], + ); + return encodedProposalId; + }; + + //distribution function from Allo contract + //args: poolId, strategyId, encoded proposalId + const { + write: writeDistribute, + error: errorDistribute, + isError: isErrorDistribute, + } = useContractWriteWithConfirmations({ + address: data?.allos[0]?.id as Address, + abi: abiWithErrors(alloABI), + functionName: "distribute", + contractName: "Allo", + fallbackErrorMessage: "Error executing proposal. Please try again.", + onConfirmations: () => { + publish({ + topic: "proposal", + type: "update", + function: "distribute", + id: proposalNumber, + containerId: strategyId, + chainId, + }); + }, + }); + + const distributeErrorName = useErrorDetails(errorDistribute); + useEffect(() => { + if (isErrorDistribute && distributeErrorName.errorName !== undefined) { + toast.error("NOT EXECUTABLE:" + " " + distributeErrorName.errorName); + } + }, [isErrorDistribute]); + + if ( + !proposalData || + !metadata || + proposalIdNumber == null || + updatedConviction == null + ) { + return ( +
+ +
+ ); + } + + const status = ProposalStatus[proposalData.proposalStatus]; + + return ( +
+
+
+
+ +
+
+
+
+

+ {metadata?.title} #{proposalIdNumber.toString()} +

+ +
+
+ +

+ Created:{" "} + + {prettyTimestamp(proposalData?.createdAt ?? 0)} + +

+
+
+ + {metadata?.description ?? "No description found"} + +
+
+ {!isSignalingType && ( + <> + } + > + + + }> + + + + )} + }> + + +
+
+ {isProposerConnected && proposalStatus === "active" ? + + : + } +
+
+
+
+
+
+ {status && status !== "active" && status !== "disputed" ? +

+ {status === "executed" ? + "Proposal passed and executed successfully!" + : `Proposal has been ${status}.`} +

+ : <> +
+

Metrics

+ {status === "active" && !isSignalingType && ( + + )} +
+ + + } +
+
+ ); +} diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/create-proposal/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/create-proposal/page.tsx new file mode 100644 index 000000000..93dc6820d --- /dev/null +++ b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/create-proposal/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import React from "react"; +import { Address } from "viem"; +import { getPoolDataDocument, getPoolDataQuery } from "#/subgraph/.graphclient"; +import { ProposalForm } from "@/components/Forms"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { useMetadataIpfsFetch } from "@/hooks/useIpfsFetch"; +import { useSubgraphQuery } from "@/hooks/useSubgraphQuery"; +import { CV_SCALE_PRECISION, MAX_RATIO_CONSTANT } from "@/utils/numbers"; + +export default function Page({ + params: { poolId, garden }, +}: { + params: { chain: string; poolId: number; garden: string }; +}) { + const { data } = useSubgraphQuery({ + query: getPoolDataDocument, + variables: { poolId: poolId, garden: garden }, + }); + const strategyObj = data?.cvstrategies?.[0]; + + const { metadata } = useMetadataIpfsFetch({ + hash: strategyObj?.metadata, + }); + + const tokenGarden = data?.tokenGarden; + + if (!tokenGarden || !metadata || !strategyObj) { + return ( +
+ +
+ ); + } + + const alloInfo = data?.allos[0]; + const proposalType = strategyObj.config?.proposalType as number; + const poolAmount = strategyObj.poolAmount as number; + + const maxRatioDivPrecision = + (Number(strategyObj.config?.maxRatio) / CV_SCALE_PRECISION) * + MAX_RATIO_CONSTANT; + + const spendingLimitPct = maxRatioDivPrecision * 100; + const poolAmountSpendingLimit = poolAmount * maxRatioDivPrecision; + + return ( +
+
+
+

Create a Proposal in Pool #{poolId}

+
+

{metadata.title}

+
+
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/page.tsx new file mode 100644 index 000000000..ab0e4c138 --- /dev/null +++ b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/[poolId]/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect } from "react"; +import { Address } from "viem"; +import { useToken } from "wagmi"; +import { + getAlloQuery, + getPoolDataDocument, + getPoolDataQuery, +} from "#/subgraph/.graphclient"; +import { PoolMetrics, Proposals } from "@/components"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import PoolHeader from "@/components/PoolHeader"; +import { QUERY_PARAMS } from "@/constants/query-params"; +import { useCollectQueryParams } from "@/hooks/useCollectQueryParams"; +import { useMetadataIpfsFetch } from "@/hooks/useIpfsFetch"; +import { useSubgraphQuery } from "@/hooks/useSubgraphQuery"; +import { PoolTypes } from "@/types"; +import { CV_SCALE_PRECISION } from "@/utils/numbers"; + +export const dynamic = "force-dynamic"; + +export type AlloQuery = getAlloQuery["allos"][number]; + +export default function Page({ + params: { chain, poolId, garden }, +}: { + params: { chain: string; poolId: number; garden: string }; +}) { + const searchParams = useCollectQueryParams(); + + const { data, refetch, error } = useSubgraphQuery({ + query: getPoolDataDocument, + variables: { poolId: poolId, garden: garden }, + changeScope: [ + { + topic: "pool", + id: poolId, + }, + { + topic: "proposal", + containerId: poolId, + type: "update", + }, + ], + }); + const strategyObj = data?.cvstrategies?.[0]; + const poolTokenAddr = strategyObj?.token as Address; + const { data: poolToken } = useToken({ + address: poolTokenAddr, + enabled: !!poolTokenAddr, + chainId: +chain, + }); + + useEffect(() => { + if (error) { + console.error("Error while fetching community data: ", error); + } + }, [error]); + + const { metadata: ipfsResult } = useMetadataIpfsFetch({ + hash: data?.cvstrategies?.[0]?.metadata, + }); + + useEffect(() => { + if (!strategyObj) { + return; + } + console.debug( + "maxRatio: " + strategyObj?.config?.maxRatio, + "minThresholdPoints: " + strategyObj?.config?.minThresholdPoints, + "poolAmount: " + strategyObj?.poolAmount, + ); + }, [strategyObj?.config, strategyObj?.config, strategyObj?.poolAmount]); + + useEffect(() => { + const newProposalId = searchParams[QUERY_PARAMS.poolPage.newPropsoal]; + if ( + newProposalId && + data && + !strategyObj?.proposals.some((c) => c.proposalNumber === newProposalId) + ) { + refetch(); + } + }, [searchParams, strategyObj?.proposals]); + + const tokenGarden = data?.tokenGarden; + + if (!tokenGarden || !poolToken) { + return ( +
+ +
+ ); + } + + if (!data || !strategyObj) { + return
Pool {poolId} not found
; + } + + const pointSystem = data.cvstrategies?.[0].config.pointSystem; + const communityAddress = strategyObj.registryCommunity.id as Address; + const alloInfo = data.allos[0]; + const proposalType = strategyObj.config.proposalType; + const poolAmount = strategyObj.poolAmount as number; + const spendingLimitPct = + (Number(strategyObj.config.maxRatio || 0) / CV_SCALE_PRECISION) * 100; + + const isEnabled = data.cvstrategies?.[0]?.isEnabled as boolean; + + return ( +
+ + {isEnabled && ( + <> + {PoolTypes[proposalType] !== "signaling" && ( + + )} + + + )} +
+ ); +} diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/create-pool/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/create-pool/page.tsx index b4cb4b609..5e12c0d20 100644 --- a/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/create-pool/page.tsx +++ b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/create-pool/page.tsx @@ -1,47 +1,50 @@ +"use client"; + +import React from "react"; +import { Address } from "viem"; import { - TokenGarden, getPoolCreationDataDocument, getPoolCreationDataQuery, } from "#/subgraph/.graphclient"; -import PoolForm from "@/components/Forms/PoolForm"; -import { initUrqlClient, queryByChain } from "@/providers/urql"; -import React from "react"; -import { Address } from "viem"; +import { PoolForm } from "@/components/Forms/PoolForm"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { useSubgraphQuery } from "@/hooks/useSubgraphQuery"; -export default async function CreatePool({ - params: { chain, garden, community }, +export default function Page({ + params: { garden, community }, }: { - params: { chain: number; garden: string; community: string }; + params: { garden: string; community: string }; }) { - const { urqlClient } = initUrqlClient(); - - const { data: result, error: error } = - await queryByChain( - urqlClient, - chain, - getPoolCreationDataDocument, - { communityAddr: community, tokenAddr: garden }, - ); + const { data: result } = useSubgraphQuery({ + query: getPoolCreationDataDocument, + variables: { communityAddr: community, tokenAddr: garden }, + }); let token = result?.tokenGarden; let alloAddr = result?.allos[0]?.id as Address; let communityName = result?.registryCommunity?.communityName as string; - return ( -
-
-

- Create a Pool in {communityName} community -

- {/*
-

subtitle for pool form creation...

-
*/} + if (!token) { + return ( +
+ +
+ ); + } + + return result ? +
+
+
+

Create a Pool in {communityName} community

+
+ +
- -
- ); + :
+ +
; } diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/page.tsx new file mode 100644 index 000000000..96f94453d --- /dev/null +++ b/apps/web/app/(app)/gardens/[chain]/[garden]/[community]/page.tsx @@ -0,0 +1,374 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { + CurrencyDollarIcon, + PlusIcon, + RectangleGroupIcon, + UserGroupIcon, +} from "@heroicons/react/24/outline"; +import { Dnum } from "dnum"; +import Image from "next/image"; +import Link from "next/link"; +import { Address } from "viem"; +import { useAccount, useToken } from "wagmi"; +import { + getCommunityDocument, + getCommunityQuery, + isMemberDocument, + isMemberQuery, +} from "#/subgraph/.graphclient"; +import { commImg, groupFlowers } from "@/assets"; +import { + Button, + DisplayNumber, + EthAddress, + IncreasePower, + PoolCard, + RegisterMember, + Statistic, + InfoWrapper, +} from "@/components"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import MarkdownWrapper from "@/components/MarkdownWrapper"; +import { TokenGardenFaucet } from "@/components/TokenGardenFaucet"; +import { isProd } from "@/configs/isProd"; +import { QUERY_PARAMS } from "@/constants/query-params"; +import { useCollectQueryParams } from "@/hooks/useCollectQueryParams"; +import { useDisableButtons } from "@/hooks/useDisableButtons"; +import { useSubgraphQuery } from "@/hooks/useSubgraphQuery"; +import { PoolTypes } from "@/types"; +import { fetchIpfs } from "@/utils/ipfsUtils"; +import { + dn, + parseToken, + SCALE_PRECISION, + SCALE_PRECISION_DECIMALS, +} from "@/utils/numbers"; + +export default function Page({ + params: { chain, garden: tokenAddr, community: communityAddr }, +}: { + params: { chain: number; garden: string; community: string }; +}) { + const searchParams = useCollectQueryParams(); + const { address: accountAddress } = useAccount(); + const [covenant, setCovenant] = useState(); + const covenantSectionRef = useRef(null); + const { data: tokenGarden } = useToken({ + address: tokenAddr as Address, + chainId: +chain, + }); + const { + data: result, + error, + refetch, + } = useSubgraphQuery({ + query: getCommunityDocument, + variables: { communityAddr: communityAddr, tokenAddr: tokenAddr }, + changeScope: [ + { topic: "community", id: communityAddr }, + { topic: "member", containerId: communityAddr }, + ], + }); + + const registryCommunity = result?.registryCommunity; + + let { + communityName, + members, + strategies, + communityFee, + registerStakeAmount, + } = registryCommunity ?? {}; + + const { data: isMemberResult } = useSubgraphQuery({ + query: isMemberDocument, + variables: { + me: accountAddress?.toLowerCase(), + comm: communityAddr.toLowerCase(), + }, + changeScope: [ + { topic: "community", id: communityAddr }, + { topic: "member", containerId: communityAddr }, + ], + enabled: accountAddress !== undefined, + }); + + const { tooltipMessage, isConnected, missmatchUrl } = useDisableButtons(); + + useEffect(() => { + if (error) { + console.error("Error while fetching community data: ", error); + } + }, [error]); + + useEffect(() => { + const fetchCovenant = async () => { + if (registryCommunity?.covenantIpfsHash) { + try { + const json = await fetchIpfs<{ covenant: string }>( + registryCommunity.covenantIpfsHash, + ); + if (json && typeof json.covenant === "string") { + setCovenant(json.covenant); + } + } catch (err) { + console.error(err); + } + } + }; + fetchCovenant(); + }, [registryCommunity?.covenantIpfsHash]); + + const communityStakedTokens = + members?.reduce( + (acc: bigint, member) => acc + BigInt(member?.stakedTokens), + 0n, + ) ?? 0; + + strategies = strategies ?? []; + + const signalingPools = strategies.filter( + (strategy) => + PoolTypes[strategy.config?.proposalType] === "signaling" && + strategy.isEnabled, + ); + + const fundingPools = strategies.filter( + (strategy) => + PoolTypes[strategy.config?.proposalType] === "funding" && + strategy.isEnabled, + ); + const activePools = strategies?.filter((strategy) => strategy?.isEnabled); + + const poolsInReview = strategies.filter((strategy) => !strategy.isEnabled); + + useEffect(() => { + const newPoolId = searchParams[QUERY_PARAMS.communityPage.newPool]; + if ( + newPoolId && + result && + !poolsInReview.some((c) => c.poolId === newPoolId) + ) { + refetch(); + } + }, [searchParams, poolsInReview]); + + useEffect(() => { + if ( + searchParams[QUERY_PARAMS.communityPage.covenant] !== undefined && + covenantSectionRef.current + ) { + const elementTop = + covenantSectionRef.current.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ + top: elementTop - 130, + behavior: "smooth", + }); + } + }, [covenantSectionRef.current, searchParams]); + + if (!tokenGarden || !registryCommunity) { + return ( +
+ +
+ ); + } + + const parsedCommunityFee = () => { + try { + const membership = [ + BigInt(registerStakeAmount), + Number(tokenGarden!.decimals), + ] as dn.Dnum; + const feePercentage = [ + BigInt(communityFee), + SCALE_PRECISION_DECIMALS, // adding 2 decimals because 1% == 10.000 == 1e4 + ] as dn.Dnum; + + return dn.multiply(membership, feePercentage); + } catch (err) { + console.error(err); + } + return [0n, 0] as dn.Dnum; + }; + + const registrationAmount = [ + BigInt(registerStakeAmount), + tokenGarden.decimals, + ] as Dnum; + + const getTotalRegistrationCost = () => { + if (registerStakeAmount) { + // using == for type coercion because communityFee is actually a string + if (communityFee == 0 || communityFee === undefined) { + return BigInt(registerStakeAmount); + } else { + return ( + BigInt(registerStakeAmount) + + BigInt(registerStakeAmount) / + (BigInt(SCALE_PRECISION) / BigInt(communityFee)) + ); + } + } else { + return 0n; + } + }; + + return ( +
+
+
+ {`${communityName} +
+
+
+

{communityName}

+ +
+
+ } + /> + } + count={activePools.length ?? 0} + /> + }> + + +
+

Registration stake:

+ + + +
+
+
+
+ +
+
+ +
+
+

Pools

+ + + +
+
+

+ Funding pools ({fundingPools.length}) +

+
+ {fundingPools.map((pool) => ( + + ))} +
+
+
+

+ Signaling pools ({signalingPools.length}) +

+
+ {signalingPools.map((pool) => ( + + ))} +
+
+
+

+ Pools in Review ({poolsInReview.length}) +

+
+ {poolsInReview.map((pool) => ( + + ))} +
+
+
+
+

Covenant

+ {registryCommunity?.covenantIpfsHash ? + covenant ? + {covenant} + : + :

No covenant was submitted.

} +
+ flowers +
+
+ {!isProd && tokenGarden && } +
+ ); +} diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/create-community/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/create-community/page.tsx index e1d4fb65f..4aebb706e 100644 --- a/apps/web/app/(app)/gardens/[chain]/[garden]/create-community/page.tsx +++ b/apps/web/app/(app)/gardens/[chain]/[garden]/create-community/page.tsx @@ -1,52 +1,65 @@ +"use client"; + +import React, { useEffect } from "react"; +import { Address } from "viem"; +import { useToken } from "wagmi"; import { - TokenGarden, getCommunityCreationDataDocument, getCommunityCreationDataQuery, } from "#/subgraph/.graphclient"; import { CommunityForm } from "@/components/Forms"; -import { initUrqlClient, queryByChain } from "@/providers/urql"; -import React from "react"; -import { Address } from "viem"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { useSubgraphQuery } from "@/hooks/useSubgraphQuery"; -export default async function page({ +export default function Page({ params: { chain, garden }, }: { params: { chain: number; garden: string }; }) { - const { urqlClient } = initUrqlClient(); + const { data: result, error: getCommunityCreationDataQueryError } = + useSubgraphQuery({ + query: getCommunityCreationDataDocument, + variables: { addr: garden }, + }); + + useEffect(() => { + if (getCommunityCreationDataQueryError) { + console.error( + "Error while fetching community creation data: ", + getCommunityCreationDataQueryError, + ); + } + }, [getCommunityCreationDataQueryError]); - const { data: result, error: error } = - await queryByChain( - urqlClient, - chain, - getCommunityCreationDataDocument, - { addr: garden }, - ); + const { data: tokenInfo } = useToken({ + address: garden as Address, + chainId: +chain, + }); const registryFactoryAddr = result?.registryFactories?.[0].id as Address; - const tokenGarden = result?.tokenGarden as TokenGarden; - const alloContractAddr = result?.tokenGarden?.communities?.[0] - .alloAddress as Address; - return ( -
-
-

- Welcome to the {tokenGarden?.symbol} Community Form! -

-
-

- Create a vibrant community around the {tokenGarden.name} by - providing the necessary details below. -

-
+ return tokenInfo ? +
+
+
+

+ Welcome to the {tokenInfo.symbol} Community Form! +

+
+

+ Create a vibrant community around the {tokenInfo.name} by + providing the necessary details below. +

+
+
+ +
- -
- ); + :
+ +
; } diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/page.tsx index 1c79078ad..56342a84b 100644 --- a/apps/web/app/(app)/gardens/[chain]/[garden]/page.tsx +++ b/apps/web/app/(app)/gardens/[chain]/[garden]/page.tsx @@ -1,120 +1,202 @@ -import { tree2, tree3, grassLarge } from "@/assets"; +"use client"; + +import React, { useEffect } from "react"; +import { + CubeTransparentIcon, + PlusIcon, + UserGroupIcon, +} from "@heroicons/react/24/outline"; import Image from "next/image"; -import { CommunityCard } from "@/components"; +import Link from "next/link"; +import { Address } from "viem"; +import { useToken } from "wagmi"; import { - RegistryCommunity, - TokenGarden, - getCommunitiesByGardenDocument, - getCommunitiesByGardenQuery, + getGardenCommunitiesDocument, + getGardenCommunitiesQuery, } from "#/subgraph/.graphclient"; -import { initUrqlClient, queryByChain } from "@/providers/urql"; -import { FormLink } from "@/components"; +import { ecosystem, grassLarge, tree2, tree3 } from "@/assets"; +import { + Button, + Communities, + EthAddress, + Statistic, + TokenLabel, +} from "@/components"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { TokenGardenFaucet } from "@/components/TokenGardenFaucet"; +import { isProd } from "@/configs/isProd"; +import { QUERY_PARAMS } from "@/constants/query-params"; +import { useCollectQueryParams } from "@/hooks/useCollectQueryParams"; +import { useDisableButtons } from "@/hooks/useDisableButtons"; +import { useSubgraphQuery } from "@/hooks/useSubgraphQuery"; export const dynamic = "force-dynamic"; -type Community = Pick< - RegistryCommunity, - | "id" - | "covenantIpfsHash" - | "chainId" - | "communityName" - | "registerToken" - | "registerStakeAmount" - | "alloAddress" -> & {}; - -export default async function Garden({ +export default function Page({ params: { chain, garden }, }: { params: { chain: number; garden: string }; }) { - const { urqlClient } = initUrqlClient(); + const searchParams = useCollectQueryParams(); + const { data: tokenGarden } = useToken({ + address: garden as Address, + chainId: +chain, + }); + + const { + data: result, + error, + refetch, + } = useSubgraphQuery({ + query: getGardenCommunitiesDocument, + variables: { chainId: chain, tokenGarden: garden.toLowerCase() }, + changeScope: [ + { + topic: "member", + containerId: garden, + }, + { + topic: "community", + containerId: garden, + }, + { + topic: "garden", + id: garden, + }, + ], + }); + + const { tooltipMessage, isConnected, missmatchUrl } = useDisableButtons(); + + useEffect(() => { + if (error) { + console.error("Error while fetching garden data: ", error); + } + }, [error]); + + let communities = + result?.registryCommunities?.filter((com) => com.isValid) ?? []; - const { data: result, error: error } = - await queryByChain( - urqlClient, - chain, - getCommunitiesByGardenDocument, - { addr: garden }, + useEffect(() => { + const newCommunityId = + searchParams[QUERY_PARAMS.gardenPage.newCommunity]?.toLowerCase(); + + if ( + newCommunityId && + result && + !communities.some((c) => c.id.toLowerCase() === newCommunityId) + ) { + refetch(); + } + }, [searchParams, result]); + + if (!result) { + return ( +
+ +
); + } - let communities = result?.tokenGarden?.communities || []; - const tokenGarden = result?.tokenGarden as TokenGarden; + const gardenTotalMembers = () => { + const uniqueMembers = new Set(); - const fetchAndUpdateCommunities = async (communities: any) => { - const promises = communities.map(async (com: any) => { - if (com?.covenantIpfsHash) { - const ipfsHash = com.covenantIpfsHash; - try { - const response = await fetch("https://ipfs.io/ipfs/" + ipfsHash); - const json = await response.json(); - // Return a new object with the updated covenantIpfsHash - return { ...com, covenantData: json }; - } catch (error) { - console.log(error); - // Return the original community object in case of an error - return com; - } - } - // Return the original community object if there's no covenantIpfsHash - return com; - }); + communities.forEach((community) => + community.members?.forEach((member) => + uniqueMembers.add(member?.memberAddress), + ), + ); - // Wait for all promises to resolve and update the communities array - const updatedCommunities = await Promise.all(promises); - return updatedCommunities; + return uniqueMembers.size; }; - // Use the function - await fetchAndUpdateCommunities(communities) - .then((updatedCommunities) => { - communities = updatedCommunities; // Here you'll have the communities array with updated covenantIpfsHashes - }) - .catch((error) => { - console.error("An error occurred:", error); - }); - return ( -
-
-
-

- {tokenGarden?.symbol} Token Ecosystem -

-

- Discover communities in the - {tokenGarden?.name} Garden, - where you connect with people and support proposals bounded by a - shared - covenant. -

- +
+
+ {`${tokenGarden?.name}`}
- tree - tree - grass +
+
+
+
+

{tokenGarden?.name}

{" "} + +
+ +
+

+ Discover communities in the + {tokenGarden?.name} Garden, + where you connect with people and support proposals bounded by a + shared + covenant. +

+
+
+ } + count={communities?.length ?? 0} + /> + } + /> +
+
-
-

- {tokenGarden?.name} Communities -

- - {/* communites */} - {communities.map((community, i) => ( - - ))} + +
+
+
+

+ Create your own community +

+
+
+ + + + tree + tree + grass +
+
+ {!isProd && tokenGarden && }
); } diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/create-proposal/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/create-proposal/page.tsx deleted file mode 100644 index dda5153e5..000000000 --- a/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/create-proposal/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { - TokenGarden, - getPoolDataDocument, - getPoolDataQuery, -} from "#/subgraph/.graphclient"; -import { ProposalForm } from "@/components/Forms"; -import { initUrqlClient, queryByChain } from "@/providers/urql"; -import { getIpfsMetadata } from "@/utils/ipfsUtils"; -import { MAX_RATIO_CONSTANT, PERCENTAGE_PRECISION } from "@/utils/numbers"; -import React from "react"; -import { Address } from "viem"; - -const { urqlClient } = initUrqlClient(); - -export default async function page({ - params: { chain, poolId, garden }, -}: { - params: { chain: string; poolId: number; garden: string }; -}) { - const { data } = await queryByChain( - urqlClient, - chain, - getPoolDataDocument, - { poolId: poolId, garden: garden }, - ); - const strategyObj = data?.cvstrategies?.[0]; - - if (!strategyObj) { - return
{`Pool ${poolId} not found`}
; - } - - const pointSystem = data?.cvstrategies?.[0].config?.pointSystem; - const strategyAddr = strategyObj.id as Address; - const communityAddress = strategyObj.registryCommunity.id as Address; - const alloInfo = data?.allos[0]; - const proposalType = strategyObj?.config?.proposalType as number; - const poolAmount = strategyObj?.poolAmount as number; - const tokenGarden = data.tokenGarden; - const metadata = data?.cvstrategies?.[0]?.metadata as string; - - const maxRatioDivPrecision = - (Number(strategyObj?.config?.maxRatio) / PERCENTAGE_PRECISION) * - MAX_RATIO_CONSTANT; - - const spendingLimitPct = maxRatioDivPrecision * 100; - const poolAmountSpendingLimit = poolAmount * maxRatioDivPrecision; - - const { title, description } = await getIpfsMetadata(metadata); - - return ( -
-
-

- Create a Proposal in Pool -

-
-

{title}

-
-
- -
- ); -} diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/page.tsx deleted file mode 100644 index ba7942f19..000000000 --- a/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/page.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Badge, Proposals, PoolMetrics, PoolGovernance } from "@/components"; -import Image from "next/image"; -import { gardenLand } from "@/assets"; -import { initUrqlClient, queryByChain } from "@/providers/urql"; -import { - Allo, - CVStrategy, - TokenGarden, - getAlloQuery, - getPoolDataDocument, - getPoolDataQuery, -} from "#/subgraph/.graphclient"; -import { Address } from "#/subgraph/src/scripts/last-addr"; -import { getIpfsMetadata } from "@/utils/ipfsUtils"; -import { pointSystems, proposalTypes } from "@/types"; - -export const dynamic = "force-dynamic"; - -export type AlloQuery = getAlloQuery["allos"][number]; - -const { urqlClient } = initUrqlClient(); - -export default async function Pool({ - params: { chain, poolId, garden }, -}: { - params: { chain: string; poolId: number; garden: string }; -}) { - const { data } = await queryByChain( - urqlClient, - chain, - getPoolDataDocument, - { poolId: poolId, garden: garden }, - ); - const strategyObj = data?.cvstrategies?.[0] as CVStrategy; - //const { tooltipMessage, isConnected, missmatchUrl } = useDisableButtons(); - - if (!strategyObj) { - return
{`Pool ${poolId} not found`}
; - } - - const pointSystem = data?.cvstrategies?.[0].config?.pointSystem; - const strategyAddr = strategyObj.id as Address; - const communityAddress = strategyObj.registryCommunity.id as Address; - const alloInfo = data?.allos[0] as Allo; - const proposalType = strategyObj?.config?.proposalType as number; - const poolAmount = strategyObj?.poolAmount as number; - const tokenGarden = data?.tokenGarden as TokenGarden; - const metadata = data?.cvstrategies?.[0]?.metadata as string; - const isEnabled = data?.cvstrategies?.[0]?.isEnabled as boolean; - const { title, description } = await getIpfsMetadata(metadata); - - //TODO: check decimals - //spending limit calculations - const PRECISON_OF_7 = 10 ** 7; - const maxRatioDivPrecision = - Number(strategyObj?.config?.maxRatio) / PRECISON_OF_7; - - const spendingLimitPct = maxRatioDivPrecision * 100; - console.log( - "maxRatio: " + strategyObj?.config?.maxRatio, - "minThresholdPoints: " + strategyObj?.config?.minThresholdPoints, - "poolAmount: " + poolAmount, - ); - - return ( -
-
-
-

Pool {poolId}

-
-
- {/* Description section */} -
-
-

{title}

- {!isEnabled && ( -
- Pending review from community council -
- )} - -

{description}

-
-
-
-
- Strategy:{" "} - - {" "} - Conviction Voting - -
- {proposalType == 1 && ( -
- Funding Token:{" "} - - {" "} - {tokenGarden?.symbol} - -
- )} -
- Points System:{" "} - - {pointSystems[pointSystem]} - -
-
-
-
-

- Proposals type accepted: -

-
- -
-
-
-
-
-
- {[...Array(6)].map((_, i) => ( - garden land - ))} -
-
- {/* Pool metrics: for now we have funds available and spending limit */} - {isEnabled && ( - <> - {proposalTypes[proposalType] !== "signaling" && ( - - )} - {/* Proposals section */} - - - )} -
-
-
- ); -} diff --git a/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/proposals/[proposalId]/page.tsx b/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/proposals/[proposalId]/page.tsx deleted file mode 100644 index 8cb9a7971..000000000 --- a/apps/web/app/(app)/gardens/[chain]/[garden]/pool/[poolId]/proposals/[proposalId]/page.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { Badge, StatusBadge } from "@/components"; -import { EthAddress } from "@/components"; -import { cvStrategyABI } from "@/src/generated"; -import { Abi, Address, createPublicClient, formatUnits, http } from "viem"; -import { getChain } from "@/configs/chainServer"; -import { ConvictionBarChart } from "@/components/Charts/ConvictionBarChart"; -import { initUrqlClient, queryByChain } from "@/providers/urql"; -import { - getProposalDataDocument, - getProposalDataQuery, -} from "#/subgraph/.graphclient"; -import { formatTokenAmount, calculatePercentageBigInt } from "@/utils/numbers"; -import { getIpfsMetadata } from "@/utils/ipfsUtils"; - -export const dynamic = "force-dynamic"; - -// export const EMPTY_BENEFICIARY = "0x0000000000000000000000000000000000000000"; - -type ProposalsMock = { - title: string; - type: "funding" | "streaming" | "signaling"; - description: string; - value?: number; - id: number; -}; - -type UnparsedProposal = { - submitter: Address; - beneficiary: Address; - requestedToken: Address; - requestedAmount: number; - stakedTokens: number; - proposalType: any; - proposalStatus: any; - blockLast: number; - convictionLast: number; - agreementActionId: number; - threshold: number; - voterStakedPointsPct: number; -}; - -type Proposal = UnparsedProposal & ProposalsMock; - -const { urqlClient } = initUrqlClient(); - -const prettyTimestamp = (timestamp: number) => { - const date = new Date(timestamp * 1000); - - const day = date.getDate(); - const month = date.toLocaleString("default", { month: "short" }); - const year = date.getFullYear(); - - return `${day} ${month} ${year}`; -}; - -export default async function Proposal({ - params: { proposalId, poolId, chain, garden }, -}: { - params: { proposalId: string; poolId: number; chain: number; garden: string }; -}) { - // TODO: fetch garden decimals in query - const { data: getProposalQuery } = await queryByChain( - urqlClient, - chain, - getProposalDataDocument, - { - garden: garden, - proposalId: proposalId, - }, - ); - - const proposalData = getProposalQuery?.cvproposal; - - if (!proposalData) { - return ( -

{`Proposal ${proposalId} not found`}

- ); - } - - const tokenSymbol = getProposalQuery?.tokenGarden?.symbol; - const tokenDecimals = getProposalQuery?.tokenGarden?.decimals; - const proposalIdNumber = proposalData.proposalNumber as number; - const convictionLast = proposalData.convictionLast as string; - const threshold = proposalData.threshold as bigint; - const proposalType = proposalData.strategy.config?.proposalType as number; - const requestedAmount = proposalData.requestedAmount as bigint; - const beneficiary = proposalData.beneficiary as Address; - const submitter = proposalData.submitter as Address; - const status = proposalData.proposalStatus as number; - const stakedAmount = proposalData.stakedAmount as bigint; - const metadata = proposalData.metadata; - - const isSignalingType = proposalType == 0; - - const { title, description } = await getIpfsMetadata(metadata); - - const client = createPublicClient({ - chain: getChain(chain), - transport: http(), - }); - - const cvStrategyContract = { - address: proposalData.strategy.id as Address, - abi: cvStrategyABI as Abi, - }; - - let totalEffectiveActivePoints = 0n; - let updateConvictionLast = 0n; - let getProposal: any = []; - let maxCVSupply = 0n; - let thFromContract = 0n; - let stakeAmountFromContract = 0n; - - try { - if (!isSignalingType) { - thFromContract = (await client.readContract({ - ...cvStrategyContract, - functionName: "calculateThreshold", - args: [proposalIdNumber], - })) as bigint; - } - } catch (error) { - console.log(error); - } - - try { - totalEffectiveActivePoints = (await client.readContract({ - ...cvStrategyContract, - functionName: "totalEffectiveActivePoints", - })) as bigint; - - stakeAmountFromContract = (await client.readContract({ - ...cvStrategyContract, - functionName: "getProposalStakedAmount", - args: [proposalIdNumber], - })) as bigint; - getProposal = await client.readContract({ - ...cvStrategyContract, - functionName: "getProposal", - args: [proposalIdNumber], - }); - updateConvictionLast = (await client.readContract({ - ...cvStrategyContract, - functionName: "updateProposalConviction", - args: [proposalIdNumber], - })) as bigint; - maxCVSupply = (await client.readContract({ - ...cvStrategyContract, - functionName: "getMaxConviction", - args: [totalEffectiveActivePoints], - })) as bigint; - } catch (error) { - updateConvictionLast = getProposal[7] as bigint; - console.log( - "proposal already executed so threshold can no be read from contracts, or it is siganling proposal", - error, - ); - } - - //logs for debugging in arb sepolia - //TODO: remove before merge - console.log("requesteAmount: %s", requestedAmount); - console.log("maxCVSupply: %s", maxCVSupply); - //thresholda - // console.log(threshold); - console.log("threshold: %s", threshold); - // console.log(thFromContract); - console.log("thFromContract: %s", thFromContract); - //stakeAmount - // console.log(stakedAmount); - console.log("stakedAmount: %s", stakedAmount); - // console.log(stakeAmountFromContract); - console.log("stakeAmountFromContract: %s", stakeAmountFromContract); - // console.log(totalEffectiveActivePoints); - console.log("totalEffectiveActivePoints: %s", totalEffectiveActivePoints); - // console.log(updateConvictionLast); - console.log("updateConvictionLast: %s", updateConvictionLast); - // console.log(convictionLast); - console.log("convictionLast: %s", convictionLast); - - const thresholdPct = calculatePercentageBigInt( - threshold, - maxCVSupply, - tokenDecimals, - ); - - console.log("thresholdPct: %s", thresholdPct); - - // console.log("ff: %s", ff); - - // const totalSupportPct = calculatePercentageDecimals( - // stakedAmount, - // totalEffectiveActivePoints, - // tokenDecimals, - // ); - - const totalSupportPct = calculatePercentageBigInt( - stakedAmount, - totalEffectiveActivePoints, - tokenDecimals, - ); - - console.log("totalSupportPct: %s", totalSupportPct); - // const currentConvictionPct = calculatePercentageDecimals( - // updateConvictionLast, - // maxCVSupply, - // tokenDecimals, - // ); - - const currentConvictionPct = calculatePercentageBigInt( - updateConvictionLast, - maxCVSupply, - tokenDecimals, - ); - - console.log("currentConviction: %s", currentConvictionPct); - - return ( -
-
- {/* main content */} -
-
- -

- - {" "} - {prettyTimestamp(proposalData?.createdAt || 0)} - -

-
- -

Pool: {poolId}

-
- - {/* title - description - status */} -
-
- -
-
-

- {title} -

-
-
-

{description}

-
-
- {/* reqAmount - bene - creatBy */} -
- {!isSignalingType && !!requestedAmount && ( -
- - Requested Amount - - - {formatTokenAmount(requestedAmount, 18)}{" "} - {tokenSymbol} - -
- )} - {!isSignalingType && beneficiary && ( -
- - Beneficiary - - -
- )} - {submitter && ( -
- - Created By - - -
- )} -
-
-
- - {status && status == 4 ? ( -
- Proposal passed and executed successfully -
- ) : ( -
- -
- )} -
-
- ); -} diff --git a/apps/web/app/(app)/gardens/page.tsx b/apps/web/app/(app)/gardens/page.tsx index d3160c43f..ccea2d846 100644 --- a/apps/web/app/(app)/gardens/page.tsx +++ b/apps/web/app/(app)/gardens/page.tsx @@ -1,96 +1,114 @@ -import React from "react"; +"use client"; + +import React, { useMemo } from "react"; import Image from "next/image"; -import { clouds1, clouds2, gardenHeader } from "@/assets"; -import { GardenCard } from "@/components"; import { getTokenGardensDocument, getTokenGardensQuery, } from "#/subgraph/.graphclient"; -import { initUrqlClient, queryByChain } from "@/providers/urql"; -import { - localhost, - arbitrumSepolia, - optimismSepolia, - sepolia, -} from "viem/chains"; -import { getContractsAddrByChain, isProd } from "@/constants/contracts"; - -export const dynamic = "force-dynamic"; +import { clouds1, clouds2, groupFlowers } from "@/assets"; +import { GardenCard, InfoBox } from "@/components"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { useSubgraphQueryMultiChain } from "@/hooks/useSubgraphQueryMultiChain"; -const { urqlClient } = initUrqlClient(); +export default function Page() { + const { data: gardens, fetching } = + useSubgraphQueryMultiChain({ + query: getTokenGardensDocument, + modifier: (data) => + data.sort( + (a, b) => + (a.tokenGardens.length ? a.tokenGardens[0].chainId : 0) - + (b.tokenGardens.length ? b.tokenGardens[0].chainId : 0), + ), + changeScope: [ + { + topic: "garden", + }, + { + topic: "community", + }, + ], + }); -export default async function Gardens() { - const chainsId = [ - localhost.id, - arbitrumSepolia.id, - optimismSepolia.id, - sepolia.id, - ]; - let gardens: getTokenGardensQuery | null = null; - gardens = { - tokenGardens: [], - }; + const tokenGardens = useMemo( + () => + gardens + ?.flatMap((g) => g.tokenGardens) + .filter((x): x is NonNullable => !!x), + [gardens], + ); - try { - if (isProd) { - const r0 = await getTokenGardens(sepolia.id); - gardens?.tokenGardens.push(...r0.data.tokenGardens); + const GardenList = useMemo(() => { + if (!tokenGardens) { + return ; + } + if (tokenGardens.length) { + return ( + <> + {tokenGardens + .sort( + (a, b) => + (a.communities?.length ?? 0) - (b.communities?.length ?? 0), + ) + .map((garden) => ( +
+ +
+ ))} + + ); } else { - const promises = []; - for (let index = 0; index < chainsId.length; index++) { - const chainId = chainsId[index]; - const subgraph = getContractsAddrByChain(chainId)?.subgraphUrl; - if (subgraph !== "") promises.push(await getTokenGardens(chainId)); - } - - const resArr = await Promise.all(promises); - resArr.forEach((res) => { - if (res?.data) gardens?.tokenGardens.push(...res?.data.tokenGardens); - }); + return ( + <> + + + Be the first to create your community 🌱
+ + https://discord.gg/FjEVDqC6EP + +
+ + ); } - } catch (error) { - console.error("Error fetching token gardens:", error); - } + }, [fetching, tokenGardens?.length]); return ( -
-
-
-
- clouds -
-
-
-

- Explore and Join Gardens Ecosystems -

-

- A place where you help shape digital economies -

+ <> +
+
+
+
+ clouds +
+
+
+

+ Welcome to Gardens +

+

+ A place where communities grow through collective + decision-making +

+
+
+
+ clouds
-
- clouds +
+
+
+ {GardenList}
-
-
-
-
-
- {gardens ? ( - gardens.tokenGardens.map((garden, id) => ( - - )) - ) : ( -
{"Can't find token gardens"}
- )} -
- gardens -
-
+ + flowers +
+ ); } - -async function getTokenGardens(chainId: string | number) { - return await queryByChain(urqlClient, chainId, getTokenGardensDocument, {}); -} diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 8c4c1cceb..98e9ae6d5 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,13 +1,22 @@ +"use client"; + import React from "react"; -import { NavBar } from "@/components"; -import { GoBackButton } from "@/components"; -export default function layout({ children }: { children: React.ReactNode }) { +import { GoBackButton, NavBar } from "@/components"; +import { Breadcrumbs } from "@/components/Breadcrumbs"; + +export default function Layout({ children }: { children: React.ReactNode }) { return ( - <> +
- -
{children}
- {/* footer */} - +
+ + {children} +
+
); } diff --git a/apps/web/app/api/ably-auth/route.ts b/apps/web/app/api/ably-auth/route.ts new file mode 100644 index 000000000..0bf9a83bb --- /dev/null +++ b/apps/web/app/api/ably-auth/route.ts @@ -0,0 +1,36 @@ +// api/ably-auth + +import Ably from "ably"; +import { NextResponse } from "next/server"; +import { HTTP_CODES } from "../utils"; +import { CHANGE_EVENT_CHANNEL_NAME } from "@/globals"; + +export async function POST() { + // Used for linter that fails + if (!process.env.NEXT_ABLY_API_KEY) { + console.error("NEXT_ABLY_API_KEY env must be"); + return NextResponse.json({ + status: HTTP_CODES.SERVER_ERROR, + message: "No auth", + }); + } + + const ably = new Ably.Rest({ key: process.env.NEXT_ABLY_API_KEY }); + + try { + const tokenRequestData = { + capability: JSON.stringify({ + [CHANGE_EVENT_CHANNEL_NAME]: ["publish", "subscribe", "presence"], + }), + }; + const tokenDetails = await ably.auth.createTokenRequest(tokenRequestData); + return NextResponse.json(tokenDetails); + } catch (error) { + console.error(error); + return NextResponse.json({ + status: HTTP_CODES.SERVER_ERROR, + message: "Failed to generate token", + error: process.env.NODE_ENV === "production" ? undefined : error, + }); + } +} diff --git a/apps/web/app/api/ipfs/[hash]/route.ts b/apps/web/app/api/ipfs/[hash]/route.ts new file mode 100644 index 000000000..33770bc61 --- /dev/null +++ b/apps/web/app/api/ipfs/[hash]/route.ts @@ -0,0 +1,25 @@ +// api/ipfs/[hash] + +import { Params } from "next/dist/shared/lib/router/utils/route-matcher"; +import { NextRequest } from "next/server"; + +const ipfsGateway = process.env.IPFS_GATEWAY ?? "ipfs.io"; +export async function GET(req: NextRequest, { params }: Params) { + const { hash } = params; + const searchParams = new URL(req.url).searchParams; + const ipfsUri = `https://${ipfsGateway}/ipfs/${hash}?${process.env.PINATA_KEY ? "pinataGatewayToken=" + process.env.PINATA_KEY : ""}`; + const res = await fetch(ipfsUri, { + method: "GET", + headers: { + "content-type": "application/json", + }, + }); + + if (searchParams.get("isText") === "true") { + const text = await res.text(); + return Response.json({ text }, { status: 200 }); + } else { + const content = await res.json(); + return Response.json(content, { status: 200 }); + } +} diff --git a/apps/web/app/api/ipfs/route.ts b/apps/web/app/api/ipfs/route.ts index 88d1d1acb..850a1bb89 100644 --- a/apps/web/app/api/ipfs/route.ts +++ b/apps/web/app/api/ipfs/route.ts @@ -1,29 +1,23 @@ -import { NextResponse } from "next/server"; -import { NextRequest } from "next/server"; -import pinataSDK from "@pinata/sdk"; +// api/ipfs import { Readable } from "stream"; +import pinataSDK from "@pinata/sdk"; +import { NextRequest, NextResponse } from "next/server"; const pinata = new pinataSDK({ pinataJWTKey: process.env.PINATA_JWT }); - const saveFile = async (buffer: Buffer, fileName: string) => { - try { - const readable = new Readable(); - readable.push(buffer); - readable.push(null); - - const options = { - pinataMetadata: { - name: fileName, - }, - }; - const response = await pinata.pinFileToIPFS(readable, options); - - return response; - } catch (error) { - // console.log(error); - throw error; - } + const readable = new Readable(); + readable.push(buffer); + readable.push(null); + + const options = { + pinataMetadata: { + name: fileName, + }, + }; + const response = await pinata.pinFileToIPFS(readable, options); + + return response; }; export async function POST(req: NextRequest) { @@ -37,7 +31,7 @@ export async function POST(req: NextRequest) { } catch (error) { return NextResponse.json( { message: "Error uploading json to IPFS" }, - { status: 500 } + { status: 500 }, ); } } else if (contentType?.startsWith("multipart/form-data")) { @@ -58,7 +52,7 @@ export async function POST(req: NextRequest) { } catch (error) { return NextResponse.json( { message: "Error uploading file to IPFS" }, - { status: 500 } + { status: 500 }, ); } } else { diff --git a/apps/web/app/api/passport-oracle/addStrategy/route.ts b/apps/web/app/api/passport-oracle/addStrategy/route.ts new file mode 100644 index 000000000..db4425c8d --- /dev/null +++ b/apps/web/app/api/passport-oracle/addStrategy/route.ts @@ -0,0 +1,119 @@ +// api/add-strategy + +import { NextResponse } from "next/server"; +import { + createPublicClient, + http, + createWalletClient, + custom, + Address, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { getConfigByChain } from "@/configs/chains"; +import { + passportScorerABI, + registryCommunityABI, + cvStrategyABI, +} from "@/src/generated"; +import { getViemChain } from "@/utils/web3"; + +const LIST_MANAGER_PRIVATE_KEY = process.env.LIST_MANAGER_PRIVATE_KEY; + +const CHAIN_ID = process.env.CHAIN_ID ? parseInt(process.env.CHAIN_ID) : 1337; +const LOCAL_RPC = "http://127.0.0.1:8545"; + +const RPC_URL = getConfigByChain(CHAIN_ID)?.rpcUrl ?? LOCAL_RPC; + +const PASSPORT_SCORER_ADDRESS = getConfigByChain(CHAIN_ID) + ?.passportScorer as Address; + +const client = createPublicClient({ + chain: getViemChain(CHAIN_ID), + transport: http(RPC_URL), +}); + +const walletClient = createWalletClient({ + account: privateKeyToAccount(`${LIST_MANAGER_PRIVATE_KEY}` as Address), + chain: getViemChain(CHAIN_ID), + transport: custom(client.transport), +}); + +export async function POST(req: Request) { + const { strategy, threshold } = await req.json(); + + if (!strategy || !threshold) { + return NextResponse.json( + { + error: + "Strategy address, threshold, and CV Strategy address are required", + }, + { status: 400 }, + ); + } + + try { + // Get registryCommunity address from CVStrategy + let registryCommunityAddress: Address; + try { + registryCommunityAddress = await client.readContract({ + abi: cvStrategyABI, + address: strategy, + functionName: "registryCommunity", + }); + } catch (error) { + console.error("Error fetching registryCommunity address:", error); + return NextResponse.json( + { error: "Failed to fetch registryCommunity address" }, + { status: 500 }, + ); + } + + // Get councilSafe address from RegistryCommunity + let councilSafeAddress: Address; + try { + councilSafeAddress = await client.readContract({ + abi: registryCommunityABI, + address: registryCommunityAddress, + functionName: "councilSafe", + }); + } catch (error) { + console.error("Error fetching councilSafe address:", error); + return NextResponse.json( + { error: "Failed to fetch councilSafe address" }, + { status: 500 }, + ); + } + + try { + const data = { + abi: passportScorerABI, + address: PASSPORT_SCORER_ADDRESS, + functionName: "addStrategy" as const, + args: [ + strategy as Address, + BigInt(threshold), + councilSafeAddress, + ] as const, + }; + + const hash = await walletClient.writeContract(data); + + return NextResponse.json({ + message: "Strategy added successfully", + transactionHash: hash, + }); + } catch (error) { + console.error("Error adding strategy:", error); + return NextResponse.json( + { error: "Failed to add strategy" }, + { status: 500 }, + ); + } + } catch (error) { + console.error("Unexpected error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/passport-oracle/dailyJob/route.ts b/apps/web/app/api/passport-oracle/dailyJob/route.ts new file mode 100644 index 000000000..99fae58e7 --- /dev/null +++ b/apps/web/app/api/passport-oracle/dailyJob/route.ts @@ -0,0 +1,187 @@ +// api/passport-oracles/daily-job + +import { NextResponse } from "next/server"; +import { gql } from "urql"; +import { + createPublicClient, + http, + createWalletClient, + custom, + Address, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { getConfigByChain } from "@/configs/chains"; +import { initUrqlClient } from "@/providers/urql"; +import { passportScorerABI } from "@/src/generated"; +import { CV_PERCENTAGE_SCALE } from "@/utils/numbers"; +import { getViemChain } from "@/utils/web3"; + +const LIST_MANAGER_PRIVATE_KEY = process.env.LIST_MANAGER_PRIVATE_KEY ?? ""; +const CHAIN_ID = process.env.CHAIN_ID ? parseInt(process.env.CHAIN_ID) : 1337; +const LOCAL_RPC = "http://127.0.0.1:8545"; +const RPC_URL = getConfigByChain(CHAIN_ID)?.rpcUrl ?? LOCAL_RPC; +const CONTRACT_ADDRESS = getConfigByChain(CHAIN_ID)?.passportScorer as Address; +const SUBGRAPH = getConfigByChain(CHAIN_ID)?.subgraphUrl as string; +const API_ENDPOINT = "/api/passport/scores"; + +interface PassportUser { + id: string; + userAddress: string; + score: string; + lastUpdated: string; +} + +interface ApiScore { + address: string; + score: string; + status: string; + last_score_timestamp: string; + expiration_date: string | null; + evidence: string | null; + error: string | null; + stamp_scores: Record; +} + +const client = createPublicClient({ + chain: getViemChain(CHAIN_ID), + transport: http(RPC_URL), +}); + +const walletClient = createWalletClient({ + account: privateKeyToAccount(LIST_MANAGER_PRIVATE_KEY as Address), + chain: getViemChain(CHAIN_ID), + transport: custom(client.transport), +}); + +const { urqlClient } = initUrqlClient({ chainId: CHAIN_ID }); + +const query = gql` + query { + passportUsers { + id + userAddress + score + lastUpdated + } + } +`; + +const fetchScoresFromService = async (): Promise => { + const url = new URL( + API_ENDPOINT, + `http://${process.env.HOST ?? "localhost"}:${process.env.PORT ?? 3000}`, + ); + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + return data.items; + } else { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to fetch scores from service"); + } +}; + +const compareScores = ( + subgraphUsers: PassportUser[], + apiScores: ApiScore[], +) => { + const updates: { + userAddress: Address; + score: number; + lastUpdated: number; + }[] = []; + + subgraphUsers.forEach((subgraphUser) => { + const apiUser = apiScores.find( + (user) => + user.address.toLowerCase() === subgraphUser.userAddress.toLowerCase(), + ); + + if ( + apiUser && + parseFloat(apiUser.score) !== parseFloat(subgraphUser.score) + ) { + updates.push({ + userAddress: subgraphUser.userAddress as Address, + score: parseFloat(apiUser.score), + lastUpdated: new Date(apiUser.last_score_timestamp).getTime() / 1000, + }); + } + }); + + return updates; +}; + +const updateScoresOnChain = async ( + updates: { userAddress: Address; score: number; lastUpdated: number }[], +) => { + for (const update of updates) { + const integerScore = Number(update.score) * CV_PERCENTAGE_SCALE; + const data = { + abi: passportScorerABI, + address: CONTRACT_ADDRESS, + functionName: "addUserScore" as const, + args: [ + update.userAddress, + { + score: BigInt(integerScore), + lastUpdated: BigInt(Date.now()), + }, + ] as const, + }; + + const hash = await walletClient.writeContract(data); + await client.waitForTransactionReceipt({ hash }); + } +}; + +const updateScores = async () => { + const subgraphResponse = await urqlClient + .query<{ passportUsers: PassportUser[] }>( + query, + {}, + { + url: SUBGRAPH, + requestPolicy: "network-only", + }, + ) + .toPromise(); + + if (!subgraphResponse.data) { + throw new Error("Failed to fetch data from subgraph"); + } + + const subgraphUsers = subgraphResponse.data.passportUsers ?? []; + + const apiScores = await fetchScoresFromService(); + const updates = compareScores(subgraphUsers, apiScores); + + if (updates.length > 0) { + await updateScoresOnChain(updates); + } + + return updates; +}; + +export async function GET() { + try { + const updates = await updateScores(); + return NextResponse.json( + { message: "Scores updated successfully", updates }, + { status: 200 }, + ); + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/passport-oracle/writeScore/route.ts b/apps/web/app/api/passport-oracle/writeScore/route.ts new file mode 100644 index 000000000..4718082cb --- /dev/null +++ b/apps/web/app/api/passport-oracle/writeScore/route.ts @@ -0,0 +1,99 @@ +// api/passport-oracle/write-score + +import { NextResponse } from "next/server"; +import { + createPublicClient, + http, + createWalletClient, + custom, + Address, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { getConfigByChain } from "@/configs/chains"; +import { passportScorerABI } from "@/src/generated"; +import { CV_PERCENTAGE_SCALE } from "@/utils/numbers"; +import { getViemChain } from "@/utils/web3"; + +const LIST_MANAGER_PRIVATE_KEY = process.env.LIST_MANAGER_PRIVATE_KEY; +const CHAIN_ID = process.env.CHAIN_ID ? parseInt(process.env.CHAIN_ID) : 1337; +const LOCAL_RPC = "http://127.0.0.1:8545"; + +const RPC_URL = getConfigByChain(CHAIN_ID)?.rpcUrl ?? LOCAL_RPC; + +const CONTRACT_ADDRESS = getConfigByChain(CHAIN_ID)?.passportScorer as Address; + +const API_ENDPOINT = "/api/passport"; + +const client = createPublicClient({ + chain: getViemChain(CHAIN_ID), + transport: http(RPC_URL), +}); + +const walletClient = createWalletClient({ + account: privateKeyToAccount( + (`${LIST_MANAGER_PRIVATE_KEY}` as Address) || "", + ), + chain: getViemChain(CHAIN_ID), + transport: custom(client.transport), +}); + +const fetchScoreFromGitcoin = async (user: string) => { + const url = new URL( + API_ENDPOINT, + `http://${process.env.HOST ?? "localhost"}:${process.env.PORT ?? 3000}`, + ); + const response = await fetch(`${url}/${user}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + return data.score; + } else { + const errorData = await response.json(); + throw new Error(errorData.message || "Failed to fetch score from Gitcoin"); + } +}; + +export async function POST(req: Request) { + const { user } = await req.json(); + + if (!user) { + return NextResponse.json( + { + error: "User address is required", + }, + { status: 400 }, + ); + } + + try { + const score = await fetchScoreFromGitcoin(user); + const integerScore = Number(score) * CV_PERCENTAGE_SCALE; + const data = { + abi: passportScorerABI, + address: CONTRACT_ADDRESS, + functionName: "addUserScore" as const, + args: [ + user, + { score: BigInt(integerScore), lastUpdated: BigInt(Date.now()) }, + ] as const, + }; + + const hash = await walletClient.writeContract(data); + + return NextResponse.json({ + message: "User score added successfully", + transactionHash: hash, + }); + } catch (error) { + console.error("Error adding user score:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/passport/[account]/route.ts b/apps/web/app/api/passport/[account]/route.ts new file mode 100644 index 000000000..268d43ce3 --- /dev/null +++ b/apps/web/app/api/passport/[account]/route.ts @@ -0,0 +1,61 @@ +// api/passport/[account] + +import { NextResponse } from "next/server"; + +interface Params { + params: { + account: string; + }; +} + +export async function GET(request: Request, { params }: Params) { + const { account } = params; + + if (!account) { + return NextResponse.json( + { error: "Account address is required" }, + { status: 400 }, + ); + } + + const apiKey = process.env.GITCOIN_PASSPORT_API_KEY; + const scorerId = process.env.SCORER_ID; + const endpoint = `https://api.scorer.gitcoin.co/registry/score/${scorerId}/${account}`; + + if (!apiKey) { + return NextResponse.json({ error: "API key is missing" }, { status: 500 }); + } + + console.info("Making request to endpoint:", endpoint); + + try { + const response = await fetch(endpoint, { + method: "GET", + headers: { + "X-API-KEY": apiKey, + "Content-Type": "application/json", + }, + }); + + console.info("Response status:", response.status); + console.info("Response status text:", response.statusText); + + if (response.ok) { + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } else { + const errorData = await response.json(); + console.info("Error data:", errorData); + return NextResponse.json( + { error: errorData.message }, + { status: response.status }, + ); + } + } catch (error) { + console.error("Request error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/passport/scores/route.ts b/apps/web/app/api/passport/scores/route.ts new file mode 100644 index 000000000..b04c96dda --- /dev/null +++ b/apps/web/app/api/passport/scores/route.ts @@ -0,0 +1,40 @@ +// api/passport/scores + +import { NextResponse } from "next/server"; + +export async function GET() { + const apiKey = process.env.GITCOIN_PASSPORT_API_KEY; + const scorerId = process.env.SCORER_ID; + const endpoint = `https://api.scorer.gitcoin.co/registry/score/${scorerId}`; + + if (!apiKey) { + return NextResponse.json({ error: "API key is missing" }, { status: 500 }); + } + + try { + const response = await fetch(endpoint, { + method: "GET", + headers: { + "X-API-KEY": apiKey, + "Content-Type": "application/json", + }, + }); + + if (response.ok) { + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } else { + const errorData = await response.json(); + return NextResponse.json( + { error: errorData.message }, + { status: response.status }, + ); + } + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/passport/signMessage/route.ts b/apps/web/app/api/passport/signMessage/route.ts new file mode 100644 index 000000000..f83d3b830 --- /dev/null +++ b/apps/web/app/api/passport/signMessage/route.ts @@ -0,0 +1,42 @@ +// api/passport/sign-message + +import { NextResponse } from "next/server"; + +export async function GET() { + const apiKey = process.env.GITCOIN_PASSPORT_API_KEY; + const endpoint = "https://api.scorer.gitcoin.co/registry/signing-message"; + + if (!apiKey) { + return NextResponse.json({ error: "API key is missing" }, { status: 500 }); + } + + try { + const response = await fetch(endpoint, { + method: "GET", + headers: { + "X-API-KEY": apiKey, + "Content-Type": "application/json", + }, + cache: "no-store", + }); + + console.info("SignMessage response ", response); + if (response.ok) { + const data = await response.json(); + console.info("DATA ", data); + return NextResponse.json(data, { status: 200 }); + } else { + const errorData = await response.json(); + return NextResponse.json( + { error: errorData.message }, + { status: response.status }, + ); + } + } catch (error) { + console.error(error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/passport/submitPassport/route.ts b/apps/web/app/api/passport/submitPassport/route.ts new file mode 100644 index 000000000..a1394f851 --- /dev/null +++ b/apps/web/app/api/passport/submitPassport/route.ts @@ -0,0 +1,61 @@ +// api/passport/submit-passport + +import { NextResponse } from "next/server"; + +interface PassportData { + address: string; + signature: string; + nonce: string; +} + +export async function POST(request: Request) { + const apiKey = process.env.GITCOIN_PASSPORT_API_KEY; + const scorerId = process.env.SCORER_ID; + const endpoint = "https://api.scorer.gitcoin.co/registry/submit-passport"; + + if (!apiKey) { + return NextResponse.json({ error: "API key is missing" }, { status: 500 }); + } + + let requestBody: PassportData; + try { + requestBody = await request.json(); + } catch (error) { + console.error("Error parsing JSON:", error); + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { address } = requestBody; + + if (!address) { + return NextResponse.json({ error: "Address is required" }, { status: 400 }); + } + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-KEY": apiKey, + }, + body: JSON.stringify({ address, scorer_id: scorerId }), + }); + + if (response.ok) { + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } else { + const errorData = await response.json(); + return NextResponse.json( + { error: errorData.statusText }, + { status: response.status }, + ); + } + } catch (error) { + console.error("Request error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/utils.ts b/apps/web/app/api/utils.ts new file mode 100644 index 000000000..11bb60b5d --- /dev/null +++ b/apps/web/app/api/utils.ts @@ -0,0 +1,12 @@ +export const HTTP_CODES = { + SUCCESS: 200, + NOT_FOUND: 404, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_ALLOWED: 405, + CONFLICT: 409, + TOO_MANY_REQUESTS: 429, + SERVER_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +}; diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico new file mode 100644 index 000000000..edbe550c1 Binary files /dev/null and b/apps/web/app/favicon.ico differ diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx new file mode 100644 index 000000000..51275cc6d --- /dev/null +++ b/apps/web/app/global-error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; + +export default function GloblError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 72fa1cfca..511b46118 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,29 +1,22 @@ import "@/styles/globals.css"; import React from "react"; import { Chakra_Petch, Inter } from "next/font/google"; -import Providers from "@/providers/Providers"; -import { Metadata } from "next"; import { Bounce, ToastContainer } from "react-toastify"; - +import Providers from "@/providers/Providers"; import "react-toastify/dist/ReactToastify.css"; const inter = Inter({ variable: "--font-inter", subsets: ["latin"], - weight: ["400", "500", "600"], + weight: ["400", "500", "600", "700"], }); const chakra = Chakra_Petch({ variable: "--font-chakra", subsets: ["latin"], - weight: ["400", "500", "700"], + weight: ["400", "500", "600", "700"], }); -const metadata: Metadata = { - title: "Gardens v2", -// description: "Gardens description...", -}; - export default function RootLayout({ children, }: { @@ -33,11 +26,15 @@ export default function RootLayout({ - - {children} + +