diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 82a080a6..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,31 +0,0 @@ -# https://turbo.build/repo/docs/ci/github-actions - -name: Build - -on: [pull_request] - -jobs: - build: - name: Build - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v3 - with: - fetch-depth: 2 - - - name: Setup Node.js environment - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: "yarn" - - - name: Install yarn - run: npm install -g yarn - - - name: Install dependencies - run: yarn - - - name: Build - run: yarn build diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml deleted file mode 100644 index 7c83b05a..00000000 --- a/.github/workflows/format-check.yml +++ /dev/null @@ -1,31 +0,0 @@ -# https://turbo.build/repo/docs/ci/github-actions - -name: Format Check - -on: [pull_request] - -jobs: - format-check: - name: Format Check - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v3 - with: - fetch-depth: 2 - - - name: Setup Node.js environment - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: "yarn" - - - name: Install yarn - run: npm install -g yarn - - - name: Install dependencies - run: yarn - - - name: Check file formatting - run: yarn format:check diff --git a/.github/workflows/gh-pages-council.yml b/.github/workflows/gh-pages-council.yml index b8e23555..79d18ef5 100644 --- a/.github/workflows/gh-pages-council.yml +++ b/.github/workflows/gh-pages-council.yml @@ -8,6 +8,9 @@ on: # Runs on pushes targeting the default branch push: branches: ["main"] + paths: + - "apps/council-ui/**" + - ".github/workflows/deploy-council-ui-to-gh-pages.yml" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 1405f1a3..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,31 +0,0 @@ -# https://turbo.build/repo/docs/ci/github-actions - -name: Lint - -on: [pull_request] - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v3 - with: - fetch-depth: 2 - - - name: Setup Node.js environment - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: "yarn" - - - name: Install yarn - run: npm install -g yarn - - - name: Install dependencies - run: yarn - - - name: Lint files - run: yarn lint diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 00000000..e2c9a118 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,36 @@ +# https://turbo.build/repo/docs/ci/github-actions + +name: Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + matrix: + tasks: [lint, build, typecheck, test] + + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "yarn" + + - name: Install dependencies + run: yarn --frozen-lockfile + + - name: Run ${{ matrix.tasks }} + run: yarn ${{ matrix.tasks }} diff --git a/.gitignore b/.gitignore index a5b73284..8c87bb8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules +dist .turbo .next -.DS_Store -.parcel-cache \ No newline at end of file +.DS_Store \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..25bf17fc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..138d0a5c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +node_modules +dist +.DS_Store +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock +vite.config.*.timestamp-* diff --git a/packages/prettier-config/index.js b/.prettierrc.cjs similarity index 73% rename from packages/prettier-config/index.js rename to .prettierrc.cjs index cb15c569..a82b039f 100644 --- a/packages/prettier-config/index.js +++ b/.prettierrc.cjs @@ -1,4 +1,5 @@ module.exports = { + plugins: ["prettier-plugin-organize-imports"], tabWidth: 2, useTabs: false, trailingComma: "all", diff --git a/.vscode/settings.json b/.vscode/settings.json index 814942fc..0ae5d428 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,17 @@ "url": "./packages/council-deploy/src/deployments/deployments.schema.json" } ], - "markdown.extension.toc.levels": "2..6" + "markdown.extension.toc.levels": "2..6", + "cSpell.words": [ + "abitype", + "calldatas", + "Delegators", + "delvtech", + "merkle", + "Timelock", + "tsup", + "typecheck", + "unvested", + "Viem" + ] } \ No newline at end of file diff --git a/README.md b/README.md index 6a4afa70..90743a0f 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ This monorepo uses [Yarn](https://classic.yarnpkg.com/) as a package manager. It | Name | Description | | ----------------------------------------------------------------------------------------------- | ------------------------------------ | -| [eslint-config](https://github.com/delvtech/council-kit/tree/main/packages/eslint-config) | Package for static type checking. | +| [@council/eslint-config](https://github.com/delvtech/council-kit/tree/main/packages/@council/eslint-config) | Package for static type checking. | | [prettier-config](https://github.com/delvtech/council-kit/tree/main/packages/prettier-config) | Package for code formatting. | -| [tsconfig](https://github.com/delvtech/council-kit/tree/main/packages/tsconfig) | Package for TypeScript configuation. | +| [@council/tsconfig](https://github.com/delvtech/council-kit/tree/main/packages/tsconfig) | Package for TypeScript configuation. | Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). diff --git a/apps/council-sdk-starter/.env.example b/apps/council-sdk-starter/.env.example index d7212656..884bbeb7 100644 --- a/apps/council-sdk-starter/.env.example +++ b/apps/council-sdk-starter/.env.example @@ -1,2 +1,2 @@ -PROVIDER_URI= -WALLET_PRIVATE_KEY= \ No newline at end of file +RPC_URL= +WALLET_PRIVATE_KEY= diff --git a/apps/council-sdk-starter/.prettierignore b/apps/council-sdk-starter/.prettierignore deleted file mode 100644 index 53c37a16..00000000 --- a/apps/council-sdk-starter/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -dist \ No newline at end of file diff --git a/apps/council-sdk-starter/.prettierrc.js b/apps/council-sdk-starter/.prettierrc.js deleted file mode 100644 index 63336b33..00000000 --- a/apps/council-sdk-starter/.prettierrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - ...require("@council/prettier-config"), - plugins: [require("prettier-plugin-organize-imports")], -}; diff --git a/apps/council-sdk-starter/package.json b/apps/council-sdk-starter/package.json index d255e048..c97ba831 100644 --- a/apps/council-sdk-starter/package.json +++ b/apps/council-sdk-starter/package.json @@ -4,39 +4,25 @@ "description": "Boilerplate to get started using the council-sdk fast!", "license": "MIT", "private": true, - "main": "dist/index.js", - "types": "dist/types.d.ts", - "source": "src/index.ts", - "alias": { - "src": "./src" - }, - "files": [ - "dist" - ], + "type": "module", "scripts": { - "watch": "parcel watch", - "build": "parcel build", - "createProposal": "ts-node -r dotenv/config -r tsconfig-paths/register src/scripts/createProposal.ts", - "executeProposal": "ts-node -r dotenv/config -r tsconfig-paths/register src/scripts/executeProposal.ts", - "setLockDuration": "ts-node -r dotenv/config -r tsconfig-paths/register src/scripts/setLockDuration.ts", - "changeVaultStatus": "ts-node -r dotenv/config -r tsconfig-paths/register src/scripts/changeVaultStatus.ts", - "getGSCMembers": "ts-node -r dotenv/config -r tsconfig-paths/register src/scripts/getGSCMembers.ts", - "getProposalResults": "ts-node -r dotenv/config -r tsconfig-paths/register src/scripts/getProposalResults.ts" + "createProposal": "tesm-node -r dotenv/config src/scripts/createProposal.ts", + "executeProposal": "tesm-node -r dotenv/config src/scripts/executeProposal.ts", + "setLockDuration": "tesm-node -r dotenv/config src/scripts/setLockDuration.ts", + "changeVaultStatus": "tesm-node -r dotenv/config src/scripts/changeVaultStatus.ts", + "getGSCMembers": "tesm-node -r dotenv/config ./src/scripts/getGSCMembers.ts", + "getProposalResults": "tesm-node -r dotenv/config src/scripts/getProposalResults.ts" }, "dependencies": { - "@council/deploy": "*", - "@council/sdk": "*", - "@council/typechain": "*", - "ethers": "^5.7.2" + "@delvtech/council-viem": "*", + "viem": "^2.7.12" }, "devDependencies": { - "@council/prettier-config": "*", "@council/tsconfig": "*", - "dotenv": "^16.0.3", - "parcel": "^2.8.0", - "prettier-plugin-organize-imports": "^3.2.2", - "ts-node": "^10.9.1", - "tsconfig-paths": "^4.1.0", - "typescript": "^4.7.4" + "@types/node": "^20.11.19", + "dotenv": "^16.4.5", + "tesm-node": "^1.3.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" } } diff --git a/apps/council-sdk-starter/src/addresses/ElementMainnetAddressList.ts b/apps/council-sdk-starter/src/addresses/ElementMainnetAddressList.ts new file mode 100644 index 00000000..242624c6 --- /dev/null +++ b/apps/council-sdk-starter/src/addresses/ElementMainnetAddressList.ts @@ -0,0 +1,12 @@ +export const elementAddresses = { + airdrop: "0xd04a459FFD3A5E3C93d5cD8BB13d26a9845716c2", + coreVoting: "0xEaCD577C3F6c44C3ffA398baaD97aE12CDCFed4a", + elementToken: "0x5c6D51ecBA4D8E4F20373e3ce96a62342B125D6d", + gscCoreVoting: "0x40309f197e7f94B555904DF0f788a3F48cF326aB", + gscVault: "0xcA870E8aa4FCEa85b5f0c6F4209C8CBA9265B940", + lockingVault: "0x02Bd4A3b1b95b01F2Aa61655415A5d3EAAcaafdD", + spender: "0xDa2Baf34B5717b257e52039f78d02B9C58751781", + timeLock: "0x81758f3361A769016eae4844072FA6d7f828a651", + treasury: "0x82eF450FB7f06E3294F2f19ed1713b255Af0f541", + vestingVault: "0x6De73946eab234F1EE61256F10067D713aF0e37A", +} as const; diff --git a/apps/council-sdk-starter/src/addresses/elementAddresses.ts b/apps/council-sdk-starter/src/addresses/elementAddresses.ts deleted file mode 100644 index b824035b..00000000 --- a/apps/council-sdk-starter/src/addresses/elementAddresses.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { goerliDeployments } from "@council/deploy"; -import mainnetAddressList from "src/addresses/ElementMainnetAddressList.json"; -import { provider } from "src/provider"; - -const { contracts: goerliContracts } = - goerliDeployments[goerliDeployments.length - 1]; - -// Find the deployed contract addresses. These are safe to cast as strings -// because we know the deployment contains these contracts in the -// @council/deploy project. -const goerliAddresses = { - timeLock: goerliContracts.find(({ name }) => name === "Timelock") - ?.address as string, - coreVoting: goerliContracts.find(({ name }) => name === "CoreVoting") - ?.address as string, - lockingVault: goerliContracts.find(({ name }) => name === "LockingVaultProxy") - ?.address as string, - vestingVault: goerliContracts.find(({ name }) => name === "VestingVaultProxy") - ?.address as string, - gscCoreVoting: goerliContracts.find(({ name }) => name === "GSCCoreVoting") - ?.address as string, - gscVault: goerliContracts.find(({ name }) => name === "GSCVault") - ?.address as string, -}; - -type Addresses = typeof mainnetAddressList.addresses | typeof goerliAddresses; - -export async function getElementAddress(): Promise { - const { chainId } = await provider.getNetwork(); - return chainId === 5 ? goerliAddresses : mainnetAddressList.addresses; -} diff --git a/apps/council-sdk-starter/src/client.ts b/apps/council-sdk-starter/src/client.ts new file mode 100644 index 00000000..d1075462 --- /dev/null +++ b/apps/council-sdk-starter/src/client.ts @@ -0,0 +1,20 @@ +import { + PublicClient, + createPublicClient, + createWalletClient, + http, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +export const publicClient: PublicClient = createPublicClient({ + transport: http(process.env.RPC_URL), +}); + +export const walletClient = + process.env.WALLET_PRIVATE_KEY && + createWalletClient({ + account: privateKeyToAccount( + process.env.WALLET_PRIVATE_KEY as `0x${string}`, + ), + transport: http(process.env.RPC_URL), + }); diff --git a/apps/council-sdk-starter/src/provider.ts b/apps/council-sdk-starter/src/provider.ts deleted file mode 100644 index 1121a117..00000000 --- a/apps/council-sdk-starter/src/provider.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { getDefaultProvider } from "ethers"; - -const defaultChainId = 1; // mainnet chain id - -// get provider using the PROVIDER_URI environment variable. Fallback to a -// default Goerli provider if no environment variable is found. -export const provider = getDefaultProvider( - process.env.PROVIDER_URI || defaultChainId, -); diff --git a/apps/council-sdk-starter/src/scripts/changeVaultStatus.ts b/apps/council-sdk-starter/src/scripts/changeVaultStatus.ts index e14a9fb4..1c800043 100644 --- a/apps/council-sdk-starter/src/scripts/changeVaultStatus.ts +++ b/apps/council-sdk-starter/src/scripts/changeVaultStatus.ts @@ -1,30 +1,28 @@ -import { CouncilContext, VotingContract } from "@council/sdk"; -import { Wallet } from "ethers"; -import { getElementAddress } from "src/addresses/elementAddresses"; -import { provider } from "src/provider"; +import { ReadWriteCouncil } from "@delvtech/council-viem"; +import { publicClient, walletClient } from "src/client"; // wrap the script in an async function so we can await promises export async function changeVaultStatus(): Promise { - const addresses = await getElementAddress(); - - // create a CouncilContext instance - const context = new CouncilContext(provider); - - // Create a VotingContract instance. - // The vaults array can be left empty since we won't be fetching any voting - // power data. - const coreVoting = new VotingContract(addresses.coreVoting, [], context); - - // create a signer for the proposal transaction - const signer = new Wallet(process.env.WALLET_PRIVATE_KEY as string, provider); - - const tx = await coreVoting.changeVaultStatus( - signer, - addresses.lockingVault, - true, - ); - - console.log(`Executed! (${tx})`); + if (!walletClient) { + throw new Error( + "Wallet client not available. Ensure the WALLET_PRIVATE_KEY environment variable is set.", + ); + } + + // create a ReadWriteCouncil instance + const council = new ReadWriteCouncil({ publicClient, walletClient }); + + // Create a ReadWriteCoreVoting instance. + const coreVoting = council.coreVoting({ + address: "0x", // <-- replace with the CoreVoting contract address + }); + + const hash = await coreVoting.changeVaultStatus({ + vault: "0x", // <-- replace with the vault address + isValid: true, + }); + + console.log(`Submitted! (${hash})`); process.exit(); } diff --git a/apps/council-sdk-starter/src/scripts/createProposal.ts b/apps/council-sdk-starter/src/scripts/createProposal.ts index 688e5a9e..4ec8bab8 100644 --- a/apps/council-sdk-starter/src/scripts/createProposal.ts +++ b/apps/council-sdk-starter/src/scripts/createProposal.ts @@ -1,87 +1,76 @@ -import { - CouncilContext, - LockingVault, - VestingVault, - VotingContract, -} from "@council/sdk"; -import { CoreVoting__factory } from "@council/typechain"; -import { BigNumber, utils, Wallet } from "ethers"; -import { getElementAddress } from "src/addresses/elementAddresses"; -import { provider } from "src/provider"; +import { ReadWriteCouncil } from "@delvtech/council-viem"; +import { publicClient, walletClient } from "src/client"; // approx 90 days in blocks assuming 12 seconds a block -const NINETY_DAYS_IN_BLOCKS = (90 * 24 * 60 * 60) / 12; +const FOURTEEN_DAYS_IN_BLOCKS = (14n * 24n * 60n * 60n) / 12n; // wrap the script in an async function so we can await promises export async function createProposal(): Promise { - const addresses = await getElementAddress(); + if (!walletClient) { + throw new Error( + "Wallet client not available. Ensure the WALLET_PRIVATE_KEY environment variable is set.", + ); + } - // create a CouncilContext instance - const context = new CouncilContext(provider); + // create a ReadWriteCouncil instance + const council = new ReadWriteCouncil({ publicClient, walletClient }); // create model instances - const lockingVault = new LockingVault(addresses.lockingVault, context); - const vestingVault = new VestingVault(addresses.vestingVault, context); - const coreVoting = new VotingContract( - addresses.coreVoting, - [lockingVault, vestingVault], - context, - ); - - // create a signer for the proposal transaction - const signer = new Wallet(process.env.WALLET_PRIVATE_KEY as string, provider); + const lockingVault = council.lockingVault("0x"); // <-- replace with the LockingVault contract address + const vestingVault = council.vestingVault("0x"); // <-- replace with the VestingVault contract address + const coreVoting = council.coreVoting({ + address: "0x", // <-- replace with the CoreVoting contract address + vaults: [lockingVault, vestingVault], + }); // prep arguments // the vaults that will be used to cast the first vote const vaults = []; + const account = (await walletClient.getAddresses())[0]; // trying to create a proposal with vaults you have no power in will throw an // uninitialized error. - const lockingVaultVotingPower = await lockingVault.getVotingPower( - signer.address, - ); - if (+lockingVaultVotingPower > 0) { + const lockingVaultVotingPower = await lockingVault.getVotingPower({ + account, + }); + if (lockingVaultVotingPower > 0n) { vaults.push(lockingVault); } - const vestingVaultVotingPower = await vestingVault.getVotingPower( - signer.address, - ); - if (+vestingVaultVotingPower > 0) { + const vestingVaultVotingPower = await vestingVault.getVotingPower({ + account, + }); + if (vestingVaultVotingPower > 0) { vaults.push(vestingVault); } // the target contract addresses for the proposal const targets = [coreVoting.address]; - // get the core voting contract abi to encode call data - const coreVotingInterface = new utils.Interface(CoreVoting__factory.abi); - // the proposed calls datas to send to the targets const calldatas = [ - coreVotingInterface.encodeFunctionData("setDefaultQuorum", [ - BigNumber.from(10), - ]), + coreVoting.contract.encodeFunctionData("setDefaultQuorum", { + quorum: 100n, + }), ]; - const currentBlock = await provider.getBlockNumber(); + const currentBlock = await publicClient.getBlockNumber(); // the block number after which the proposal can no longer be executed - const lastCall = currentBlock + NINETY_DAYS_IN_BLOCKS; + const lastCall = currentBlock + FOURTEEN_DAYS_IN_BLOCKS; // the ballot to cast for the first vote const ballot = "yes"; - const tx = await coreVoting.createProposal( - signer, - vaults, - targets, + const hash = await coreVoting.createProposal({ + ballot, calldatas, lastCall, - ballot, - ); + targets, + vaults, + }); - console.log(tx); + console.log(hash); process.exit(); } diff --git a/apps/council-sdk-starter/src/scripts/executeProposal.ts b/apps/council-sdk-starter/src/scripts/executeProposal.ts index 37fdf04d..16a091b3 100644 --- a/apps/council-sdk-starter/src/scripts/executeProposal.ts +++ b/apps/council-sdk-starter/src/scripts/executeProposal.ts @@ -1,31 +1,28 @@ -import { CouncilContext, VotingContract } from "@council/sdk"; -import { Wallet } from "ethers"; -import { getElementAddress } from "src/addresses/elementAddresses"; -import { provider } from "src/provider"; +import { ReadWriteCouncil } from "@delvtech/council-viem"; +import { publicClient, walletClient } from "src/client"; // wrap the script in an async function so we can await promises export async function getProposalResults(): Promise { - const addresses = await getElementAddress(); - - // create a CouncilContext instance - const context = new CouncilContext(provider); - - // Create a VotingContract instance. - // The vaults array can be left empty since we won't be fetching any voting - // power data. - const coreVoting = new VotingContract(addresses.coreVoting, [], context); - - // create a signer for the proposal transaction - const signer = new Wallet(process.env.WALLET_PRIVATE_KEY as string, provider); + if (!walletClient) { + throw new Error( + "Wallet client not available. Ensure the WALLET_PRIVATE_KEY environment variable is set.", + ); + } + + // create a ReadWriteCouncil instance + const council = new ReadWriteCouncil({ publicClient, walletClient }); + + // Create a ReadWriteCoreVoting instance. + const coreVoting = council.coreVoting({ + address: "0x", // <-- replace with the CoreVoting contract address + }); // get the proposal to be executed - const proposal = await coreVoting.getProposal(0); + const proposal = await coreVoting.getProposal({ id: 0n }); - const tx = await proposal.execute(signer, { - onSubmitted: (tx) => console.log(`Executing... (${tx})`), - }); + const hash = await proposal?.execute(); - console.log(`Executed! (${tx})`); + console.log(`Execution transaction submitted! (${hash})`); process.exit(); } diff --git a/apps/council-sdk-starter/src/scripts/getGSCMembers.ts b/apps/council-sdk-starter/src/scripts/getGSCMembers.ts index 863ad2e5..02c586cd 100644 --- a/apps/council-sdk-starter/src/scripts/getGSCMembers.ts +++ b/apps/council-sdk-starter/src/scripts/getGSCMembers.ts @@ -1,16 +1,20 @@ -import { CouncilContext, GSCVault } from "@council/sdk"; -import { getElementAddress } from "src/addresses/elementAddresses"; -import { provider } from "src/provider"; +import { ReadWriteCouncil } from "@delvtech/council-viem"; +import { elementAddresses } from "src/addresses/ElementMainnetAddressList"; +import { publicClient, walletClient } from "src/client"; // wrap the script in an async function so we can await promises export async function getGSCMembers(): Promise { - const addresses = await getElementAddress(); + if (!walletClient) { + throw new Error( + "Wallet client not available. Ensure the WALLET_PRIVATE_KEY environment variable is set.", + ); + } - // create a CouncilContext instance - const context = new CouncilContext(provider); + // create a ReadWriteCouncil instance + const council = new ReadWriteCouncil({ publicClient, walletClient }); - // create a GSCVault instance - const gscVault = new GSCVault(addresses.gscVault, context); + // create a ReadGscVault instance + const gscVault = council.gscVault(elementAddresses.gscVault); // <-- replace with the LockingVault contract address // get all members const members = await gscVault.getMembers(); @@ -20,14 +24,16 @@ export async function getGSCMembers(): Promise { for (const member of members) { console.log("fetching", member.address); - // get the voting vaults used to prove the member meets the minimum voting - // power requirement - const votingPowerVaults = await gscVault.getMemberVaults(member.address); + // get the voting vaults that were used to prove the member meets the + // minimum voting power requirement + const votingPowerVaults = await gscVault.getMemberVaults({ + account: member.address, + }); memberStats.push({ address: member.address, - joinDate: await gscVault.getJoinDate(member.address), - votingPower: await member.getVotingPower(votingPowerVaults), + joinDate: await gscVault.getJoinDate({ account: member.address }), + votingPower: await member.getVotingPower({ vaults: votingPowerVaults }), }); } diff --git a/apps/council-sdk-starter/src/scripts/getProposalResults.ts b/apps/council-sdk-starter/src/scripts/getProposalResults.ts index 4226c443..54536d8f 100644 --- a/apps/council-sdk-starter/src/scripts/getProposalResults.ts +++ b/apps/council-sdk-starter/src/scripts/getProposalResults.ts @@ -1,18 +1,22 @@ -import { CouncilContext, VoteResults, VotingContract } from "@council/sdk"; -import { getElementAddress } from "src/addresses/elementAddresses"; -import { provider } from "src/provider"; +import { ReadWriteCouncil, VoteResults } from "@delvtech/council-viem"; +import { elementAddresses } from "src/addresses/ElementMainnetAddressList"; +import { publicClient, walletClient } from "src/client"; // wrap the script in an async function so we can await promises export async function getProposalResults(): Promise { - const addresses = await getElementAddress(); + if (!walletClient) { + throw new Error( + "Wallet client not available. Ensure the WALLET_PRIVATE_KEY environment variable is set.", + ); + } - // create a CouncilContext instance - const context = new CouncilContext(provider); + // create a ReadWriteCouncil instance + const council = new ReadWriteCouncil({ publicClient, walletClient }); - // Create a VotingContract instance. - // The vaults array can be left empty since we won't be fetching any voting - // power data. - const coreVoting = new VotingContract(addresses.coreVoting, [], context); + // Create a ReadWriteCoreVoting instance. + const coreVoting = council.coreVoting({ + address: elementAddresses.coreVoting, // <-- replace with the CoreVoting contract address + }); // get all proposals const proposals = await coreVoting.getProposals(); @@ -20,7 +24,7 @@ export async function getProposalResults(): Promise { // get results for all proposals and key them by id in a new object const resultsByProposalId: Record = {}; for (const proposal of proposals) { - resultsByProposalId[proposal.id] = await proposal.getResults(); + resultsByProposalId[Number(proposal.id)] = await proposal.getResults(); } console.table(resultsByProposalId); diff --git a/apps/council-sdk-starter/src/scripts/setLockDuration.ts b/apps/council-sdk-starter/src/scripts/setLockDuration.ts index 0c90124d..db193376 100644 --- a/apps/council-sdk-starter/src/scripts/setLockDuration.ts +++ b/apps/council-sdk-starter/src/scripts/setLockDuration.ts @@ -1,28 +1,29 @@ -import { CouncilContext, VotingContract } from "@council/sdk"; -import { Wallet } from "ethers"; -import { getElementAddress } from "src/addresses/elementAddresses"; -import { provider } from "src/provider"; +import { ReadWriteCouncil } from "@delvtech/council-viem"; +import { publicClient, walletClient } from "src/client"; // wrap the script in an async function so we can await promises -export async function getProposalResults(): Promise { - const addresses = await getElementAddress(); +export async function setLockDuration(): Promise { + if (!walletClient) { + throw new Error( + "Wallet client not available. Ensure the WALLET_PRIVATE_KEY environment variable is set.", + ); + } - // create a CouncilContext instance - const context = new CouncilContext(provider); + // create a ReadWriteCouncil instance + const council = new ReadWriteCouncil({ publicClient, walletClient }); - // Create a VotingContract instance. - // The vaults array can be left empty since we won't be fetching any voting - // power data. - const coreVoting = new VotingContract(addresses.coreVoting, [], context); + // Create a ReadWriteCoreVoting instance. + const coreVoting = council.coreVoting({ + address: "0x", // <-- replace with the CoreVoting contract address + }); - // create a signer for the proposal transaction - const signer = new Wallet(process.env.WALLET_PRIVATE_KEY as string, provider); + const hash = await coreVoting.setLockDuration({ + blocks: 0n, // <-- replace with the number of blocks to lock + }); - const tx = await coreVoting.setLockDuration(signer, 0); - - console.log(`Executed! (${tx})`); + console.log(`Submitted! (${hash})`); process.exit(); } -getProposalResults(); +setLockDuration(); diff --git a/apps/council-sdk-starter/tsconfig.json b/apps/council-sdk-starter/tsconfig.json index 38697782..6af24742 100644 --- a/apps/council-sdk-starter/tsconfig.json +++ b/apps/council-sdk-starter/tsconfig.json @@ -5,9 +5,9 @@ "compilerOptions": { "rootDir": ".", "baseUrl": ".", - "paths": { - "src/*": ["src/*"] - }, - "resolveJsonModule": true + "resolveJsonModule": true, + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "bundler" } } diff --git a/apps/council-ui/.env.sample b/apps/council-ui/.env.sample index 41ec8e68..fa41c240 100644 --- a/apps/council-ui/.env.sample +++ b/apps/council-ui/.env.sample @@ -1,12 +1,15 @@ -# Alchemy id for the rpc provider of your choice -NEXT_PUBLIC_MAINNET_ALCHEMY_KEY= -NEXT_PUBLIC_GOERLI_ALCHEMY_KEY= +# URL for the rpc provider of your choice +NEXT_PUBLIC_MAINNET_RPC_URL= +NEXT_PUBLIC_GOERLI_RPC_URL= # Intentionally blank for local development. This lets you build the app using # `next export` and serve it locally from the ./out directory. If serving from a # subdirectory (eg: GH Pages), you'll want to set this to your subdirectory # path, ie: `/council-monorepo` for a github repo called council-monorepo. -NEXT_PUBLIC_COUNCIL_UI_BASE_PATH= +# NEXT_PUBLIC_COUNCIL_UI_BASE_PATH= # If running a local node, use this instead of the alchemy id -NEXT_PUBLIC_LOCAL_RPC_URL= \ No newline at end of file +NEXT_PUBLIC_LOCAL_RPC_URL=http://127.0.0.1:8545 + +# WalletConnect integration w/ RainbowKit +NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= \ No newline at end of file diff --git a/apps/council-ui/.prettierrc.cjs b/apps/council-ui/.prettierrc.cjs new file mode 100644 index 00000000..da6a83a3 --- /dev/null +++ b/apps/council-ui/.prettierrc.cjs @@ -0,0 +1,9 @@ +module.exports = { + plugins: ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], + tabWidth: 2, + useTabs: false, + trailingComma: "all", + singleQuote: false, + semi: true, + printWidth: 80, +}; diff --git a/apps/council-ui/.prettierrc.js b/apps/council-ui/.prettierrc.js deleted file mode 100644 index 4a902ca3..00000000 --- a/apps/council-ui/.prettierrc.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - ...require("@council/prettier-config"), - plugins: [ - require("prettier-plugin-tailwindcss"), - require("prettier-plugin-organize-imports"), - ], - tailwindConfig: "./tailwind.config.js", -}; diff --git a/apps/council-ui/package.json b/apps/council-ui/package.json index 63eb4b43..5bd8739c 100644 --- a/apps/council-ui/package.json +++ b/apps/council-ui/package.json @@ -8,57 +8,54 @@ "export": "next export", "start": "next start", "lint": "next lint", - "format": "prettier --write '**/*.{gql,graphql,js,jsx,ts,tsx,json,md,yaml,yml}'", - "format:check": "prettier --check '**/*.{gql,graphql,js,jsx,ts,tsx,json,md}'" + "typecheck": "tsc --noEmit" }, "dependencies": { - "@council/deploy": "*", - "@council/sdk": "*", - "@ensdomains/ensjs": "^3.0.0-alpha.37", - "@heroicons/react": "^2.0.13", + "@delvtech/council-artifacts": "*", + "@delvtech/council-viem": "*", + "@ensdomains/ensjs": "^3.4.4", + "@heroicons/react": "^2.1.1", "@metamask/jazzicon": "^2.0.0", - "@pushprotocol/restapi": "^1.2.10", - "@rainbow-me/rainbowkit": "^0.7.4", - "@tailwindcss/line-clamp": "^0.4.2", - "@tanstack/react-query": "^4.16.1", - "@types/lodash.chunk": "^4.2.7", + "@pushprotocol/restapi": "^1.6.7", + "@rainbow-me/rainbowkit": "^2.0.0", + "@tailwindcss/line-clamp": "^0.4.4", + "@tanstack/react-query": "^5.22.2", "assert-never": "^1.2.1", - "classnames": "^2.3.2", + "classnames": "^2.5.1", "d3-format": "^3.1.0", - "daisyui": "^2.33.0", - "date-fns": "^2.29.3", - "ethers": "^5.7.2", - "fuse.js": "^6.6.2", - "listr2": "^5.0.6", + "daisyui": "^4.7.2", + "date-fns": "^3.3.1", + "ethers": "^6.11.1", + "fuse.js": "^7.0.0", + "listr2": "^8.0.2", "lodash.chunk": "^4.2.0", "next": "13.0.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-hot-toast": "^2.4.0", - "react-loading-skeleton": "^3.1.0", - "react-tooltip": "^5.7.0", - "wagmi": "^0.7.7" + "react-hot-toast": "^2.4.1", + "react-loading-skeleton": "^3.4.0", + "react-tooltip": "^5.26.0", + "viem": "^2.7.12", + "wagmi": "^2.5.7" }, "devDependencies": { - "@babel/core": "^7.0.0", + "@babel/core": "^7.23.9", "@council/eslint-config": "*", - "@council/prettier-config": "*", "@council/tsconfig": "*", - "@tailwindcss/typography": "^0.5.8", - "@tanstack/eslint-plugin-query": "^4.15.1", - "@types/d3-format": "^3.0.1", - "@types/node": "^17.0.12", - "@types/react": "18.0.17", - "autoprefixer": "^10.4.13", - "eslint": "7.32.0", - "eslint-config-next": "^13.0.1", - "eslint-plugin-react": "^7.31.10", - "eslint-plugin-tailwindcss": "^3.6.2", - "next-transpile-modules": "9.0.0", - "postcss": "^8.4.18", - "prettier-plugin-organize-imports": "^3.1.1", - "prettier-plugin-tailwindcss": "^0.1.13", - "tailwindcss": "^3.2.1", - "typescript": "^4.5.3" + "@tailwindcss/typography": "^0.5.10", + "@tanstack/eslint-plugin-query": "^5.20.1", + "@types/d3-format": "^3.0.4", + "@types/lodash.chunk": "^4.2.9", + "@types/node": "^20.11.19", + "@types/react": "18.2.57", + "autoprefixer": "^10.4.17", + "eslint": "8.56.0", + "eslint-config-next": "^14.1.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-tailwindcss": "^3.14.3", + "next-transpile-modules": "10.0.1", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" } } diff --git a/apps/council-ui/pages/_app.tsx b/apps/council-ui/pages/_app.tsx index f624cd03..2a7f0605 100644 --- a/apps/council-ui/pages/_app.tsx +++ b/apps/council-ui/pages/_app.tsx @@ -1,7 +1,6 @@ // disabling prettier organize imports plugin to preserve css import order // organize-imports-ignore import dynamic from "next/dynamic"; -import "react-tooltip/dist/react-tooltip.css"; import "@rainbow-me/rainbowkit/styles.css"; import "src/styles/globals.css"; import "react-loading-skeleton/dist/skeleton.css"; diff --git a/apps/council-ui/pages/airdrop/index.tsx b/apps/council-ui/pages/airdrop/index.tsx index 5204ac85..658c830a 100644 --- a/apps/council-ui/pages/airdrop/index.tsx +++ b/apps/council-ui/pages/airdrop/index.tsx @@ -1,20 +1,19 @@ -import { ReactElement, useEffect, useState } from "react"; - import { ConnectButton } from "@rainbow-me/rainbowkit"; import classNames from "classnames"; -import { constants } from "ethers"; +import { ReactElement, useEffect, useState } from "react"; import ClaimStep from "src/ui/airdrop/ClaimStep"; import ConfirmClaimStep from "src/ui/airdrop/ConfirmClaimStep"; import ConfirmDepositStep from "src/ui/airdrop/ConfirmDepositStep"; import DepositOrClaimStep from "src/ui/airdrop/DepositOrClaimStep"; import DepositStep from "src/ui/airdrop/DepositStep"; -import { useAirdropLockingVault } from "src/ui/airdrop/hooks/useAirdropLockingVault"; -import { useClaimableAirdropAmount } from "src/ui/airdrop/hooks/useClaimableAirdropAmount"; +import { useAirdropVault } from "src/ui/airdrop/hooks/useAirdropLockingVault"; import { useClaimAirdrop } from "src/ui/airdrop/hooks/useClaimAirdrop"; import { useClaimAndDelegateAirdrop } from "src/ui/airdrop/hooks/useClaimAndDelegateAirdrop"; -import useRouterSteps from "src/ui/router/useRouterSteps"; +import { useClaimableAirdropAmount } from "src/ui/airdrop/hooks/useClaimableAirdropAmount"; +import useRouterSteps from "src/ui/router/hooks/useRouterSteps"; import { useDelegate } from "src/ui/vaults/lockingVault/hooks/useDelegate"; -import { useAccount, useSigner } from "wagmi"; +import { zeroAddress } from "viem"; +import { useAccount } from "wagmi"; export default function AirdropPage(): ReactElement { // Utilities to help with routing between steps @@ -26,43 +25,38 @@ export default function AirdropPage(): ReactElement { ], }); - // Determine if the user has any claimable airdrop tokens - const { data: claimableAmount } = useClaimableAirdropAmount(); - // The address that will receive the airdrop - const [recipientAddress, setRecipientAddress] = useState(""); + const [recipientAddress, setRecipientAddress] = useState(); // The address to delegate to if the user chooses to deposit - const [delegateAddress, setDelegateAddress] = useState(""); + const [delegateAddress, setDelegateAddress] = useState(); // Determine if the user needs to choose a delegate - const { data: lockingVault } = useAirdropLockingVault(); - const { data: currentDelegate } = useDelegate( - lockingVault?.address, - recipientAddress, - ); + const { airdropVault } = useAirdropVault(); + const { delegate: currentDelegate } = useDelegate({ + account: recipientAddress as `0x${string}`, + vault: airdropVault, + }); const needsDelegate = - currentDelegate && currentDelegate.address === constants.AddressZero; + currentDelegate && currentDelegate.address === zeroAddress; // Set the recipient and delegate addresses to the connected wallet if they // haven't been set yet - const { address } = useAccount(); + const account = useAccount(); useEffect(() => { - setRecipientAddress((previousValue) => previousValue || address || ""); - setDelegateAddress((previousValue) => previousValue || address || ""); - }, [address]); - - const { data: signer } = useSigner(); - const { mutate: claimAndDelegate } = useClaimAndDelegateAirdrop(); - const { mutate: claim } = useClaimAirdrop(); + setRecipientAddress((previousValue) => previousValue || account.address); + setDelegateAddress((previousValue) => previousValue || account.address); + }, [account.address]); - const hasClaimableAmount = !!claimableAmount && !!+claimableAmount; + const { claimableAmount } = useClaimableAirdropAmount(); + const { claimAirdrop } = useClaimAirdrop(); + const { claimAndDelegateAirdrop } = useClaimAndDelegateAirdrop(); return ( -
+

Airdrop Claim

-
    +
{(() => { - if (!address) { + if (!account) { return (
@@ -113,16 +107,15 @@ export default function AirdropPage(): ReactElement { case "confirm-deposit": return ( goToStep("deposit")} onConfirm={ - signer && claimableAmount && +claimableAmount + claimAndDelegateAirdrop ? () => - claimAndDelegate({ - signer, - recipient: recipientAddress, - delegate: delegateAddress, + claimAndDelegateAirdrop({ + recipient: recipientAddress as `0x${string}`, + delegate: delegateAddress as `0x${string}`, }) : undefined } @@ -140,14 +133,13 @@ export default function AirdropPage(): ReactElement { case "confirm-claim": return ( goToStep("claim")} onConfirm={ - signer && claimableAmount && +claimableAmount + claimAirdrop ? () => - claim({ - signer, - recipient: recipientAddress, + claimAirdrop({ + recipient: recipientAddress as `0x${string}`, }) : undefined } @@ -159,11 +151,9 @@ export default function AirdropPage(): ReactElement { return ( goToStep("deposit") : undefined - } - onClaim={ - hasClaimableAmount ? () => goToStep("claim") : undefined + claimableAmount ? () => goToStep("deposit") : undefined } + onClaim={claimableAmount ? () => goToStep("claim") : undefined} /> ); } diff --git a/apps/council-ui/pages/index.tsx b/apps/council-ui/pages/index.tsx index 85ce0bc4..6757f924 100644 --- a/apps/council-ui/pages/index.tsx +++ b/apps/council-ui/pages/index.tsx @@ -4,8 +4,8 @@ import { ReactElement } from "react"; export default function Index(): ReactElement { return (
-
-

+
+

Council is a decentralized governance protocol that allows a community to manage a DAO.

@@ -30,11 +30,11 @@ export default function Index(): ReactElement {

-
-
+
+
Get Started
-
+
Get up and running with Council with our quick start guides, protocol documentation, a fully customizable reference UI, a Javascript SDK, and fully open source code.{" "} @@ -51,16 +51,16 @@ export default function Index(): ReactElement {
-
-
+
+

Why use Council?

-

+

Council Protocol was created because there are no governance frameworks that exist today that meet the realistic needs of day-to-day and long-term governance.

-
+

Voting Vaults 🗳️

@@ -106,14 +106,14 @@ export default function Index(): ReactElement {
-
-
+
+

Council 🤝 DAOs

-
-
+
+

Yearn

yearn logo
-
-
+
+

Balancer

balancer logo
-
-
+
+

Synthetix

snx logo
-
-
-

+
+
+

Built with ❤️ and 🧠 by{" "} - Delve + Delve

Building Council has been an absolute pleasure and we couldn't diff --git a/apps/council-ui/pages/proposals/details.tsx b/apps/council-ui/pages/proposals/details.tsx index 730aafb5..f3800a1c 100644 --- a/apps/council-ui/pages/proposals/details.tsx +++ b/apps/council-ui/pages/proposals/details.tsx @@ -1,105 +1,78 @@ -import { Ballot, getBlockDate, Proposal, Vote } from "@council/sdk"; -import { useQuery } from "@tanstack/react-query"; +import { Ballot, ReadVote } from "@delvtech/council-viem"; +import { QueryStatus, useQuery } from "@tanstack/react-query"; import { useRouter } from "next/router"; import { ReactElement, useMemo, useState } from "react"; import Skeleton from "react-loading-skeleton"; -import { councilConfigs } from "src/config/council.config"; -import { EnsRecords, getBulkEnsRecords } from "src/ens/getBulkEnsRecords"; -import { - getProposalStatus, - ProposalStatus, -} from "src/proposals/getProposalStatus"; import { Routes } from "src/routes"; import { Breadcrumbs } from "src/ui/base/Breadcrumbs"; -import { ErrorMessage } from "src/ui/base/error/ErrorMessage"; -import ExternalLink from "src/ui/base/links/ExternalLink"; import { Page } from "src/ui/base/Page"; -import { useCouncil } from "src/ui/council/useCouncil"; -import { useChainId } from "src/ui/network/useChainId"; +import ExternalLink from "src/ui/base/links/ExternalLink"; +import { getBlockDate } from "src/ui/base/utils/getBlockDate"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadCoreVoting } from "src/ui/council/hooks/useReadCoreVoting"; +import { useReadGscVoting } from "src/ui/council/hooks/useReadGscVoting"; import { ProposalStatsRow } from "src/ui/proposals/ProposalStatsRow/ProposalStatsRow"; import { ProposalStatsRowSkeleton } from "src/ui/proposals/ProposalStatsRow/ProposalStatsRowSkeleton"; import { Quorum } from "src/ui/proposals/Quorum/Quorum"; import { QuorumBarSkeleton } from "src/ui/proposals/Quorum/QuorumSkeleton"; import { VotingActivityTable } from "src/ui/proposals/VotingActivityTable/VotingActivityTable"; import { VotingActivityTableSkeleton } from "src/ui/proposals/VotingActivityTable/VotingActivityTableSkeleton"; -import { useGSCMemberAddresses } from "src/ui/vaults/gscVault/useGSCMemberAddresses"; -import { useVotingPower } from "src/ui/vaults/hooks/useVotingPower"; -import { GSCOnlyToggle } from "src/ui/voters/GSCOnlyToggle"; -import { useGSCVote } from "src/ui/voting/hooks/useGSCVote"; -import { useVote } from "src/ui/voting/hooks/useVote"; +import { useReadProposal } from "src/ui/proposals/hooks/useReadProposal"; +import { useGscMembers } from "src/ui/vaults/gscVault/hooks/useGscMembers"; +import { GSCOnlyToggle } from "src/ui/voters/GscOnlyToggle"; import { ProposalVoting } from "src/ui/voting/ProposalVoting"; import { ProposalVotingSkeleton } from "src/ui/voting/ProposalVotingSkeleton"; -import { useAccount, useBlockNumber, useSigner } from "wagmi"; +import { EnsRecords, getBulkEnsRecords } from "src/utils/getBulkEnsRecords"; +import { ProposalStatus, getProposalStatus } from "src/utils/getProposalStatus"; +import { useAccount, usePublicClient } from "wagmi"; export default function ProposalPage(): ReactElement { const { query, replace } = useRouter(); const { id: idParam, votingContract: votingContractAddressParam } = query; - const id = +(idParam as string); - const votingContractAddress = votingContractAddressParam as string; + const id = BigInt((idParam as string) || 0); + const votingContractAddress = votingContractAddressParam as `0x${string}`; + + const account = useAccount(); - const { data: signer } = useSigner(); - const { address } = useAccount(); - const { data: blockNumber } = useBlockNumber(); + const coreVoting = useReadCoreVoting(); + const gscVoting = useReadGscVoting(); + const usedCoreVoting = + votingContractAddress === gscVoting?.address ? gscVoting : coreVoting; - const { data, error, status } = useProposalDetailsPageData( + const { data, status } = useProposalDetailsPageData( votingContractAddress, id, - address, + account.address, ); - const { data: gscMemberAddresses } = useGSCMemberAddresses(); + const { gscMembers } = useGscMembers(); - // voting activity filtering + // // voting activity filtering const [gscOnly, setGscOnly] = useState(false); const filteredVotes = useMemo(() => { - if (data && gscOnly && gscMemberAddresses) { + if (data?.votes && gscOnly && gscMembers) { return dedupeVotes(data.votes).filter(({ voter }) => - gscMemberAddresses.includes(voter.address), + gscMembers.some((member) => member.address === voter.address), ); } return dedupeVotes(data?.votes); - }, [data, gscOnly, gscMemberAddresses]); + }, [data, gscOnly, gscMembers]); - const { data: votingPower } = useVotingPower(address); - const { mutate: vote } = useVote(); - const { mutate: gscVote } = useGSCVote(); - - const handleVote = (ballot: Ballot) => { - if (!data || !signer) { - return; - } - const voteArgs = { - signer, - proposalId: id, - ballot, - }; - if (data.type === "gsc") { - return gscVote(voteArgs); - } - return vote(voteArgs); - }; - - const { coreVoting, gscVoting } = useCouncil(); - if ( - ![coreVoting.address, gscVoting?.address].includes(votingContractAddress) || - !votingContractAddressParam || - !idParam - ) { + // // Redirect to proposals page if the voting contract is not found. + if (!usedCoreVoting) { replace("/proposals"); // Returning empty fragment is to remove the undefined type from the query params. return <>; } - if (status === "error") { - return ; - } - const proposalTitle = data?.title ?? `Proposal ${id}`; + // return <>{status}; + return (

- {status === "loading" ? ( + {status === "pending" ? ( ) : ( )} -
-
-

- {status === "loading" ? ( - +
+
+

+ {status === "pending" ? ( + ) : ( proposalTitle )} @@ -124,9 +97,9 @@ export default function ProposalPage(): ReactElement {

- {status === "success" ? ( + {data ? ( @@ -137,7 +110,7 @@ export default function ProposalPage(): ReactElement {
- {status === "success" ? ( + {data ? ( )} -
+
- {status === "success" ? ( + {data ? ( data.paragraphSummary && (

{data.paragraphSummary}

) @@ -168,7 +141,7 @@ export default function ProposalPage(): ReactElement { Voting Activity {filteredVotes && `(${filteredVotes.length})`}

- {gscMemberAddresses && ( + {gscMembers && ( - {status === "success" && filteredVotes ? ( + {data && filteredVotes ? (

Your Vote

- {status === "success" ? ( + {data ? ( ) : ( @@ -211,133 +180,135 @@ export default function ProposalPage(): ReactElement { } interface ProposalDetailsPageData { + proposalExists: boolean; type: "core" | "gsc"; - votingContractName: string; status: ProposalStatus; isActive: boolean; - currentQuorum: string; - requiredQuorum: string | null; - createdAtBlock: number | null; - createdBy: string | null; - createdAtDate: Date | null; - createdTransactionHash: string | null; - endsAtDate: Date | null; - unlockedAtDate: Date | null; - lastCallAtDate: Date | null; - votes: Vote[]; - accountBallot?: Ballot; + votes?: ReadVote[]; voterEnsRecords: EnsRecords; - descriptionURL: string | null; + createdAtBlock: bigint; + currentQuorum: bigint; + createdTransactionHash?: `0x${string}`; + votingContractName?: string; + requiredQuorum?: bigint; + createdBy?: `0x${string}`; + createdAtDate?: Date; + endsAtDate?: Date; + unlockedAtDate?: Date; + lastCallDate?: Date; + accountBallot?: Ballot; + descriptionURL?: string; title?: string; - paragraphSummary: string | null; - executedTransactionHash: string | null; + paragraphSummary?: string; + executedTransactionHash?: `0x${string}`; } function useProposalDetailsPageData( - votingContractAddress?: string, - id?: number, - account?: string, -) { - const { context, coreVoting, gscVoting } = useCouncil(); - const provider = context.provider; - const chainId = useChainId(); - const proposalConfigs = councilConfigs[chainId].coreVoting.proposals; - const votingContractName = councilConfigs[chainId].coreVoting.name; - - const queryEnabled = votingContractAddress !== undefined && id !== undefined; - return useQuery({ - queryKey: ["proposalDetailsPage", id], - enabled: queryEnabled, - queryFn: queryEnabled + coreVotingAddress?: `0x${string}`, + id?: bigint, + account?: `0x${string}`, +): { + // proposalExists: boolean; + data: ProposalDetailsPageData | undefined; + status: QueryStatus; +} { + const gscVoting = useReadGscVoting(); + const config = useCouncilConfig(); + const client = usePublicClient(); + const { proposal } = useReadProposal({ + id, + coreVoting: coreVotingAddress, + }); + + const isGsc = coreVotingAddress === gscVoting?.address; + const votingConfig = isGsc ? config.gscVoting : config.coreVoting; + const votingContractName = votingConfig?.name; + + const enabled = !!proposal; + + const { data, status, error } = useQuery({ + queryKey: ["proposalDetailsPage", coreVotingAddress, String(proposal?.id)], + enabled, + queryFn: enabled ? async (): Promise => { - let proposal: Proposal | undefined; - let type: ProposalDetailsPageData["type"] = "core"; - - if (votingContractAddress === coreVoting.address) { - proposal = coreVoting.getProposal(id); - } else if (votingContractAddress === gscVoting?.address) { - type = "gsc"; - proposal = gscVoting.getProposal(id); - } else { - throw new Error( - `No config found for voting contract address ${votingContractAddress}, See src/config.`, - ); - } - - const createdTransactionHash = - await proposal.getCreatedTransactionHash(); - const createdAtBlock = await proposal.getCreatedBlock(); - const createdAtDate = createdAtBlock - ? await getBlockDate(createdAtBlock, provider) - : null; - - const expirationBlock = await proposal.getExpirationBlock(); - const endsAtDate = expirationBlock - ? await getBlockDate(expirationBlock, context.provider, { - estimateFutureDates: true, - }) - : null; + const createdTransaction = await proposal.getCreatedTransaction(); + const createdAtDate = proposal.created + ? await getBlockDate(proposal.created, client) + : undefined; + + const endsAtDate = proposal.expiration + ? await getBlockDate(proposal.expiration, client) + : undefined; const unlockedAtBlock = await proposal.getUnlockBlock(); const unlockedAtDate = unlockedAtBlock - ? await getBlockDate(unlockedAtBlock, provider, { - estimateFutureDates: true, - }) - : null; + ? await getBlockDate(unlockedAtBlock, client) + : undefined; const lastCallBlock = await proposal.getLastCallBlock(); - const lastCallAtDate = lastCallBlock - ? await getBlockDate(lastCallBlock, provider, { - estimateFutureDates: true, - }) - : null; + const lastCallDate = lastCallBlock + ? await getBlockDate(lastCallBlock, client) + : undefined; const votes = await proposal.getVotes(); const voterEnsRecords = await getBulkEnsRecords( - Array.from(new Set(votes.map((vote) => vote.voter.address))), - provider, + Array.from(new Set(votes?.map(({ voter }) => voter.address))), + client, ); + const isExecuted = await proposal.getIsExecuted(); const currentQuorum = await proposal.getCurrentQuorum(); const requiredQuorum = await proposal.getRequiredQuorum(); const results = await proposal.getResults(); + const isActive = await proposal.getIsActive(); + const createdBy = await proposal.getCreatedBy(); + + const accountBallot = account + ? (await proposal.getVote({ account }))?.ballot + : undefined; - const proposalConfig = proposalConfigs[id]; + const proposalConfig = votingConfig?.proposals[String(id)]; + const executedTransaction = await proposal.getExecutedTransaction(); return { - type, + proposalExists: !!proposal, + type: isGsc ? "gsc" : "core", votingContractName, status: getProposalStatus({ - isExecuted: await proposal.getIsExecuted(), - lastCallDate: lastCallAtDate, + isExecuted, + lastCallDate, currentQuorum, requiredQuorum, results, }), - isActive: await proposal.getIsActive(), + isActive: isActive ?? false, currentQuorum, requiredQuorum, - createdAtBlock, - createdBy: await proposal.getCreatedBy(), + createdAtBlock: proposal.created, + createdBy: createdBy?.address, createdAtDate, endsAtDate, unlockedAtDate, - lastCallAtDate: lastCallAtDate, - votes: await proposal.getVotes(), + lastCallDate, + votes, voterEnsRecords, - createdTransactionHash, - accountBallot: account - ? (await proposal.getVote(account))?.ballot - : undefined, - descriptionURL: proposalConfig?.descriptionURL ?? null, - paragraphSummary: proposalConfig?.paragraphSummary ?? null, + createdTransactionHash: createdTransaction?.hash, + accountBallot, + descriptionURL: proposalConfig?.descriptionURL, + paragraphSummary: proposalConfig?.paragraphSummary, title: proposalConfig?.title, - executedTransactionHash: - await proposal.getExecutedTransactionHash(), + executedTransactionHash: executedTransaction?.hash, }; + // return "ProposalDetailsPageData"; } : undefined, }); + + return { + // proposalExists: data?.proposalExists ?? false, + data, + status, + }; } /** @@ -345,12 +316,12 @@ function useProposalDetailsPageData( */ // TODO: This function breaks the build when only the generic signature is used. // The overload signature fixes the build and maintains a strong return type. -function dedupeVotes(votes: T): T; -function dedupeVotes(votes: Vote[] | undefined): Vote[] | undefined { +function dedupeVotes(votes: T): T; +function dedupeVotes(votes: ReadVote[] | undefined): ReadVote[] | undefined { if (!votes) { return votes; } - const byVoterAddress: Record = {}; + const byVoterAddress: Record = {}; for (const vote of votes) { byVoterAddress[vote.voter.address] = vote; } diff --git a/apps/council-ui/pages/proposals/index.tsx b/apps/council-ui/pages/proposals/index.tsx index bd84c2f9..5a142f42 100644 --- a/apps/council-ui/pages/proposals/index.tsx +++ b/apps/council-ui/pages/proposals/index.tsx @@ -1,20 +1,20 @@ -import { getBlockDate } from "@council/sdk"; import { useQuery, UseQueryResult } from "@tanstack/react-query"; import assertNever from "assert-never"; -import { parseEther } from "ethers/lib/utils"; import { ReactElement } from "react"; -import { councilConfigs } from "src/config/council.config"; -import { getProposalStatus } from "src/proposals/getProposalStatus"; import { ExternalInfoCard } from "src/ui/base/information/ExternalInfoCard"; import { Page } from "src/ui/base/Page"; -import { useCouncil } from "src/ui/council/useCouncil"; -import { useChainId } from "src/ui/network/useChainId"; +import { getBlockDate } from "src/ui/base/utils/getBlockDate"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadCoreVoting } from "src/ui/council/hooks/useReadCoreVoting"; +import { useReadGscVoting } from "src/ui/council/hooks/useReadGscVoting"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; import { ProposalRowData, ProposalsTable, } from "src/ui/proposals/ProposalTable/ProposalsTable"; import { ProposalsTableSkeleton } from "src/ui/proposals/ProposalTable/ProposalsTableSkeleton"; -import { useAccount } from "wagmi"; +import { getProposalStatus } from "src/utils/getProposalStatus"; +import { useAccount, usePublicClient } from "wagmi"; export default function ProposalsPage(): ReactElement { const { address } = useAccount(); @@ -26,7 +26,7 @@ export default function ProposalsPage(): ReactElement { {(() => { switch (status) { - case "loading": + case "pending": return (
@@ -36,8 +36,8 @@ export default function ProposalsPage(): ReactElement { case "error": return (
- - {error ? (error as string).toString() : "Unknown error"} + + {error ? String(error) : "Unknown error"}
); @@ -70,14 +70,17 @@ export default function ProposalsPage(): ReactElement { } function useProposalsPageData( - account: string | undefined, + account: `0x${string}` | undefined, ): UseQueryResult { - const { context, coreVoting, gscVoting } = useCouncil(); - const chainId = useChainId(); - const proposalsConfig = councilConfigs[chainId].coreVoting.proposals; + const coreVoting = useReadCoreVoting(); + const gscVoting = useReadGscVoting(); + const chainId = useSupportedChainId(); + const config = useCouncilConfig(); + const client = usePublicClient(); + return useQuery({ - queryKey: ["proposalsPage", account], - queryFn: async () => { + queryKey: ["proposalsPage", account, chainId], + queryFn: async (): Promise => { let allProposals = await coreVoting.getProposals(); if (gscVoting) { @@ -87,42 +90,50 @@ function useProposalsPageData( return await Promise.all( allProposals.map(async (proposal) => { - const proposalConfig = proposalsConfig[proposal.id]; - const createdBlock = await proposal.getCreatedBlock(); - const expirationBlock = await proposal.getExpirationBlock(); - const votingEnds = expirationBlock - ? await getBlockDate(expirationBlock, context.provider, { - estimateFutureDates: true, - }) - : null; + const vote = account + ? await proposal.getVote({ account }) + : undefined; + + const currentQuorum = await proposal.getCurrentQuorum(); + + const requiredQuorum = await proposal.getRequiredQuorum(); + const isExecuted = await proposal.getIsExecuted(); + const results = await proposal.getResults(); + const lastCall = await proposal.getLastCallBlock(); const lastCallDate = lastCall - ? await getBlockDate(lastCall, context.provider, { - estimateFutureDates: true, - }) - : null; - const currentQuorum = await proposal.getCurrentQuorum(); - const vote = account ? await proposal.getVote(account) : null; - return { - status: getProposalStatus({ - isExecuted: await proposal.getIsExecuted(), - currentQuorum, - lastCallDate, - requiredQuorum: await proposal.getRequiredQuorum(), - results: await proposal.getResults(), - }), - votingContractAddress: proposal.votingContract.address, - votingContractName: proposal.votingContract.name, + ? await getBlockDate(lastCall, client) + : undefined; + + const createdDate = await getBlockDate(proposal.created, client); + const votingEnds = await getBlockDate(proposal.expiration, client); + + const status = getProposalStatus({ + isExecuted, + currentQuorum, + lastCallDate, + requiredQuorum, + results, + }); + + const isGsc = proposal.coreVoting.address === gscVoting?.address; + const proposalConfig = isGsc + ? config.gscVoting?.proposals[String(proposal.id)] + : config.coreVoting.proposals[String(proposal.id)]; + + const result: ProposalRowData = { + status, + coreVotingAddress: proposal.coreVoting.address, + votingContractName: proposal.coreVoting.name, id: proposal.id, - created: - createdBlock && - (await getBlockDate(createdBlock, context.provider)), + created: createdDate, votingEnds, currentQuorum, - ballot: vote && parseEther(vote.power).gt(0) ? vote.ballot : null, + ballot: vote && vote.power > BigInt(0) ? vote.ballot : undefined, sentenceSummary: proposalConfig?.sentenceSummary, title: proposalConfig?.title, }; + return result; }), ); }, diff --git a/apps/council-ui/pages/vaults/details.tsx b/apps/council-ui/pages/vaults/details.tsx index af471ba3..5e2aa48b 100644 --- a/apps/council-ui/pages/vaults/details.tsx +++ b/apps/council-ui/pages/vaults/details.tsx @@ -1,22 +1,19 @@ import { useRouter } from "next/router"; import { ReactElement } from "react"; import { Page } from "src/ui/base/Page"; -import { useChainId } from "src/ui/network/useChainId"; +import { useVaultConfig } from "src/ui/config/hooks/useVaultConfig"; import { FrozenLockingVaultDetails } from "src/ui/vaults/frozenLockingVault/FrozenLockingVaultDetails"; import { GenericVaultDetails } from "src/ui/vaults/genericVault/GenericVaultDetails"; -import { GSCVaultDetails } from "src/ui/vaults/gscVault/GSCVaultDetails"; +import { GscVaultDetails } from "src/ui/vaults/gscVault/GSCVaultDetails"; import { LockingVaultDetails } from "src/ui/vaults/lockingVault/LockingVaultDetails"; import { VestingVaultDetails } from "src/ui/vaults/vestingVault/VestingVaultDetails"; -import { getVaultConfig } from "src/vaults/vaults"; +// import { VestingVaultDetails } from"src/ui/vaults/vestingVault/VestingVaultDetails"; -export default function Vault(): ReactElement { - const { - query: { address }, - replace, - } = useRouter(); +export default function VaultPage(): ReactElement { + const { query, replace } = useRouter(); + const address = query.address as `0x${string}` | undefined; - const chainId = useChainId(); - const vaultConfig = getVaultConfig(address?.toString() || "", chainId); + const vaultConfig = useVaultConfig(address); if (!address || !vaultConfig) { replace("/vaults"); @@ -25,24 +22,24 @@ export default function Vault(): ReactElement { return ( {(() => { - if (!vaultConfig) { + if (!address || !vaultConfig) { return; } switch (vaultConfig.type) { case "FrozenLockingVault": - return ; + return ; case "LockingVault": - return ; + return ; case "VestingVault": - return ; + return ; case "GSCVault": - return ; + return ; default: - return ; + return ; } })()} diff --git a/apps/council-ui/pages/vaults/index.tsx b/apps/council-ui/pages/vaults/index.tsx index 3b254fa1..0bced7d0 100644 --- a/apps/council-ui/pages/vaults/index.tsx +++ b/apps/council-ui/pages/vaults/index.tsx @@ -1,15 +1,16 @@ +import { ReadLockingVault, ReadVestingVault } from "@delvtech/council-viem"; import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { ReactElement } from "react"; import { ExternalInfoCard } from "src/ui/base/information/ExternalInfoCard"; import { Page } from "src/ui/base/Page"; -import { useCouncil } from "src/ui/council/useCouncil"; -import { useChainId } from "src/ui/network/useChainId"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadCoreVoting } from "src/ui/council/hooks/useReadCoreVoting"; +import { useReadGscVoting } from "src/ui/council/hooks/useReadGscVoting"; import { GenericVaultCard, GenericVaultCardSkeleton, } from "src/ui/vaults/GenericVaultCard"; -import { GSCVaultPreviewCard } from "src/ui/vaults/gscVault/GSCVaultPreviewCard/GSCVaultPreviewCard"; -import { getVaultConfig } from "src/vaults/vaults"; +import { GSCVaultPreviewCard } from "src/ui/vaults/gscVault/GscVaultPreviewCard"; import { useAccount } from "wagmi"; export default function VaultsPage(): ReactElement { @@ -28,7 +29,7 @@ export default function VaultsPage(): ReactElement {

-
+
{status === "success" ? ( data.map((vault) => { switch (vault.name) { @@ -64,7 +65,7 @@ export default function VaultsPage(): ReactElement { )}
-
+
{ - const { coreVoting, gscVoting } = useCouncil(); - const chainId = useChainId(); + const coreVoting = useReadCoreVoting(); + const gscVoting = useReadGscVoting(); + const config = useCouncilConfig(); return useQuery({ queryKey: ["vaultsPage", account], - queryFn: (): Promise => { - let allVaults = coreVoting.vaults; - if (gscVoting) { - allVaults = [...allVaults, ...gscVoting.vaults]; - } + queryFn: async (): Promise => { + const data: VaultData[] = []; + + // core voting vaults + for (const vault of coreVoting.vaults) { + const vaultConfig = config.coreVoting.vaults.find( + ({ address }) => address === vault.address, + ); + + let tvp: bigint | undefined = undefined; + if ( + vault instanceof ReadLockingVault || + vault instanceof ReadVestingVault + ) { + tvp = await vault.getTotalVotingPower(); + } - return Promise.all( - allVaults.map(async (vault) => { - const vaultConfig = getVaultConfig(vault.address, chainId); + data.push({ + address: vault.address, + name: vaultConfig?.name || vault.name, + tvp, + votingPower: account && (await vault.getVotingPower({ account })), + sentenceSummary: vaultConfig?.sentenceSummary, + }); + } - return { + // gsc vault + if (gscVoting) { + for (const vault of gscVoting.vaults) { + data.push({ address: vault.address, - name: vaultConfig?.name || vault.name, - tvp: await vault.getTotalVotingPower?.(), - votingPower: account && (await vault.getVotingPower(account)), - sentenceSummary: vaultConfig?.sentenceSummary, - }; - }), - ); + name: config.gscVoting!.vault.name, + tvp: undefined, + votingPower: account && (await vault.getVotingPower({ account })), + sentenceSummary: config.gscVoting!.vault.sentenceSummary, + }); + } + } + + return data; }, }); } diff --git a/apps/council-ui/pages/voters/details.tsx b/apps/council-ui/pages/voters/details.tsx index e9926380..f13b18f8 100644 --- a/apps/council-ui/pages/voters/details.tsx +++ b/apps/council-ui/pages/voters/details.tsx @@ -1,37 +1,37 @@ -import { Vote, Voter } from "@council/sdk"; +import { ReadVote, ReadVotingVault } from "@delvtech/council-viem"; import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { getAddress } from "ethers/lib/utils"; import { useRouter } from "next/router"; import { ReactElement } from "react"; import Skeleton from "react-loading-skeleton"; -import { makeEtherscanAddressURL } from "src/etherscan/makeEtherscanAddressURL"; import { Routes } from "src/routes"; import { Breadcrumbs } from "src/ui/base/Breadcrumbs"; import { ErrorMessage } from "src/ui/base/error/ErrorMessage"; import { useDisplayName } from "src/ui/base/formatting/useDisplayName"; import { Page } from "src/ui/base/Page"; import { asyncFilter } from "src/ui/base/utils/asyncFilter"; -import { useCouncil } from "src/ui/council/useCouncil"; +import { useReadCoreVoting } from "src/ui/council/hooks/useReadCoreVoting"; import { AddressWithEtherscan } from "src/ui/ens/AdddressWithEtherscan"; -import { useChainId } from "src/ui/network/useChainId"; -import { useGSCStatus } from "src/ui/vaults/gscVault/useGSCStatus"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; +import { useGscStatus } from "src/ui/vaults/gscVault/hooks/useGscStatus"; import { VoterStatsRowSkeleton } from "src/ui/voters/skeletons/VoterStatsRowSkeleton"; import { VoterStatsRow } from "src/ui/voters/VoterStatsRow"; import { VoterVaultsList } from "src/ui/voters/VoterVaultsList"; import { VoterVaultsListSkeleton } from "src/ui/voters/VoterVaultsListSkeleton"; import { VotingHistoryTableSkeleton } from "src/ui/voters/VotingHistorySkeleton"; import { VotingHistoryTable } from "src/ui/voters/VotingHistoryTable"; -import { GSCStatus } from "src/vaults/gscVault/types"; +import { makeEtherscanAddressURL } from "src/utils/etherscan/makeEtherscanAddressURL"; +import { GscStatus } from "src/utils/gscVault/types"; +import { getAddress } from "viem"; import { useEnsName } from "wagmi"; -export default function VoterDetailsPage(): ReactElement { +export default function VoterPage(): ReactElement { const { query } = useRouter(); - const { address } = query as { address: string | undefined }; - const { coreVoting } = useCouncil(); - const { data, status } = useVoterData(address); - const displayName = useDisplayName(address); + const { address: account } = query as { address: `0x${string}` | undefined }; + const coreVoting = useReadCoreVoting(); + const { data, status } = useVoterData(account); + const displayName = useDisplayName(account); - if (!address) { + if (!account) { return ( ); @@ -48,7 +48,7 @@ export default function VoterDetailsPage(): ReactElement { crumbs={[{ href: Routes.VOTERS, content: "All voters" }]} currentPage={displayName} /> - +
{status === "success" ? ( @@ -68,7 +68,7 @@ export default function VoterDetailsPage(): ReactElement {

Voting Vaults ({numVotingVaults})

- +
) : (
@@ -79,7 +79,7 @@ export default function VoterDetailsPage(): ReactElement {
)} -
+

Voting History ({data?.votingHistory.length ?? 0})

@@ -96,24 +96,23 @@ export default function VoterDetailsPage(): ReactElement { } interface VoterHeaderProps { - address: string; + address: `0x${string}`; } function VoterHeader({ address }: VoterHeaderProps) { - const chainId = useChainId(); + const chainId = useSupportedChainId(); const { data: ens, isLoading: ensLoading } = useEnsName({ - address: getAddress(address as string), - enabled: !!address, + address: getAddress(address), }); if (ensLoading) { return (
-

+

-

+

@@ -134,57 +133,63 @@ function VoterHeader({ address }: VoterHeaderProps) {
) : ( -

+

); } interface VoterData { - gscStatus: GSCStatus | null; + gscStatus: GscStatus | undefined; proposalsCreated: number; - votingHistory: Vote[]; - votingPower: string; + votingHistory: ReadVote[]; + votingPower: bigint; percentOfTVP: number; } export function useVoterData( - address: string | undefined, + account: `0x${string}` | undefined, ): UseQueryResult { - const { context, coreVoting } = useCouncil(); - const { data: gscStatus } = useGSCStatus(address); + const coreVoting = useReadCoreVoting(); + const { gscStatus } = useGscStatus(account); - const queryEnabled = !!address && !!gscStatus; + const queryEnabled = !!account && !!gscStatus; return useQuery({ - queryKey: ["voter-details", address, gscStatus], + queryKey: ["voter-details", account, gscStatus], enabled: queryEnabled, queryFn: queryEnabled ? async (): Promise => { - const voter = new Voter(address, context); // display voting history in reverse chronological order, ie: most // recent proposals first // TODO: Where does GSC Voting history fit in this? const votingHistory = [ - ...(await voter.getVotes(coreVoting.address)), + ...(await coreVoting.getVotes({ account })), ].reverse(); - const votingPower = await voter.getVotingPower( - coreVoting.vaults.map((vault) => vault.address), - ); - const tvp = await coreVoting.getTotalVotingPower(); + + const votingPower = await coreVoting.getVotingPower({ account }); + let tvp = 0n; + + for (const vault of coreVoting.vaults) { + if (hasTotalVotingPower(vault)) { + tvp += await vault.getTotalVotingPower(); + } + } const coreVotingProposals = await coreVoting.getProposals(); const proposalsCreatedByAddress = await asyncFilter( coreVotingProposals, async (proposal) => { const createdBy = await proposal.getCreatedBy(); - return createdBy === address; + return createdBy?.address === account; }, ); return { votingHistory, votingPower, - percentOfTVP: +((+votingPower / +tvp) * 100).toFixed(1), + percentOfTVP: +((Number(votingPower) / Number(tvp)) * 100).toFixed( + 1, + ), gscStatus, proposalsCreated: proposalsCreatedByAddress.length, }; @@ -193,3 +198,12 @@ export function useVoterData( refetchOnWindowFocus: false, }); } + +function hasTotalVotingPower( + vault: ReadVotingVault, +): vault is ReadVotingVault & { getTotalVotingPower: () => Promise } { + return ( + "getTotalVotingPower" in vault && + typeof vault.getTotalVotingPower === "function" + ); +} diff --git a/apps/council-ui/pages/voters/index.tsx b/apps/council-ui/pages/voters/index.tsx index 8fd28e20..77b9af6f 100644 --- a/apps/council-ui/pages/voters/index.tsx +++ b/apps/council-ui/pages/voters/index.tsx @@ -1,19 +1,26 @@ +import { + ReadVotingVault, + VoterPowerBreakdown, + VoterWithPower, +} from "@delvtech/council-viem"; import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { ReactElement, useMemo, useState } from "react"; -import { getBulkEnsRecords } from "src/ens/getBulkEnsRecords"; import { ErrorMessage } from "src/ui/base/error/ErrorMessage"; import { Page } from "src/ui/base/Page"; -import { useCouncil } from "src/ui/council/useCouncil"; -import { useChainId } from "src/ui/network/useChainId"; -import { GSCOnlyToggle } from "src/ui/voters/GSCOnlyToggle"; +import { useReadCoreVoting } from "src/ui/council/hooks/useReadCoreVoting"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; +import { useReadGscVault } from "src/ui/vaults/gscVault/hooks/useReadGscVault"; +import { GSCOnlyToggle } from "src/ui/voters/GscOnlyToggle"; import { useVotersSearch } from "src/ui/voters/hooks/useVotersSearch"; import { VoterRowData } from "src/ui/voters/types"; import { VoterList } from "src/ui/voters/VoterList/VoterList"; import { VoterListSkeleton } from "src/ui/voters/VoterList/VoterListSkeleton"; +import { getBulkEnsRecords } from "src/utils/getBulkEnsRecords"; +import { usePublicClient } from "wagmi"; const DEFAULT_LIST_SIZE = 100; -export default function Voters(): ReactElement { +export default function VotersPage(): ReactElement { const { data: voters, status, error } = useVoterPageData(); const { results, search } = useVotersSearch(voters); const [listSize, setListSize] = useState(DEFAULT_LIST_SIZE); @@ -32,7 +39,7 @@ export default function Voters(): ReactElement { return ( -
+

Voters

@@ -45,7 +52,7 @@ export default function Voters(): ReactElement { search(e.target.value as string)} disabled={status !== "success"} /> @@ -71,31 +78,47 @@ export default function Voters(): ReactElement { } export function useVoterPageData(): UseQueryResult { - const { - coreVoting, - gscVoting, - context: { provider }, - } = useCouncil(); - const chainId = useChainId(); + const coreVoting = useReadCoreVoting(); + const gscVault = useReadGscVault(); + const chainId = useSupportedChainId(); + const publicClient = usePublicClient(); return useQuery({ queryKey: ["voter-list-page", chainId], queryFn: async () => { - const voterPowerBreakdowns = await coreVoting.getVotingPowerBreakdown(); - const gscMembers = (await gscVoting?.getVoters()) || []; + const voterPowerBreakdowns: VoterPowerBreakdown[] = []; + + for (const vault of coreVoting.vaults) { + if (hasVotingPowerBreakdown(vault)) { + const breakdown = await vault.getVotingPowerBreakdown(); + voterPowerBreakdowns.push(...breakdown); + } + } + + const mergedBreakdowns = mergeVoterPowerBreakdowns(voterPowerBreakdowns); + + const gscMembers = (await gscVault?.getVoters()) || []; const gscMemberAddresses = gscMembers.map(({ address }) => address); const ensRecords = await getBulkEnsRecords( voterPowerBreakdowns.map(({ voter }) => voter.address), - provider, + publicClient, ); - return voterPowerBreakdowns.map(({ voter, votingPower, delegators }) => ({ - address: voter.address, - ensName: ensRecords[voter.address], - votingPower, - numberOfDelegators: delegators.length, - isGSCMember: gscMemberAddresses.includes(voter.address), - })); + console.log({ + ensRecords, + }); + + return mergedBreakdowns.map( + ({ voter, votingPower, votingPowerByDelegator }) => { + return { + address: voter.address, + ensName: ensRecords[voter.address], + votingPower, + numberOfDelegators: votingPowerByDelegator.length, + isGSCMember: gscMemberAddresses.includes(voter.address), + }; + }, + ); }, // This is an expensive query and do not want to refetch. @@ -103,3 +126,80 @@ export function useVoterPageData(): UseQueryResult { staleTime: Infinity, }); } + +function hasVotingPowerBreakdown( + vault: ReadVotingVault, +): vault is ReadVotingVault & { + getVotingPowerBreakdown: () => Promise; +} { + return ( + "getVotingPowerBreakdown" in vault && + typeof vault.getVotingPowerBreakdown === "function" + ); +} + +// TODO: This was a method on the old Voting Contract type, but depended on +// vaults having an optional method. It seems like a combined voter list would +// be a common use case, so this might still belong in the SDK somewhere. +function mergeVoterPowerBreakdowns( + breakdowns: VoterPowerBreakdown[], +): VoterPowerBreakdown[] { + // create a temp object to merge unique addresses + const breakdownsByVoter: Record< + `0x${string}`, + VoterWithPower & { + fromDelegators: bigint; + byDelegator: Record<`0x${string}`, VoterWithPower>; + } + > = {}; + + for (const { + voter, + votingPower, + votingPowerByDelegator, + votingPowerFromAllDelegators, + } of breakdowns) { + const breakdown = breakdownsByVoter[voter.address]; + + if (!breakdown) { + // Add a breakdown for this voter in the unique list + breakdownsByVoter[voter.address] = { + voter, + votingPower, + fromDelegators: votingPowerFromAllDelegators, + // key delegators by their address + byDelegator: Object.fromEntries( + votingPowerByDelegator.map((delegatorWithPower) => [ + delegatorWithPower.voter.address, + delegatorWithPower, + ]), + ), + }; + } else { + // if a breakdown for this voter already exists, then merge with the + // current one. + breakdown.votingPower += votingPower; + breakdown.fromDelegators += votingPowerFromAllDelegators; + + for (const delegatorWithPower of votingPowerByDelegator) { + if (!breakdown.byDelegator[delegatorWithPower.voter.address]) { + // Add the delegator with power to the breakdown in the unique list + breakdown.byDelegator[delegatorWithPower.voter.address] = + delegatorWithPower; + } else { + breakdown.byDelegator[delegatorWithPower.voter.address].votingPower += + delegatorWithPower.votingPower; + } + } + } + } + + return Object.values(breakdownsByVoter).map( + ({ voter, votingPower, fromDelegators, byDelegator }) => ({ + voter, + votingPower, + votingPowerFromAllDelegators: fromDelegators, + votingPowerByDelegator: Object.values(byDelegator), + }), + ); +} diff --git a/apps/council-ui/src/clients/council.ts b/apps/council-ui/src/clients/council.ts deleted file mode 100644 index 3441282c..00000000 --- a/apps/council-ui/src/clients/council.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - Airdrop, - CouncilContext, - GSCVault, - GSCVotingContract, - LockingVault, - VestingVault, - VotingContract, - VotingVault, -} from "@council/sdk"; -import { councilConfigs, SupportedChainId } from "src/config/council.config"; -import { provider as getProvider } from "src/provider"; - -export interface CouncilClient { - context: CouncilContext; - coreVoting: VotingContract; - gscVoting?: GSCVotingContract; - airdrop?: Airdrop; -} - -export function getCouncilClient(chainId: SupportedChainId): CouncilClient { - const config = councilConfigs[chainId]; - if (!config) { - throw new Error( - `Attempted to create a Council client with Chain ID ${chainId}, but no config was found. See src/config.`, - ); - } - - const provider = getProvider({ chainId }); - const context = new CouncilContext(provider); - - const coreVotingVaults = config.coreVoting.vaults.map(({ type, address }) => { - switch (type) { - case "FrozenLockingVault": - case "LockingVault": - return new LockingVault(address, context); - case "VestingVault": - return new VestingVault(address, context); - case "GSCVault": - return new GSCVault(address, context); - default: - return new VotingVault(address, context); - } - }); - - const client: CouncilClient = { - context, - coreVoting: new VotingContract( - config.coreVoting.address, - coreVotingVaults, - context, - ), - }; - - if (config.gscVoting) { - client.gscVoting = new GSCVotingContract( - config.gscVoting.address, - new GSCVault(config.gscVoting.vaults[0].address, context), - context, - ); - } - - if (config.airdrop) { - client.airdrop = new Airdrop(config.airdrop.address, context); - } - - return client; -} diff --git a/apps/council-ui/src/clients/wagmi.ts b/apps/council-ui/src/clients/wagmi.ts deleted file mode 100644 index 588b5e45..00000000 --- a/apps/council-ui/src/clients/wagmi.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { provider } from "src/provider"; -import { connectors } from "src/wallet/connectors"; -import { createClient } from "wagmi"; - -export const wagmiClient = createClient({ - autoConnect: true, - connectors, - provider, -}); diff --git a/apps/council-ui/src/config/CouncilConfig.ts b/apps/council-ui/src/config/CouncilConfig.ts index 8c046adc..7c6df908 100644 --- a/apps/council-ui/src/config/CouncilConfig.ts +++ b/apps/council-ui/src/config/CouncilConfig.ts @@ -11,8 +11,8 @@ export interface CouncilConfig { */ chainId: number; timelock: ContractConfig; - coreVoting: VotingContractConfig; - gscVoting?: VotingContractConfig; + coreVoting: CoreVotingContractConfig; + gscVoting?: GscVotingContractConfig; airdrop?: AirdropConfig; /** @@ -23,16 +23,23 @@ export interface CouncilConfig { } export interface ContractConfig { - address: string; + address: `0x${string}`; } -export interface VotingContractConfig extends ContractConfig { +export interface BaseVotingContractConfig extends ContractConfig { name: string; descriptionURL: string; - vaults: VaultConfig[]; proposals: Record; } +export interface CoreVotingContractConfig extends BaseVotingContractConfig { + vaults: VaultConfig[]; +} + +export interface GscVotingContractConfig extends BaseVotingContractConfig { + vault: VaultConfig; +} + export interface VaultConfig extends ContractConfig { type: | "LockingVault" @@ -65,7 +72,7 @@ export interface ProposalConfig { * A description to show on the proposal's details page. */ paragraphSummary?: string; - descriptionURL: string; + descriptionURL?: string; targets: string[]; calldatas: string[]; } diff --git a/apps/council-ui/src/config/council.config.ts b/apps/council-ui/src/config/council.config.ts index 221451bf..c0028494 100644 --- a/apps/council-ui/src/config/council.config.ts +++ b/apps/council-ui/src/config/council.config.ts @@ -3,10 +3,10 @@ import { goerliCouncilConfig } from "src/config/goerli"; import { localhostCouncilConfig } from "src/config/localhost"; import { mainnetCouncilConfig } from "src/config/mainnet"; -export type SupportedChainId = 1 | 5 | 31337; +export type SupportedChainId = 1 | 5 | 1337; export const councilConfigs: Record = { 1: mainnetCouncilConfig, 5: goerliCouncilConfig, - 31337: localhostCouncilConfig, + 1337: localhostCouncilConfig, }; diff --git a/apps/council-ui/src/config/goerli.ts b/apps/council-ui/src/config/goerli.ts index 2040de47..7397445c 100644 --- a/apps/council-ui/src/config/goerli.ts +++ b/apps/council-ui/src/config/goerli.ts @@ -1,51 +1,25 @@ -import { goerliDeployments } from "@council/deploy"; import { CouncilConfig } from "src/config/CouncilConfig"; -const { contracts: goerliContracts } = - goerliDeployments[goerliDeployments.length - 1]; - -// Find the deployed contract addresses. These are safe to cast as strings -// because we know the deployment contains these contracts in the -// @council/deploy project. -const goerliTimelockAddress = goerliContracts.find( - ({ name }) => name === "Timelock", -)?.address as string; -const goerliCoreVotingAddress = goerliContracts.find( - ({ name }) => name === "CoreVoting", -)?.address as string; -const lockingVaultProxyAddress = goerliContracts.find( - ({ name }) => name === "LockingVaultProxy", -)?.address as string; -const vestingVaultProxyAddress = goerliContracts.find( - ({ name }) => name === "VestingVaultProxy", -)?.address as string; -const gscVotingAddress = goerliContracts.find( - ({ name }) => name === "GSCCoreVoting", -)?.address as string; -const goerliGSCVaultAddress = goerliContracts.find( - ({ name }) => name === "GSCVault", -)?.address as string; - export const goerliCouncilConfig: CouncilConfig = { version: "", chainId: 5, timelock: { - address: goerliTimelockAddress, + address: "0x7e7eEc56D2C53E9203d5cF48E01560Da52ff5214", }, coreVoting: { name: "Core Voting", - address: goerliCoreVotingAddress, + address: "0x1dcFAD45c31e0b4d9A3E3cb05013023d9A9Bbd11", descriptionURL: "https://moreinfo.com", vaults: [ { name: "Locking Vault", - address: lockingVaultProxyAddress, + address: "0x4520da1DDFad1F48536A2a21CF5923dd2c2247e9", type: "LockingVault", descriptionURL: "https://moreinfo.com", }, { name: "Vesting Vault", - address: vestingVaultProxyAddress, + address: "0x6dbE1aF34649d1efe5f6a708A1CF93bF2F422250", type: "VestingVault", descriptionURL: "https://moreinfo.com", }, @@ -74,22 +48,25 @@ export const goerliCouncilConfig: CouncilConfig = { gscVoting: { name: "GSC", - address: gscVotingAddress, + address: "0xd3f84fc6f50e421502e9f8e36b519E0D156BE6C8", descriptionURL: "https://moreinfo.com", - vaults: [ - { - name: "GSC Vault", - address: goerliGSCVaultAddress, - type: "GSCVault", - descriptionURL: "https://moreinfo.com", - }, - ], + vault: { + name: "GSC Vault", + address: "0xCFb73f8D5D29e5d936AdF86A8A739AE12b882E8D", + type: "GSCVault", + descriptionURL: "https://moreinfo.com", + }, proposals: { 0: { descriptionURL: "", targets: [], calldatas: [] }, 1: { descriptionURL: "", targets: [], calldatas: [] }, }, }, + airdrop: { + address: "0x8278a7951f9E3C88B1817223603635981D65bC63", + baseDataURL: "/api/airdrop", + }, + /** * Optional Push integration */ diff --git a/apps/council-ui/src/config/localhost.ts b/apps/council-ui/src/config/localhost.ts index db50193c..504a9f21 100644 --- a/apps/council-ui/src/config/localhost.ts +++ b/apps/council-ui/src/config/localhost.ts @@ -2,7 +2,7 @@ import { CouncilConfig } from "src/config/CouncilConfig"; export const localhostCouncilConfig: CouncilConfig = { version: "", - chainId: 31337, + chainId: 1337, timelock: { address: "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9", }, @@ -17,6 +17,18 @@ export const localhostCouncilConfig: CouncilConfig = { type: "LockingVault", descriptionURL: "https://moreinfo.com", }, + { + name: "Locking Vault 2", + address: "0x959922be3caee4b8cd9a407cc3ac1c251c2007b1", + type: "LockingVault", + descriptionURL: "https://moreinfo.com", + }, + { + name: "Vesting Vault", + address: "0x59b670e9fa9d0a427751af201d676719a970857b", + type: "VestingVault", + descriptionURL: "https://moreinfo.com", + }, ], proposals: {}, }, diff --git a/apps/council-ui/src/config/mainnet.ts b/apps/council-ui/src/config/mainnet.ts index 27338843..fd52acf0 100644 --- a/apps/council-ui/src/config/mainnet.ts +++ b/apps/council-ui/src/config/mainnet.ts @@ -14,32 +14,35 @@ export const mainnetCouncilConfig: CouncilConfig = { { name: "Locking Vault", sentenceSummary: - "Allows users to deposit their tokens in exchange for voting power, which can also be delegated to a different user.", + "Deposit tokens in exchange for voting power, which can then be delegated.", paragraphSummary: "Allows users to deposit their tokens in exchange for voting power, which can also be delegated to a different user.", address: "0x02Bd4A3b1b95b01F2Aa61655415A5d3EAAcaafdD", - type: "FrozenLockingVault", - descriptionURL: "https://moreinfo.com", + type: "LockingVault", + descriptionURL: + "https://docs.element.fi/governance-council/council-protocol-smart-contracts/voting-vaults/locking-vault", }, { name: "Vesting Vault", address: "0x6De73946eab234F1EE61256F10067D713aF0e37A", sentenceSummary: - "Allows locked / vesting positions to still have voting power in the governance system by using a defined multiplier for the vested tokens over unvested.", + "Allows vesting tokens to have voting power in proportion to a DAO-defined multiplier.", paragraphSummary: "Allows locked / vesting positions to still have voting power in the governance system by using a defined multiplier for the vested tokens over unvested.", type: "VestingVault", - descriptionURL: "https://moreinfo.com", + descriptionURL: + "https://docs.element.fi/governance-council/council-protocol-smart-contracts/voting-vaults/vesting-vault", }, ], proposals: { 0: { - descriptionURL: "https://moreinfo.com", + descriptionURL: "", targets: [], calldatas: [], }, 1: { - descriptionURL: "https://moreinfo.com", + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0x46785a4b78a9d03aeb5cdeb1c3ca4ae02cf9e5aca508e59bef405d16a7c8b4a6", targets: [], calldatas: [], title: "EGP-2: Increase GSC quorum threshold", @@ -48,17 +51,19 @@ export const mainnetCouncilConfig: CouncilConfig = { "As of today (April 28, 2022) there has only been one delegate (myself) who has proven their membership to the GSC on-chain. In roughly five days I will be able to pass votes by myself with no further approval because the current quroum threshold on the GSC is one. I believe that no one person should be able to govern the GSC by themselves, and thus I am proposing to effectively lock the GSC until two other delegates join the GSC. Three was chosen as a starting point for one reason, to break a tie. As more delegates join, I'm sure this value will gradually increase but for the time being, a threshold of three will be adequate to ensure some level of participation.", }, 2: { - descriptionURL: "https://moreinfo.com", + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0x132d4d3e0580349d938d22c844ce088ba2e5f394fc28b41f2927856746b125d7", targets: [], calldatas: [], - title: "EGP-15: Element Fixed Borrow Protocol Grant Proposal (Old)", + title: "(Invalid) EGP-15: Element Fixed Borrow Protocol Grant Proposal", sentenceSummary: "Component is proposing a 274,414.06 ELFI voting token grant to build a fixed borrow protocol on top of Element Finance and for building the YTC tool.", paragraphSummary: "The proceeds of this grant will enable us to build a protocol offering competitive, low-cost, fixed-rate borrowing on Compound Finance and Aave. Component has been an active contributor to Element Finance since early 2021 and will continue to launch new integrations for fixed rates on Element Finance. This grant ensures a long term relationship where Component will be part of growing, generating revenue and decentralizing the Element DAO.", }, 3: { - descriptionURL: "https://moreinfo.com", + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0x132d4d3e0580349d938d22c844ce088ba2e5f394fc28b41f2927856746b125d7", targets: [], calldatas: [], title: "EGP-15: Element Fixed Borrow Protocol Grant Proposal", @@ -67,29 +72,208 @@ export const mainnetCouncilConfig: CouncilConfig = { paragraphSummary: "The proceeds of this grant will enable us to build a protocol offering competitive, low-cost, fixed-rate borrowing on Compound Finance and Aave. Component has been an active contributor to Element Finance since early 2021 and will continue to launch new integrations for fixed rates on Element Finance. This grant ensures a long term relationship where Component will be part of growing, generating revenue and decentralizing the Element DAO.", }, + 4: { + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0xcedbf99283b0dc2c32184cc04a66839ff483621ee661da75a421931297ac7788", + targets: [], + calldatas: [], + title: + "EGP-22: Removal of Non-Contributor Grants from the Vesting Vault", + sentenceSummary: + "Removing the Element Finance team-allocated token grants for those who left Element Finance before their vesting cliffs, for team members who partially vested, or for team members who left before the vesting dates were reached.", + paragraphSummary: + "This proposal formalizes a solution for the removal/reclaiming of Element Finance team members-allocated token grants who departed prior to reaching the vesting vault smart contract-defined vesting cliffs, partially vested, and/or before the vesting dates were reached.", + }, + 5: { + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0x7c35f280e01513b65fe0a3f2abdf2d8cd28eb019c14ad732b1cf25386a52a451", + targets: [], + calldatas: [], + title: + "EGP-23: Convert timestamps to blocknumbers in Deployed Vesting Vault", + sentenceSummary: + "Fix an identified bug within the deployed Vesting Vault Contract regarding the token grant configurations for core contributors, advisors, and investors.", + paragraphSummary: + "It was identified that the grants in the Vesting vault contract were misconfigured during deployment last March 31, 2022. The time in which the grants will start vesting was set up using Unix time stamps in seconds instead of block numbers. The contract expects block numbers as the threshold when funds will start dispersing. The difference between block numbers and time stamps is on an order of magnitude. This has caused all grants to not start until the year ~2630.", + }, + 6: { + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0x0bebfeaada03ab21c52917ff5f0347fa29ce7a45a0a55a48b5382bf02963331f", + targets: [], + calldatas: [], + title: + "EGP-24: Reclaim (Clawback) unclaimed voting rights that have been distributed", + sentenceSummary: + "Reclaim the unclaimed voting rights (ELFI) from the initial distribution event and direct it back to the DAO Treasury.", + paragraphSummary: + "On March 31st, 2022 the Element Labs team distributed voting rights to various different parties, users, and individuals deemed qualified to participate in the DAO operations and voting of the newly formed Element DAO. Close to a year later, a decent % of the distributed voting rights have still not been claimed and utilized as intended. This proposal is to suggest that the unclaimed voting rights ought to be reclaimed by the Element DAO to be re-distributed into the right hands via grants, participants, DAO contributors, liquidity programs, rewards, bounties, etc.", + }, + 7: { + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0xea38eff160c02bd3bce89830ac1e866dc6f5e47ac36f9ede504f30a5661a5c21", + targets: [], + calldatas: [], + title: "EGP-21: Enable the transferability of ELFI", + sentenceSummary: + "Enable the transferability of ELFI by unlocking the voting vaults where the delegated ELFI tokens are currently locked in.", + paragraphSummary: + "This EGP is focused on enabling the transferability of ELFI to allow for the changing of hands of voters. It has been close to 1 year since the release of ELFI, and sufficient (necessary) progress has been made in the development of the DAO. Now is the time for all of the participants within the DAO and the users outside of the DAO looking in to be given the ability to freely choose how they want to express their form of participation.", + }, + 8: { + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0x38b141145f27e23753978ebe8987059c49986579d1b7dcbe1a6d6f8743bff8f7", + targets: [], + calldatas: [], + title: + "EGP-5: Enable Temporary Protocol Incentives for Liquidity Providers", + sentenceSummary: + "Enable protocol incentives to keep TVL in the protocol, and increase network distribution to more users prior to ELFI unlock.", + paragraphSummary: + "Discussing a fair short term incentive program before enabling transferability of ELFI. As Element’s vaults begin to expire, the necessity of incentivizing rollover is getting more and more important to help retain liquidity in the protocol. The fixed income markets require liquidity to create the opportunity for fixed rate purchasers to earn a fixed rate with minimal slippage.", + }, + 9: { + descriptionURL: + "https://snapshot.org/#/elfi.eth/proposal/0x38b141145f27e23753978ebe8987059c49986579d1b7dcbe1a6d6f8743bff8f7", + targets: [], + calldatas: [], + title: "Technical Correction of Core Voting Proposal 8 (EGP-5)", + sentenceSummary: + "This proposal fixes the technical error of the prior proposal, EGP-5.", + paragraphSummary: + "This proposal fixes the technical error of the prior proposal, EGP-5. The ELFI token has 18 decimals, which was not accounted for in Proposal 8. Thus, despite the success of the onchain vote, the retroactive incentives of EGP-5 could not be sent to the reward contract.", + }, + 10: { + descriptionURL: + "https://docs.google.com/document/d/1tutU6ZzIvOCv3CcdCzUJ_PbPMIUM-xu3gAhQ0qkV1Z0/edit", + targets: [], + calldatas: [], + title: "EGP16-2: Begin unwinding of the main treasury", + sentenceSummary: + "This PR is the follow-up from the approved Snapshot proposal, targeting the unwinding of the main treasury.", + paragraphSummary: + "The main treasury holds approx. 192,000 USDC in assets wound up in Yearn positions. These assets come from affiliate fees via Yearn. A separate proposal manages the unwinding of the GSC treasury, which contains about 60,000 USDC. Following this proposal, two more will be necessary to unwind CRV positions and to consolidate assets into USDC, DAI, and ETH.", + }, + 11: { + targets: [], + calldatas: [], + title: + "EGP28: Security Fix for Discovered Vulnerability in the Locking Vault", + sentenceSummary: + "EGP-28 is being proposed in response to a bug disclosure from the Immunefi bug bounty program related to the LockingVault.", + paragraphSummary: + "This proposal includes a solution to fix the ability for exploit to the LockingVault. For security purposes, this proposal won’t include the specific details of the proposal solution until the fix has been implemented. Once the proposal has been executed, a full report will be published revealing the bug report, the fix (including the technical solution), and a call to action for governance process improvement in the case that another situation like this occurs in the future.", + }, + 12: { + targets: [], + calldatas: [], + title: + "EGP27: The Removal of Non-Contributor Grants from the Vesting Vault", + sentenceSummary: + "EGP-27 is being proposed to remove the DELV (formerly Element Finance) team-allocated token grants for those who left DELV before their vesting cliffs, reduce existing grants for Team Members who partially vested, or for Team Members who left before the vesting dates were reached.", + paragraphSummary: + "This proposal formalizes a solution for the removal/reclaiming of DELV Team Members-allocated token grants who departed prior to reaching the vesting vault smart contract-defined vesting cliffs, partially vested, and/or before the vesting dates were reached.\n\n These DELV team-allocated token grants were distributed during the governance launch of Element DAO on March 31 of 2022. The vesting cliff was defined as one year from the launch date and the token vesting schedule defined was as three years after the launch date.", + }, + 13: { + targets: [], + calldatas: [], + title: "EGP-16: Main Treasury - 1", + sentenceSummary: "Revised proposal to unwind the main treasury.", + paragraphSummary: + "The main treasury holds approx. 192,000 USDC in assets wound up in Yearn positions. These assets come from affiliate fees via Yearn. A separate proposal manages the unwinding of the GSC treasury, which contains about 60,000 USDC. Following this proposal, two more will be necessary to unwind CRV positions and to consolidate assets into USDC, DAI, and ETH.", + }, + 14: { + targets: [], + calldatas: [], + title: "EGP-16: Main Treasury - 2", + sentenceSummary: "Revised proposal to unwind the main treasury.", + paragraphSummary: + "The main treasury holds approx. 192,000 USDC in assets wound up in Yearn positions. These assets come from affiliate fees via Yearn. A separate proposal manages the unwinding of the GSC treasury, which contains about 60,000 USDC. Following this proposal, two more will be necessary to unwind CRV positions and to consolidate assets into USDC, DAI, and ETH.", + }, }, }, - gscVoting: { name: "GSC", address: "0x40309f197e7f94B555904DF0f788a3F48cF326aB", descriptionURL: "https://moreinfo.com", - vaults: [ - { - name: "GSC Vault", - address: "0xcA870E8aa4FCEa85b5f0c6F4209C8CBA9265B940", - type: "GSCVault", + vault: { + name: "GSC Vault", + address: "0xcA870E8aa4FCEa85b5f0c6F4209C8CBA9265B940", + type: "GSCVault", + sentenceSummary: + "The Governance Steering Council (GSC) vault gives one vote to each member that has reached an established threshold of delegated voting power defined by the DAO.", + paragraphSummary: + "The Governance Steering Council (GSC) vault gives one vote to each member that has reached an established threshold of delegated voting power defined by the DAO. Council members can create, vote, and execute proposals if the GSC quorum is met, with quorum set by the DAO.", + descriptionURL: + "https://docs.element.fi/governance-council/council-protocol-smart-contracts/voting-vaults/governance-steering-council-gsc-vault", + }, + proposals: { + 0: { + descriptionURL: + "https://docs.google.com/document/d/17tR4ZjibQyfAma3QfklA0FTA4n6S3lGEXXWMzT75K-k/edit", + targets: [], + calldatas: [], + title: "(Invalid) EGP16-1: Unwind GSC Treasury", sentenceSummary: - "The Governance Steering Council (GSC) vault gives one vote to each member that has surpassed a pre-established threshold of delegated Voting Power defined by the DAO.", + "This PR follows on from the approved snapshot proposal to unwind the GSC and main treasury, currently living in a gnosis safe, transferring the unwound assets to the main treasury.", paragraphSummary: - "The Governance Steering Council (GSC) vault gives one vote to each member that has surpassed a pre-established threshold of delegated Voting Power defined by the DAO. Members of the council can create, vote, and execute proposals if the GSC quorum is met, with quorum set by the DAO.", - descriptionURL: "https://moreinfo.com", + "The GSC treasury is a gnosis safe currently containing 24 different assets in balancer pools. These assets came from protocol fees traded on the Element protocol. In order to unwind the assets, the LP positions are withdrawn from the Balancer pools. After withdrawal, principal tokens are redeemed through the Element protocol. Once redeemed, the base assets are transferred and consolidated to the main treasury.", + }, + 1: { + descriptionURL: + "https://docs.google.com/document/d/17tR4ZjibQyfAma3QfklA0FTA4n6S3lGEXXWMzT75K-k/edit", + targets: [], + calldatas: [], + title: "EGP16-1: Unwind GSC Treasury", + sentenceSummary: + "This PR follows on from the approved snapshot proposal to unwind the GSC and main treasury, currently living in a gnosis safe, transferring the unwound assets to the main treasury.", + paragraphSummary: + "The GSC treasury is a gnosis safe currently containing 24 different assets in balancer pools. These assets came from protocol fees traded on the Element protocol. In order to unwind the assets, the LP positions are withdrawn from the Balancer pools. After withdrawal, principal tokens are redeemed through the Element protocol. Once redeemed, the base assets are transferred and consolidated to the main treasury.", + }, + 2: { + descriptionURL: + "https://docs.google.com/document/d/17tR4ZjibQyfAma3QfklA0FTA4n6S3lGEXXWMzT75K-k/edit", + targets: [], + calldatas: [], + title: "EGP-16: GSC-1", + sentenceSummary: + "Revised proposal to unwind the GSC treasury, transferring the unwound assets to the main treasury.", + paragraphSummary: + "The GSC treasury is a gnosis safe currently containing 24 different assets in balancer pools. These assets came from protocol fees traded on the Element protocol. In order to unwind the assets, the LP positions are withdrawn from the Balancer pools. After withdrawal, principal tokens are redeemed through the Element protocol. Once redeemed, the base assets are transferred and consolidated to the main treasury.", + }, + 3: { + descriptionURL: + "https://docs.google.com/document/d/17tR4ZjibQyfAma3QfklA0FTA4n6S3lGEXXWMzT75K-k/edit", + targets: [], + calldatas: [], + title: "EGP-16: GSC-2", + sentenceSummary: + "Revised proposal to unwind the GSC treasury, transferring the unwound assets to the main treasury.", + paragraphSummary: + "The GSC treasury is a gnosis safe currently containing 24 different assets in balancer pools. These assets came from protocol fees traded on the Element protocol. In order to unwind the assets, the LP positions are withdrawn from the Balancer pools. After withdrawal, principal tokens are redeemed through the Element protocol. Once redeemed, the base assets are transferred and consolidated to the main treasury.", + }, + 4: { + descriptionURL: + "https://docs.google.com/document/d/17tR4ZjibQyfAma3QfklA0FTA4n6S3lGEXXWMzT75K-k/edit", + targets: [], + calldatas: [], + title: "EGP-16: GSC-3", + sentenceSummary: + "Revised proposal to unwind the GSC treasury, transferring the unwound assets to the main treasury.", + paragraphSummary: + "The GSC treasury is a gnosis safe currently containing 24 different assets in balancer pools. These assets came from protocol fees traded on the Element protocol. In order to unwind the assets, the LP positions are withdrawn from the Balancer pools. After withdrawal, principal tokens are redeemed through the Element protocol. Once redeemed, the base assets are transferred and consolidated to the main treasury.", + }, + 5: { + descriptionURL: + "https://docs.google.com/document/d/17tR4ZjibQyfAma3QfklA0FTA4n6S3lGEXXWMzT75K-k/edit", + targets: [], + calldatas: [], + title: "EGP-16: GSC-4", + sentenceSummary: + "Revised proposal to unwind the GSC treasury, transferring the unwound assets to the main treasury.", + paragraphSummary: + "The GSC treasury is a gnosis safe currently containing 24 different assets in balancer pools. These assets came from protocol fees traded on the Element protocol. In order to unwind the assets, the LP positions are withdrawn from the Balancer pools. After withdrawal, principal tokens are redeemed through the Element protocol. Once redeemed, the base assets are transferred and consolidated to the main treasury.", }, - ], - proposals: { - 0: { descriptionURL: "", targets: [], calldatas: [] }, - 1: { descriptionURL: "", targets: [], calldatas: [] }, }, }, diff --git a/apps/council-ui/src/ens/getBulkEnsRecords.ts b/apps/council-ui/src/ens/getBulkEnsRecords.ts deleted file mode 100644 index cd5a9dac..00000000 --- a/apps/council-ui/src/ens/getBulkEnsRecords.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ENS } from "@ensdomains/ensjs"; -import { providers } from "ethers"; -import chunk from "lodash.chunk"; - -export type EnsRecords = Record; - -/** - * Fetches ENS names in bulk using MultiCall. - * Some addresses may not get resolved if the gas limit of the chunk was reached. - * This size can be tweaked the options. - * @param {Array} addresses - An array of addresses. - * @param {providers.Provider} addresses - Ethers provider. - * @returns {Record} A record of addresses to ens name. The name is nullable. - */ -export async function getBulkEnsRecords( - addresses: string[], - provider: providers.Provider, - options?: { chunkSize?: number }, -): Promise { - const ENSInstance = new ENS(); - await ENSInstance.setProvider(provider as providers.JsonRpcProvider); // safe to cast - - // spit array in chunks to paginate bulk requests - const chunkedAddresses = chunk(addresses, options?.chunkSize ?? 100); - - // fetch each paginated request - const chunkedResults = await Promise.all( - chunkedAddresses.map(async (chunk): Promise<[string, string | null][]> => { - // batch call of ens names using MultiCall - const batch = await ENSInstance.batch( - ...chunk.map((address) => { - return ENSInstance.getName.batch(address); - }), - ); - - // batch may not exist if gas limited was reached when reading - // TODO @cashd: investigate why certain addresses require more gas to read than others, by 10x deviation. - if (batch) { - return chunk.map((address, i) => { - return [address, batch[i]?.name ?? null]; - }); - } - - return chunk.map((address) => [address, null]); - }), - ); - - // construct the record - const records = Object.fromEntries(chunkedResults.flat()); - return records; -} diff --git a/apps/council-ui/src/lib/councilSdk.ts b/apps/council-ui/src/lib/councilSdk.ts new file mode 100644 index 00000000..aa14c4b6 --- /dev/null +++ b/apps/council-ui/src/lib/councilSdk.ts @@ -0,0 +1,3 @@ +import { createLruSimpleCache } from "@delvtech/council-viem"; + +export const sdkCache = createLruSimpleCache({ max: 500 }); diff --git a/apps/council-ui/src/lib/rainbowKit.ts b/apps/council-ui/src/lib/rainbowKit.ts new file mode 100644 index 00000000..7d3c5581 --- /dev/null +++ b/apps/council-ui/src/lib/rainbowKit.ts @@ -0,0 +1,17 @@ +import { getDefaultConfig } from "@rainbow-me/rainbowkit"; +import { chains, transports } from "src/lib/wagmi"; + +const projectId = process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID; + +if (!projectId) { + throw new Error( + "Missing WalletConnect project ID. Please set the NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID variable in your environment.", + ); +} + +export const wagmiConfig = getDefaultConfig({ + appName: "Council", + projectId, + chains: chains as any, + transports, +}); diff --git a/apps/council-ui/src/clients/reactQuery.ts b/apps/council-ui/src/lib/reactQuery.ts similarity index 100% rename from apps/council-ui/src/clients/reactQuery.ts rename to apps/council-ui/src/lib/reactQuery.ts diff --git a/apps/council-ui/src/lib/wagmi.ts b/apps/council-ui/src/lib/wagmi.ts new file mode 100644 index 00000000..c63d227e --- /dev/null +++ b/apps/council-ui/src/lib/wagmi.ts @@ -0,0 +1,23 @@ +import { councilConfigs } from "src/config/council.config"; +import { http } from "wagmi"; +import { goerli, hardhat, localhost, mainnet } from "wagmi/chains"; + +const configuredChainIds = Object.keys(councilConfigs); + +const allChains = [mainnet, goerli, hardhat, localhost]; +const rpcUrlsByChainId: Record = { + 1: process.env.NEXT_PUBLIC_MAINNET_RPC_URL, + 5: process.env.NEXT_PUBLIC_GOERLI_RPC_URL, + 1337: process.env.NEXT_PUBLIC_LOCAL_RPC_URL, + 31337: process.env.NEXT_PUBLIC_LOCAL_RPC_URL, +}; + +export const chains = Object.values(allChains).filter(({ id }) => + configuredChainIds.includes(String(id)), +); + +export const transports = Object.fromEntries( + chains.map(({ id }) => { + return [id, http(rpcUrlsByChainId[id])]; + }), +); diff --git a/apps/council-ui/src/provider.ts b/apps/council-ui/src/provider.ts deleted file mode 100644 index f2f2e598..00000000 --- a/apps/council-ui/src/provider.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { councilConfigs } from "src/config/council.config"; -import { allChains, ChainProviderFn, configureChains } from "wagmi"; -import { alchemyProvider } from "wagmi/providers/alchemy"; -import { jsonRpcProvider } from "wagmi/providers/jsonRpc"; - -const supportedChainIds = Object.keys(councilConfigs); - -const configuredChains = allChains.filter((chain) => { - // Wagmi has 2 chains with the 31337 id, Hardhat and Foundry. Calling - // configureChains with 2 chains using the same id will cause errors, so we - // filter them out here to make room for a custom one. - return chain.id !== 31337 && supportedChainIds.includes(`${chain.id}`); -}); - -// Add a general Localhost chain for 31337 if in development -if ( - supportedChainIds.includes("31337") && - process.env.NODE_ENV === "development" -) { - configuredChains.push({ - id: 31337, - name: "Localhost", - network: "localhost", - rpcUrls: { - default: process.env.NEXT_PUBLIC_LOCAL_RPC_URL || "http://127.0.0.1:8545", - }, - }); -} - -const configuredProviders = configuredChains.map((chain) => { - if (chain.id === 1) { - const mainnetAlchemyKey = process.env.NEXT_PUBLIC_MAINNET_ALCHEMY_KEY; - if (!mainnetAlchemyKey) { - console.error( - "Chain ID 1 (mainnet) exists in council.config.ts, but no provider was given, see .env", - ); - } - return alchemyProvider({ - apiKey: process.env.NEXT_PUBLIC_MAINNET_ALCHEMY_KEY, - }); - } - - if (chain.id === 5) { - const goerliAlchemyKey = process.env.NEXT_PUBLIC_GOERLI_ALCHEMY_KEY; - if (!goerliAlchemyKey) { - console.error( - "Chain ID 5 (goerli) exists in council.config.ts, but no provider was given, see .env", - ); - } - return alchemyProvider({ - apiKey: process.env.NEXT_PUBLIC_GOERLI_ALCHEMY_KEY, - }); - } - - if (chain.id === 31337) { - const provider = jsonRpcProvider({ - rpc: () => ({ http: chain.rpcUrls.default }), - }); - return provider; - } -}) as ChainProviderFn[]; // safe to cast - -/** - * Use configureChains from wagmi to specify providers for each chain at - * config-time. - * - * See: https://wagmi.sh/docs/providers/configuring-chains - */ -export const { provider, chains } = configureChains( - configuredChains, - // If a provider does not support a chain, it will fall back onto the next one - // in the array. - configuredProviders, -); diff --git a/apps/council-ui/src/routes.ts b/apps/council-ui/src/routes.ts index 1594669b..107cd068 100644 --- a/apps/council-ui/src/routes.ts +++ b/apps/council-ui/src/routes.ts @@ -7,14 +7,14 @@ export enum Routes { } export function makeProposalURL( - votingContractAddress: string, - id: number, + votingContractAddress: `0x${string}`, + id: bigint | number, ): UrlObject { return { pathname: "/proposals/details", query: { votingContract: votingContractAddress, - id, + id: String(id), }, }; } diff --git a/apps/council-ui/src/ui/airdrop/ClaimStep.tsx b/apps/council-ui/src/ui/airdrop/ClaimStep.tsx index cd0d1311..f09efb15 100644 --- a/apps/council-ui/src/ui/airdrop/ClaimStep.tsx +++ b/apps/council-ui/src/ui/airdrop/ClaimStep.tsx @@ -2,7 +2,7 @@ import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/20/solid"; import { ReactElement } from "react"; interface ClaimStepProps { - recipient: string; + recipient: string | undefined; setRecipient: (address: string) => void; onBack: () => void; onNext: () => void; @@ -21,7 +21,7 @@ export default function ClaimStep({ Address

-
diff --git a/apps/council-ui/src/ui/airdrop/ConfirmClaimStep.tsx b/apps/council-ui/src/ui/airdrop/ConfirmClaimStep.tsx index f75cd46a..beae3757 100644 --- a/apps/council-ui/src/ui/airdrop/ConfirmClaimStep.tsx +++ b/apps/council-ui/src/ui/airdrop/ConfirmClaimStep.tsx @@ -8,7 +8,7 @@ import { useAirdropToken } from "./hooks/useAirdropToken"; import { useClaimableAirdropAmount } from "./hooks/useClaimableAirdropAmount"; interface ConfirmClaimStepProps { - recipient: string; + recipient: `0x${string}` | undefined; onBack: () => void; onConfirm: (() => void) | undefined; } @@ -18,19 +18,19 @@ export default function ConfirmClaimStep({ onBack, onConfirm, }: ConfirmClaimStepProps): ReactElement { - const { data: claimableAmount } = useClaimableAirdropAmount(); - const { data: token } = useAirdropToken(); - const { data: symbol } = useTokenSymbol(token?.address); - + const { claimableAmountFormatted } = useClaimableAirdropAmount(); + const { airdropToken } = useAirdropToken(); + const { symbol } = useTokenSymbol(airdropToken?.address); const displayName = useDisplayName(recipient); + return ( <>
Send
- {claimableAmount && symbol ? ( - `${formatBalance(claimableAmount, 4)} ${symbol}` + {claimableAmountFormatted && symbol ? ( + `${formatBalance(claimableAmountFormatted, 4)} ${symbol}` ) : ( )} @@ -42,16 +42,16 @@ export default function ConfirmClaimStep({
-
diff --git a/apps/council-ui/src/ui/airdrop/ConfirmDepositStep.tsx b/apps/council-ui/src/ui/airdrop/ConfirmDepositStep.tsx index 2e28c540..e493c079 100644 --- a/apps/council-ui/src/ui/airdrop/ConfirmDepositStep.tsx +++ b/apps/council-ui/src/ui/airdrop/ConfirmDepositStep.tsx @@ -8,8 +8,8 @@ import { useAirdropToken } from "./hooks/useAirdropToken"; import { useClaimableAirdropAmount } from "./hooks/useClaimableAirdropAmount"; interface ConfirmDepositStepProps { - account: string; - delegate?: string; + account: `0x${string}` | undefined; + delegate?: `0x${string}`; onBack: () => void; onConfirm: (() => void) | undefined; } @@ -20,9 +20,9 @@ export default function ConfirmDepositStep({ onBack, onConfirm, }: ConfirmDepositStepProps): ReactElement { - const { data: claimableAmount } = useClaimableAirdropAmount(); - const { data: token } = useAirdropToken(); - const { data: symbol } = useTokenSymbol(token?.address); + const { claimableAmountFormatted } = useClaimableAirdropAmount(); + const { airdropToken } = useAirdropToken(); + const { symbol } = useTokenSymbol(airdropToken); const displayName = useDisplayName(account); const delegateDisplayName = useDisplayName(delegate); @@ -33,8 +33,8 @@ export default function ConfirmDepositStep({
Deposit
- {claimableAmount && symbol ? ( - `${formatBalance(claimableAmount, 4)} ${symbol}` + {claimableAmountFormatted && symbol ? ( + `${formatBalance(claimableAmountFormatted, 4)} ${symbol}` ) : ( )} @@ -52,16 +52,16 @@ export default function ConfirmDepositStep({ )}
-
diff --git a/apps/council-ui/src/ui/airdrop/DepositOrClaimStep.tsx b/apps/council-ui/src/ui/airdrop/DepositOrClaimStep.tsx index 0100699f..ac5ddeef 100644 --- a/apps/council-ui/src/ui/airdrop/DepositOrClaimStep.tsx +++ b/apps/council-ui/src/ui/airdrop/DepositOrClaimStep.tsx @@ -1,11 +1,11 @@ import { BuildingLibraryIcon, WalletIcon } from "@heroicons/react/20/solid"; import { ReactElement } from "react"; import Skeleton from "react-loading-skeleton"; +import { useAirdropToken } from "src/ui/airdrop/hooks/useAirdropToken"; +import { useClaimableAirdropAmount } from "src/ui/airdrop/hooks/useClaimableAirdropAmount"; import { formatBalance } from "src/ui/base/formatting/formatBalance"; import { AirdropIcon } from "src/ui/base/svg/24/AirdropIcon"; import { useTokenSymbol } from "src/ui/token/hooks/useTokenSymbol"; -import { useAirdropToken } from "./hooks/useAirdropToken"; -import { useClaimableAirdropAmount } from "./hooks/useClaimableAirdropAmount"; interface DepositOrClaimStepProps { onDeposit: (() => void) | undefined; @@ -16,9 +16,9 @@ export default function DepositOrClaimStep({ onDeposit, onClaim, }: DepositOrClaimStepProps): ReactElement { - const { data: claimableAmount } = useClaimableAirdropAmount(); - const { data: token } = useAirdropToken(); - const { data: symbol } = useTokenSymbol(token?.address); + const { claimableAmountFormatted } = useClaimableAirdropAmount(); + const { airdropToken } = useAirdropToken(); + const { symbol } = useTokenSymbol(airdropToken); return ( <> @@ -27,16 +27,16 @@ export default function DepositOrClaimStep({
- + - {claimableAmount && symbol ? ( - formatBalance(claimableAmount, 4) + {claimableAmountFormatted && symbol ? ( + formatBalance(claimableAmountFormatted, 4) ) : ( )} - {symbol} + {symbol}
@@ -50,19 +50,19 @@ export default function DepositOrClaimStep({

diff --git a/apps/council-ui/src/ui/airdrop/DepositStep.tsx b/apps/council-ui/src/ui/airdrop/DepositStep.tsx index c3430d6d..bcce0fcb 100644 --- a/apps/council-ui/src/ui/airdrop/DepositStep.tsx +++ b/apps/council-ui/src/ui/airdrop/DepositStep.tsx @@ -2,7 +2,7 @@ import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/20/solid"; import { ReactElement } from "react"; interface DepositStepProps { - account: string; + account: string | undefined; setAccount: (account: string) => void; delegate?: string; setDelegate?: (delegate: string) => void; diff --git a/apps/council-ui/src/ui/airdrop/hooks/useAirdropData.ts b/apps/council-ui/src/ui/airdrop/hooks/useAirdropData.ts index fa3a80a3..8ecf9bd2 100644 --- a/apps/council-ui/src/ui/airdrop/hooks/useAirdropData.ts +++ b/apps/council-ui/src/ui/airdrop/hooks/useAirdropData.ts @@ -1,21 +1,34 @@ -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { AirdropData, getAirdropData } from "src/airdrop/getAirdropData"; -import { useChainId } from "src/ui/network/useChainId"; +import { QueryStatus, useQuery } from "@tanstack/react-query"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; +import { AirdropData, getAirdropData } from "src/utils/getAirdropData"; import { useAccount } from "wagmi"; /** * Fetch the data needed to claim an airdrop for the connected wallet address. * If the address doesn't have an airdrop, `airdropData` will be `undefined`. */ -export function useAirdropData(): UseQueryResult { +export function useAirdropData(): { + airdropData: AirdropData | undefined; + status: QueryStatus; +} { const { address } = useAccount(); - const chainId = useChainId(); + const chainId = useSupportedChainId(); const enabled = !!address && !!chainId; - return useQuery({ - queryKey: ["airdropData", address, chainId], + const { data, status } = useQuery({ + queryKey: ["useAirdropData", address, chainId], enabled, - queryFn: enabled ? () => getAirdropData(address, chainId) : undefined, + queryFn: enabled + ? async () => { + const data = await getAirdropData(address, chainId); + return data || null; + } + : undefined, }); + + return { + airdropData: data || undefined, + status, + }; } diff --git a/apps/council-ui/src/ui/airdrop/hooks/useAirdropLockingVault.ts b/apps/council-ui/src/ui/airdrop/hooks/useAirdropLockingVault.ts index a5f5f817..25ef47f3 100644 --- a/apps/council-ui/src/ui/airdrop/hooks/useAirdropLockingVault.ts +++ b/apps/council-ui/src/ui/airdrop/hooks/useAirdropLockingVault.ts @@ -1,17 +1,24 @@ -import { LockingVault } from "@council/sdk"; -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { useCouncil } from "src/ui/council/useCouncil"; +import { ReadLockingVault } from "@delvtech/council-viem"; +import { QueryStatus, useQuery } from "@tanstack/react-query"; +import { useReadAirdrop } from "src/ui/airdrop/hooks/useReadAirdrop"; /** - * Fetch the locking vault for the airdrop. + * Fetch the locking vault for the configured airdrop. */ -export function useAirdropLockingVault(): UseQueryResult< - LockingVault | undefined -> { - const { airdrop } = useCouncil(); - return useQuery({ - queryKey: ["airdropLockingVault", airdrop?.address], +export function useAirdropVault(): { + airdropVault: ReadLockingVault | undefined; + status: QueryStatus; +} { + const airdrop = useReadAirdrop(); + + const { data, status } = useQuery({ + queryKey: ["useAirdropLockingVault"], enabled: !!airdrop, queryFn: !!airdrop ? () => airdrop.getLockingVault() : undefined, }); + + return { + airdropVault: data, + status, + }; } diff --git a/apps/council-ui/src/ui/airdrop/hooks/useAirdropToken.ts b/apps/council-ui/src/ui/airdrop/hooks/useAirdropToken.ts index c32a54f3..7dbab790 100644 --- a/apps/council-ui/src/ui/airdrop/hooks/useAirdropToken.ts +++ b/apps/council-ui/src/ui/airdrop/hooks/useAirdropToken.ts @@ -1,15 +1,24 @@ -import { Token } from "@council/sdk"; -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { useCouncil } from "src/ui/council/useCouncil"; +import { ReadToken } from "@delvtech/council-viem"; +import { QueryStatus, useQuery } from "@tanstack/react-query"; +import { useReadAirdrop } from "src/ui/airdrop/hooks/useReadAirdrop"; /** - * Fetch the token for the airdrop. + * Fetch the token for the configured airdrop. */ -export function useAirdropToken(): UseQueryResult { - const { airdrop } = useCouncil(); - return useQuery({ +export function useAirdropToken(): { + airdropToken: ReadToken | undefined; + status: QueryStatus; +} { + const airdrop = useReadAirdrop(); + + const { data, status } = useQuery({ queryKey: ["airdropToken", airdrop?.address], enabled: !!airdrop, queryFn: !!airdrop ? () => airdrop.getToken() : undefined, }); + + return { + airdropToken: data, + status, + }; } diff --git a/apps/council-ui/src/ui/airdrop/hooks/useClaimAirdrop.ts b/apps/council-ui/src/ui/airdrop/hooks/useClaimAirdrop.ts index 4e9dff0a..acebe39b 100644 --- a/apps/council-ui/src/ui/airdrop/hooks/useClaimAirdrop.ts +++ b/apps/council-ui/src/ui/airdrop/hooks/useClaimAirdrop.ts @@ -1,72 +1,42 @@ -import { UseMutationResult, useQueryClient } from "@tanstack/react-query"; -import { Signer } from "ethers"; -import { makeTransactionErrorToast } from "src/ui/base/toast/makeTransactionErrorToast"; -import { makeTransactionSubmittedToast } from "src/ui/base/toast/makeTransactionSubmittedToast"; -import { makeTransactionSuccessToast } from "src/ui/base/toast/makeTransactionSuccessToast"; -import { useCouncil } from "src/ui/council/useCouncil"; -import { useChainId } from "src/ui/network/useChainId"; -import { useMutation } from "wagmi"; +import { MutationStatus } from "@tanstack/react-query"; +import { useReadWriteAirdrop } from "src/ui/airdrop/hooks/useReadWriteAirdrop"; +import { useWrite } from "src/ui/contract/hooks/useWrite"; import { useAirdropData } from "./useAirdropData"; import { useClaimableAirdropAmount } from "./useClaimableAirdropAmount"; -interface ClaimArguments { - signer: Signer; - recipient: string; +interface ClaimOptions { + recipient: `0x${string}`; } -export function useClaimAirdrop(): UseMutationResult< - string, - unknown, - ClaimArguments -> { - const { airdrop } = useCouncil(); - const { data: claimableAmount } = useClaimableAirdropAmount(); - const { data } = useAirdropData(); - const chainId = useChainId(); - const queryClient = useQueryClient(); +export function useClaimAirdrop(): { + claimAirdrop: ((options: ClaimOptions) => void) | undefined; + transactionHash: `0x${string}` | undefined; + status: MutationStatus; +} { + const airdrop = useReadWriteAirdrop(); + const { airdropData } = useAirdropData(); + const { claimableAmount } = useClaimableAirdropAmount(); - let transactionHash: string; - return useMutation({ - mutationFn: ({ signer, recipient }: ClaimArguments) => { - if (!airdrop) { - throw new Error("No airdrop configured"); - } - if (!claimableAmount) { - throw new Error("No claimable amount"); - } - if (!data) { - throw new Error("No airdrop data found"); + const enabled = !!airdrop && !!claimableAmount && !!airdropData; + + const { write, status, transactionHash } = useWrite({ + writeFn: ({ recipient }: ClaimOptions) => { + if (!enabled) { + throw new Error("No claimable airdrop found"); } - return airdrop.claim( - signer, - claimableAmount, - data?.amount, - data?.proof, + return airdrop.claim({ + amount: claimableAmount, + merkleProof: airdropData.proof, recipient, - { - onSubmitted: (hash) => { - makeTransactionSubmittedToast("Claiming airdrop", hash, chainId); - transactionHash = hash; - }, - }, - ); - }, - onSuccess: (hash) => { - makeTransactionSuccessToast( - `Successfully claimed airdrop!`, - hash, - chainId, - ); - queryClient.invalidateQueries(); - }, - onError(error) { - makeTransactionErrorToast( - `Failed to claim airdrop`, - transactionHash, - chainId, - ); - console.error(error); + totalGrant: airdropData.amount, + }); }, }); + + return { + claimAirdrop: enabled ? write : undefined, + transactionHash, + status, + }; } diff --git a/apps/council-ui/src/ui/airdrop/hooks/useClaimAndDelegateAirdrop.ts b/apps/council-ui/src/ui/airdrop/hooks/useClaimAndDelegateAirdrop.ts index 932daeed..29a440ba 100644 --- a/apps/council-ui/src/ui/airdrop/hooks/useClaimAndDelegateAirdrop.ts +++ b/apps/council-ui/src/ui/airdrop/hooks/useClaimAndDelegateAirdrop.ts @@ -1,78 +1,46 @@ -import { UseMutationResult, useQueryClient } from "@tanstack/react-query"; -import { Signer } from "ethers"; -import { makeTransactionErrorToast } from "src/ui/base/toast/makeTransactionErrorToast"; -import { makeTransactionSubmittedToast } from "src/ui/base/toast/makeTransactionSubmittedToast"; -import { makeTransactionSuccessToast } from "src/ui/base/toast/makeTransactionSuccessToast"; -import { useCouncil } from "src/ui/council/useCouncil"; -import { useChainId } from "src/ui/network/useChainId"; -import { useMutation } from "wagmi"; +import { MutationStatus } from "@tanstack/react-query"; +import { useReadWriteAirdrop } from "src/ui/airdrop/hooks/useReadWriteAirdrop"; +import { useWrite } from "src/ui/contract/hooks/useWrite"; import { useAirdropData } from "./useAirdropData"; import { useClaimableAirdropAmount } from "./useClaimableAirdropAmount"; -interface ClaimAndDelegateArguments { - signer: Signer; - delegate: string; - recipient: string; +interface ClaimAndDelegateOptions { + delegate: `0x${string}`; + recipient: `0x${string}`; } -export function useClaimAndDelegateAirdrop(): UseMutationResult< - string, - unknown, - ClaimAndDelegateArguments -> { - const { airdrop } = useCouncil(); - const { data: claimableAmount } = useClaimableAirdropAmount(); - const { data } = useAirdropData(); - const chainId = useChainId(); - const queryClient = useQueryClient(); +export function useClaimAndDelegateAirdrop(): { + claimAndDelegateAirdrop: + | ((options: ClaimAndDelegateOptions) => void) + | undefined; + transactionHash: `0x${string}` | undefined; + status: MutationStatus; +} { + const airdrop = useReadWriteAirdrop(); + const { airdropData } = useAirdropData(); + const { claimableAmount } = useClaimableAirdropAmount(); - let transactionHash: string; - return useMutation({ - mutationFn: ({ - signer, - delegate, - recipient, - }: ClaimAndDelegateArguments) => { - if (!airdrop) { - throw new Error("No airdrop configured"); - } - if (!claimableAmount) { - throw new Error("No claimable amount"); - } - if (!data) { - throw new Error("No airdrop data found"); + const enabled = !!airdrop && !!claimableAmount && !!airdropData; + + const { write, status, transactionHash } = useWrite({ + writeFn: ({ delegate, recipient }: ClaimAndDelegateOptions) => { + if (!enabled) { + throw new Error("No claimable airdrop found"); } - return airdrop.claimAndDelegate( - signer, - claimableAmount, + return airdrop.claimAndDelegate({ + amount: claimableAmount, delegate, - data?.amount, - data?.proof, + merkleProof: airdropData.proof, recipient, - { - onSubmitted: (hash) => { - makeTransactionSubmittedToast("Claiming airdrop", hash, chainId); - transactionHash = hash; - }, - }, - ); - }, - onSuccess: (hash) => { - makeTransactionSuccessToast( - `Successfully claimed airdrop!`, - hash, - chainId, - ); - queryClient.invalidateQueries(); - }, - onError(error) { - makeTransactionErrorToast( - `Failed to claim airdrop`, - transactionHash, - chainId, - ); - console.error(error); + totalGrant: airdropData.amount, + }); }, }); + + return { + claimAndDelegateAirdrop: enabled ? write : undefined, + transactionHash, + status, + }; } diff --git a/apps/council-ui/src/ui/airdrop/hooks/useClaimableAirdropAmount.ts b/apps/council-ui/src/ui/airdrop/hooks/useClaimableAirdropAmount.ts index c92fe3d5..2944987a 100644 --- a/apps/council-ui/src/ui/airdrop/hooks/useClaimableAirdropAmount.ts +++ b/apps/council-ui/src/ui/airdrop/hooks/useClaimableAirdropAmount.ts @@ -1,39 +1,55 @@ -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { formatUnits, parseUnits } from "ethers/lib/utils"; -import { useCouncil } from "src/ui/council/useCouncil"; +import { QueryStatus, useQuery } from "@tanstack/react-query"; +import { useReadAirdrop } from "src/ui/airdrop/hooks/useReadAirdrop"; +import { useTokenDecimals } from "src/ui/token/hooks/useTokenDecimals"; +import { formatUnits } from "viem"; import { useAccount } from "wagmi"; import { useAirdropData } from "./useAirdropData"; +import { useAirdropToken } from "./useAirdropToken"; /** - * Fetch the amount of an airdrop that can still be claimed by the connected - * wallet address. + * Fetch the amount that can still be claimed from the configured airdrop by the + * connected wallet address. */ -export function useClaimableAirdropAmount(): UseQueryResult { - const { airdrop } = useCouncil(); - const { address } = useAccount(); - const { data, status } = useAirdropData(); +export function useClaimableAirdropAmount(): { + claimableAmount: bigint; + claimableAmountFormatted: `${number}`; + status: QueryStatus; +} { + const airdrop = useReadAirdrop(); + const { address: account } = useAccount(); + const { airdropData, status: dataStatus } = useAirdropData(); + const { airdropToken } = useAirdropToken(); + const { decimals } = useTokenDecimals(airdropToken); - const enabled = !!airdrop && !!address && status !== "loading"; + const enabled = !!airdrop && !!account && dataStatus === "success"; - return useQuery({ - queryKey: ["claimableAirdropAmount", airdrop?.address, address, data], + const { data, status } = useQuery({ + queryKey: [ + "useClaimableAirdropAmount", + airdrop?.address, + account, + airdropData, + ], enabled, queryFn: enabled ? async () => { - if (!data || !+data.amount) { - return "0"; + if (!airdropData || !airdropData.amount) { + return 0n; } - - const claimed = await airdrop.getClaimedAmount(address); - const token = await airdrop.getToken(); - const decimals = await token.getDecimals(); - - const amountBigNumber = parseUnits(data.amount, decimals); - const claimedBigNumber = parseUnits(claimed, decimals); - const claimableBigNumber = amountBigNumber.sub(claimedBigNumber); - - return formatUnits(claimableBigNumber, decimals); + const claimed = await airdrop.getClaimedAmount({ account }); + return airdropData.amount - claimed; } : undefined, }); + + const claimableAmountFormatted = + data !== undefined && decimals !== undefined + ? formatUnits(data, decimals) + : "0"; + + return { + claimableAmount: data ?? 0n, + claimableAmountFormatted: claimableAmountFormatted as `${number}`, + status, + }; } diff --git a/apps/council-ui/src/ui/airdrop/hooks/useReadAirdrop.ts b/apps/council-ui/src/ui/airdrop/hooks/useReadAirdrop.ts new file mode 100644 index 00000000..776755a5 --- /dev/null +++ b/apps/council-ui/src/ui/airdrop/hooks/useReadAirdrop.ts @@ -0,0 +1,17 @@ +import { ReadAirdrop } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadCouncil } from "src/ui/council/hooks/useReadCouncil"; + +/** + * Use a ReadAirdrop instance for configured airdrop. + */ +export function useReadAirdrop(): ReadAirdrop | undefined { + const { airdrop } = useCouncilConfig(); + const council = useReadCouncil(); + + return useMemo( + () => airdrop && council.airdrop(airdrop.address), + [council, airdrop], + ); +} diff --git a/apps/council-ui/src/ui/airdrop/hooks/useReadWriteAirdrop.ts b/apps/council-ui/src/ui/airdrop/hooks/useReadWriteAirdrop.ts new file mode 100644 index 00000000..fd46a082 --- /dev/null +++ b/apps/council-ui/src/ui/airdrop/hooks/useReadWriteAirdrop.ts @@ -0,0 +1,17 @@ +import { ReadWriteAirdrop } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadWriteCouncil } from "src/ui/council/hooks/useReadWriteCouncil"; + +/** + * Use a ReadWriteAirdrop instance for configured airdrop. + */ +export function useReadWriteAirdrop(): ReadWriteAirdrop | undefined { + const { airdrop } = useCouncilConfig(); + const council = useReadWriteCouncil(); + + return useMemo( + () => airdrop && council?.airdrop(airdrop.address), + [council, airdrop], + ); +} diff --git a/apps/council-ui/src/ui/app.tsx b/apps/council-ui/src/ui/app.tsx index 1499b114..7b874579 100644 --- a/apps/council-ui/src/ui/app.tsx +++ b/apps/council-ui/src/ui/app.tsx @@ -3,39 +3,27 @@ import { QueryClientProvider } from "@tanstack/react-query"; import type { AppProps } from "next/app"; import { ReactElement } from "react"; import { Toaster } from "react-hot-toast"; -import { Tooltip, TooltipProvider } from "react-tooltip"; -import { reactQueryClient } from "src/clients/reactQuery"; -import { wagmiClient } from "src/clients/wagmi"; import { councilConfigs } from "src/config/council.config"; -import { chains } from "src/provider"; -import { CouncilClientProvider } from "src/ui/council/CouncilProvider"; +import { wagmiConfig } from "src/lib/rainbowKit"; +import { reactQueryClient } from "src/lib/reactQuery"; import { Navigation } from "src/ui/navigation/Navigation"; -import { WagmiConfig } from "wagmi"; +import { WagmiProvider } from "wagmi"; console.log(councilConfigs); function App({ Component, pageProps }: AppProps): ReactElement { return ( - - - - - - - -
- -
- {/* Share a single tooltip for the entire app to avoid nasty - coupling of tooltip and the wrapped component via an `id` prop. - This follows the recipe in - https://react-tooltip.com/docs/examples/multiple-anchors */} - -
-
-
-
-
+ + + + + +
+ +
+
+
+
); } diff --git a/apps/council-ui/src/ui/base/Address.tsx b/apps/council-ui/src/ui/base/Address.tsx index ece73cf4..6621d9bf 100644 --- a/apps/council-ui/src/ui/base/Address.tsx +++ b/apps/council-ui/src/ui/base/Address.tsx @@ -4,7 +4,7 @@ import { formatAddress } from "src/ui/base/formatting/formatAddress"; import { WalletIcon } from "src/ui/base/WalletIcon"; interface AddressProps { - address: string; + address: `0x${string}`; /** * If provided this will be rendered instead of the formatted address. */ diff --git a/apps/council-ui/src/ui/base/Page.tsx b/apps/council-ui/src/ui/base/Page.tsx index 9413f27d..82edb5b3 100644 --- a/apps/council-ui/src/ui/base/Page.tsx +++ b/apps/council-ui/src/ui/base/Page.tsx @@ -12,10 +12,13 @@ export function Page({ }: PropsWithChildren): ReactElement { return ( // https://daisyui.com/docs/colors - +
diff --git a/apps/council-ui/src/ui/base/Tooltip.tsx b/apps/council-ui/src/ui/base/Tooltip.tsx new file mode 100644 index 00000000..938a1c43 --- /dev/null +++ b/apps/council-ui/src/ui/base/Tooltip.tsx @@ -0,0 +1,59 @@ +import classNames from "classnames"; +import { ReactElement } from "react"; +// TODO: Add dependency-cruiser rule to enforce nobody imports directly from +// react-tooltip outside of this file and app.tsx. +import { + Tooltip as BaseTooltip, + PlacesType, + PositionStrategy, + VariantType, +} from "react-tooltip"; + +export interface ToolTipOptions + extends React.AnchorHTMLAttributes { + content: string; + place?: PlacesType; + positionStrategy?: PositionStrategy; + variant?: VariantType; +} + +// Re-exporting to simplify the API and enforce proper usage. +export function Tooltip({ + content, + place = "top", + positionStrategy, + variant, + children, + ...passThruProps +}: ToolTipOptions): ReactElement { + return ( + <> + + {children} + + + + ); +} + +export function DefinitionTooltip({ + className, + ...passThruProps +}: ToolTipOptions): ReactElement { + return ( + + ); +} diff --git a/apps/council-ui/src/ui/base/Tooltip/Tooltip.tsx b/apps/council-ui/src/ui/base/Tooltip/Tooltip.tsx deleted file mode 100644 index a56c2116..00000000 --- a/apps/council-ui/src/ui/base/Tooltip/Tooltip.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import classNames from "classnames"; -import { ReactElement } from "react"; -// TODO: Add dependency-cruiser rule to enforce nobody imports directly from -// react-tooltip outside of this file and app.tsx. -import { ITooltipWrapper, TooltipWrapper } from "react-tooltip"; - -// Re-exporting for naming only is usually bad, but in this case it's worth it -// since TooltipWrapper isn't intuitive to remember and react-tooltip has a -// `Tooltip` component that we *shouldn't use*. -export const Tooltip = TooltipWrapper; - -export function DefinitionTooltip({ - className, - ...passThruProps -}: ITooltipWrapper): ReactElement { - return ( - - ); -} diff --git a/apps/council-ui/src/ui/base/formatting/commify.ts b/apps/council-ui/src/ui/base/formatting/commify.ts new file mode 100644 index 00000000..210d5f6a --- /dev/null +++ b/apps/council-ui/src/ui/base/formatting/commify.ts @@ -0,0 +1,3 @@ +export function commify(value: string | number | bigint): string { + return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} diff --git a/apps/council-ui/src/ui/base/formatting/formatAddress.ts b/apps/council-ui/src/ui/base/formatting/formatAddress.ts index e761e96b..cb527555 100644 --- a/apps/council-ui/src/ui/base/formatting/formatAddress.ts +++ b/apps/council-ui/src/ui/base/formatting/formatAddress.ts @@ -1,4 +1,4 @@ -export function formatAddress(address: string): string { +export function formatAddress(address: `0x${string}`): string { // first 2 and last 4 to match rainbowkit's style return `0x${address.slice(2, 4)}...${address.slice(-4)}`; } diff --git a/apps/council-ui/src/ui/base/formatting/formatBalance.ts b/apps/council-ui/src/ui/base/formatting/formatBalance.ts index 7b5156e5..648003d0 100644 --- a/apps/council-ui/src/ui/base/formatting/formatBalance.ts +++ b/apps/council-ui/src/ui/base/formatting/formatBalance.ts @@ -1,5 +1,5 @@ import { format } from "d3-format"; -import { commify } from "ethers/lib/utils"; +import { commify } from "src/ui/base/formatting/commify"; /** * Used for final balance presentation since it cuts off decimals @@ -8,8 +8,8 @@ import { commify } from "ethers/lib/utils"; * @returns a formatted string with proper commas and {numDecimals} decimal places */ export function formatBalance( - balance: string | number, + balance: string | number | bigint, numDecimals = 1, -): string { - return commify(format(`.${numDecimals}~f`)(+balance)); +): `${number}` { + return commify(format(`.${numDecimals}~f`)(Number(balance))) as `${number}`; } diff --git a/apps/council-ui/src/ui/base/formatting/formatUnitsBalance.ts b/apps/council-ui/src/ui/base/formatting/formatUnitsBalance.ts new file mode 100644 index 00000000..455ede68 --- /dev/null +++ b/apps/council-ui/src/ui/base/formatting/formatUnitsBalance.ts @@ -0,0 +1,23 @@ +import { formatBalance } from "src/ui/base/formatting/formatBalance"; +import { formatUnits } from "viem"; + +/** + * Formats a balance with a fixed number of decimals + * @param balance The balance to format + * @param decimals The number of decimals in the balance to format. Defaults to + * 18. + * @param displayDecimals The number of decimals to display. Defaults to 1. + * @returns The formatted balance + */ +export function formatUnitsBalance({ + balance, + decimals = 18, + displayDecimals = 1, +}: { + balance: number | bigint; + decimals?: number; + displayDecimals?: number; +}): `${number}` { + const formatted = formatUnits(BigInt(balance), decimals); + return formatBalance(formatted, displayDecimals); +} diff --git a/apps/council-ui/src/ui/base/formatting/formatVotingPower.ts b/apps/council-ui/src/ui/base/formatting/formatVotingPower.ts new file mode 100644 index 00000000..5236fe34 --- /dev/null +++ b/apps/council-ui/src/ui/base/formatting/formatVotingPower.ts @@ -0,0 +1,14 @@ +import { formatBalance } from "src/ui/base/formatting/formatBalance"; +import { formatUnits } from "viem"; + +/** + * Formats a scaled voting power for display. + */ +export function formatVotingPower( + balance: number | bigint, + displayDecimals: number = 1, +): `${number}` { + // Always use 18 decimals for voting power + const formatted = formatUnits(BigInt(balance), 18); + return formatBalance(formatted, displayDecimals); +} diff --git a/apps/council-ui/src/ui/base/formatting/useDisplayName.ts b/apps/council-ui/src/ui/base/formatting/useDisplayName.ts index 19eb30c1..ce64a126 100644 --- a/apps/council-ui/src/ui/base/formatting/useDisplayName.ts +++ b/apps/council-ui/src/ui/base/formatting/useDisplayName.ts @@ -1,12 +1,10 @@ +import { formatAddress } from "src/ui/base/formatting/formatAddress"; import { useEnsName } from "wagmi"; -import { formatAddress } from "./formatAddress"; export function useDisplayName( - address: string | null | undefined, + address: `0x${string}` | undefined, ): string | undefined { - const { data: ensName } = useEnsName({ - address: address as `0x{string}` | undefined, - }); + const { data: ensName } = useEnsName({ address }); // hooks don't let us bail out early, so we do this after the useEnsName call if (!address) { diff --git a/apps/council-ui/src/ui/base/forms/NumericInput.tsx b/apps/council-ui/src/ui/base/forms/NumericInput.tsx index aca52101..78070ecb 100644 --- a/apps/council-ui/src/ui/base/forms/NumericInput.tsx +++ b/apps/council-ui/src/ui/base/forms/NumericInput.tsx @@ -1,5 +1,5 @@ import { ReactElement } from "react"; -import { Input, InputProps } from "./Input"; +import { Input, InputProps } from "src/ui/base/forms/Input"; export interface NumericInputProps extends InputProps { maxButtonValue?: string | number; diff --git a/apps/council-ui/src/ui/base/toast/makeTransactionErrorToast.tsx b/apps/council-ui/src/ui/base/toast/makeTransactionErrorToast.tsx index ac62c5e4..44276bbf 100644 --- a/apps/council-ui/src/ui/base/toast/makeTransactionErrorToast.tsx +++ b/apps/council-ui/src/ui/base/toast/makeTransactionErrorToast.tsx @@ -1,7 +1,7 @@ import { XMarkIcon } from "@heroicons/react/20/solid"; import toast from "react-hot-toast"; import { SupportedChainId } from "src/config/council.config"; -import { makeEtherscanTransactionURL } from "src/etherscan/makeEtherscanTransactionURL"; +import { makeEtherscanTransactionURL } from "src/utils/etherscan/makeEtherscanTransactionURL"; export function makeTransactionErrorToast( message: string, diff --git a/apps/council-ui/src/ui/base/toast/makeTransactionSubmittedToast.tsx b/apps/council-ui/src/ui/base/toast/makeTransactionSubmittedToast.tsx index 5b8ca26d..747517ac 100644 --- a/apps/council-ui/src/ui/base/toast/makeTransactionSubmittedToast.tsx +++ b/apps/council-ui/src/ui/base/toast/makeTransactionSubmittedToast.tsx @@ -1,7 +1,7 @@ import { XMarkIcon } from "@heroicons/react/20/solid"; import toast from "react-hot-toast"; import { SupportedChainId } from "src/config/council.config"; -import { makeEtherscanTransactionURL } from "src/etherscan/makeEtherscanTransactionURL"; +import { makeEtherscanTransactionURL } from "src/utils/etherscan/makeEtherscanTransactionURL"; export function makeTransactionSubmittedToast( message: string, diff --git a/apps/council-ui/src/ui/base/toast/makeTransactionSuccessToast.tsx b/apps/council-ui/src/ui/base/toast/makeTransactionSuccessToast.tsx index e2211593..d11d780f 100644 --- a/apps/council-ui/src/ui/base/toast/makeTransactionSuccessToast.tsx +++ b/apps/council-ui/src/ui/base/toast/makeTransactionSuccessToast.tsx @@ -1,7 +1,7 @@ import { XMarkIcon } from "@heroicons/react/20/solid"; import toast from "react-hot-toast"; import { SupportedChainId } from "src/config/council.config"; -import { makeEtherscanTransactionURL } from "src/etherscan/makeEtherscanTransactionURL"; +import { makeEtherscanTransactionURL } from "src/utils/etherscan/makeEtherscanTransactionURL"; export function makeTransactionSuccessToast( message: string, diff --git a/apps/council-ui/src/ui/base/utils/getBlockDate.ts b/apps/council-ui/src/ui/base/utils/getBlockDate.ts new file mode 100644 index 00000000..1ec228c1 --- /dev/null +++ b/apps/council-ui/src/ui/base/utils/getBlockDate.ts @@ -0,0 +1,34 @@ +import { BlockNotFoundError } from "viem"; +import { UsePublicClientReturnType } from "wagmi"; + +const blockTime = 12n; + +/** + * Get the date of a mined block or estimate the date of a future block. + */ +export async function getBlockDate( + blockNumber: bigint, + client: UsePublicClientReturnType, +): Promise { + if (!client) { + return; + } + + const block = await client + .getBlock({ + blockNumber: blockNumber, + }) + .catch((error) => { + if (error instanceof BlockNotFoundError) { + return undefined; + } + }); + + if (block) { + return new Date(Number(block.timestamp) * 1000); + } + + const latestBlock = await client.getBlockNumber(); + const secondsLeft = (blockNumber - latestBlock) * blockTime; + return new Date(Date.now() + Number(secondsLeft) * 1000); +} diff --git a/apps/council-ui/src/ui/config/hooks/useCouncilConfig.ts b/apps/council-ui/src/ui/config/hooks/useCouncilConfig.ts new file mode 100644 index 00000000..f7276a84 --- /dev/null +++ b/apps/council-ui/src/ui/config/hooks/useCouncilConfig.ts @@ -0,0 +1,8 @@ +import { CouncilConfig } from "src/config/CouncilConfig"; +import { councilConfigs } from "src/config/council.config"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; + +export function useCouncilConfig(): CouncilConfig { + const chainId = useSupportedChainId(); + return councilConfigs[chainId]; +} diff --git a/apps/council-ui/src/ui/config/hooks/useVaultConfig.ts b/apps/council-ui/src/ui/config/hooks/useVaultConfig.ts new file mode 100644 index 00000000..a47ec98f --- /dev/null +++ b/apps/council-ui/src/ui/config/hooks/useVaultConfig.ts @@ -0,0 +1,14 @@ +import { VaultConfig } from "src/config/CouncilConfig"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; + +export function useVaultConfig( + address: `0x${string}` | undefined, +): VaultConfig | undefined { + const config = useCouncilConfig(); + + if (config.gscVoting?.vault?.address === address) { + return config.gscVoting?.vault; + } + + return config.coreVoting.vaults.find((vault) => vault.address === address); +} diff --git a/apps/council-ui/src/ui/contract/hooks/useWrite.ts b/apps/council-ui/src/ui/contract/hooks/useWrite.ts new file mode 100644 index 00000000..dd1f9107 --- /dev/null +++ b/apps/council-ui/src/ui/contract/hooks/useWrite.ts @@ -0,0 +1,65 @@ +import { + MutationStatus, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { useState } from "react"; +import { makeTransactionErrorToast } from "src/ui/base/toast/makeTransactionErrorToast"; +import { makeTransactionSubmittedToast } from "src/ui/base/toast/makeTransactionSubmittedToast"; +import { makeTransactionSuccessToast } from "src/ui/base/toast/makeTransactionSuccessToast"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; +import { usePublicClient } from "wagmi"; + +/** + * A hook which takes a write function that returns a transaction hash and wraps + * it with unified logic for: + * + * - Waiting for the transaction to be mined + * - Showing toast notifications for transaction status + * - Invalidating queries on success + */ +export function useWrite< + TFunction extends (...args: any[]) => Promise<`0x${string}`>, +>({ + writeFn, +}: { + writeFn: TFunction; +}): { + write: TFunction; + status: MutationStatus; + transactionHash: `0x${string}` | undefined; +} { + const [transactionHash, setTransactionHash] = useState<`0x${string}`>(); + + const chainId = useSupportedChainId(); + const queryClient = useQueryClient(); + const publicClient = usePublicClient(); + + const mutationFn = async (...args: Parameters) => { + const hash = await writeFn(...args); + setTransactionHash(hash); + makeTransactionSubmittedToast("Approving", hash, chainId); + await publicClient?.waitForTransactionReceipt({ hash }); + return hash; + }; + + const { mutate, status } = useMutation({ + mutationFn: mutationFn as TFunction, + onSuccess: (hash) => { + makeTransactionSuccessToast("Successfully approved!", hash, chainId); + // All query cache can be invalidated. The SDK uses it's own cache which + // has built-in invalidation logic based on the methods called. + queryClient.invalidateQueries(); + }, + onError(error) { + makeTransactionErrorToast("Failed to approve", transactionHash, chainId); + console.error(error); + }, + }); + + return { + write: mutate as TFunction, + status, + transactionHash, + }; +} diff --git a/apps/council-ui/src/ui/council/CouncilClientContext.ts b/apps/council-ui/src/ui/council/CouncilClientContext.ts deleted file mode 100644 index a01b02e4..00000000 --- a/apps/council-ui/src/ui/council/CouncilClientContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from "react"; -import { getCouncilClient } from "src/clients/council"; -import { SupportedChainId } from "src/config/council.config"; -import { chains } from "src/provider"; - -export const CouncilClientContext = createContext( - getCouncilClient(chains[0].id as SupportedChainId), -); diff --git a/apps/council-ui/src/ui/council/CouncilProvider.tsx b/apps/council-ui/src/ui/council/CouncilProvider.tsx deleted file mode 100644 index 73e287f9..00000000 --- a/apps/council-ui/src/ui/council/CouncilProvider.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { PropsWithChildren, ReactElement, useMemo } from "react"; -import { getCouncilClient } from "src/clients/council"; -import { useChainId } from "src/ui/network/useChainId"; -import { CouncilClientContext } from "./CouncilClientContext"; - -export function CouncilClientProvider({ - children, -}: PropsWithChildren): ReactElement { - const chainId = useChainId(); - const client = useMemo(() => getCouncilClient(chainId), [chainId]); - return ( - - {children} - - ); -} diff --git a/apps/council-ui/src/ui/council/hooks/useReadCoreVoting.ts b/apps/council-ui/src/ui/council/hooks/useReadCoreVoting.ts new file mode 100644 index 00000000..9b9e1960 --- /dev/null +++ b/apps/council-ui/src/ui/council/hooks/useReadCoreVoting.ts @@ -0,0 +1,28 @@ +import { ReadCoreVoting } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadCouncil } from "src/ui/council/hooks/useReadCouncil"; + +/** + * Use a ReadCoreVoting instance for configured core voting contract. + */ +export function useReadCoreVoting(): ReadCoreVoting { + const council = useReadCouncil(); + const { coreVoting } = useCouncilConfig(); + + return useMemo(() => { + return council.coreVoting({ + address: coreVoting.address, + vaults: coreVoting.vaults.map((vault) => { + switch (vault.type) { + case "LockingVault": + return council.lockingVault(vault.address); + case "VestingVault": + return council.vestingVault(vault.address); + default: + return vault.address; + } + }), + }); + }, [council, coreVoting.address, coreVoting.vaults]); +} diff --git a/apps/council-ui/src/ui/council/hooks/useReadCouncil.ts b/apps/council-ui/src/ui/council/hooks/useReadCouncil.ts new file mode 100644 index 00000000..2a57c27a --- /dev/null +++ b/apps/council-ui/src/ui/council/hooks/useReadCouncil.ts @@ -0,0 +1,25 @@ +import { ReadCouncil } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { sdkCache } from "src/lib/councilSdk"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; +import { usePublicClient } from "wagmi"; + +/** + * Use a ReadCouncil instance. + */ +export function useReadCouncil(): ReadCouncil { + const chainId = useSupportedChainId(); + const publicClient = usePublicClient({ chainId }); + + return useMemo(() => { + if (!publicClient) { + throw new Error("Public client is not available"); + } + + return new ReadCouncil({ + publicClient, + cache: sdkCache, + namespace: "council-viem", + }); + }, [chainId]); +} diff --git a/apps/council-ui/src/ui/council/hooks/useReadGscVoting.ts b/apps/council-ui/src/ui/council/hooks/useReadGscVoting.ts new file mode 100644 index 00000000..01b372df --- /dev/null +++ b/apps/council-ui/src/ui/council/hooks/useReadGscVoting.ts @@ -0,0 +1,23 @@ +import { ReadCoreVoting } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadCouncil } from "src/ui/council/hooks/useReadCouncil"; + +/** + * Use a ReadCoreVoting instance for the configured gsc voting contract. + */ +export function useReadGscVoting(): ReadCoreVoting | undefined { + const council = useReadCouncil(); + const { gscVoting } = useCouncilConfig(); + + return useMemo(() => { + if (!gscVoting) { + return undefined; + } + + return council.coreVoting({ + address: gscVoting.address, + vaults: [council.gscVault(gscVoting.vault.address)], + }); + }, [council, gscVoting]); +} diff --git a/apps/council-ui/src/ui/council/hooks/useReadWriteCoreVoting.ts b/apps/council-ui/src/ui/council/hooks/useReadWriteCoreVoting.ts new file mode 100644 index 00000000..3a7f7b41 --- /dev/null +++ b/apps/council-ui/src/ui/council/hooks/useReadWriteCoreVoting.ts @@ -0,0 +1,28 @@ +import { ReadWriteCoreVoting } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadWriteCouncil } from "src/ui/council/hooks/useReadWriteCouncil"; + +/** + * Use a ReadWriteCoreVoting instance for configured core voting contract. + */ +export function useReadWriteCoreVoting(): ReadWriteCoreVoting | undefined { + const council = useReadWriteCouncil(); + const { coreVoting } = useCouncilConfig(); + + return useMemo(() => { + return council?.coreVoting({ + address: coreVoting.address, + vaults: coreVoting.vaults.map((vault) => { + switch (vault.type) { + case "LockingVault": + return council.lockingVault(vault.address); + case "VestingVault": + return council.vestingVault(vault.address); + default: + return vault.address; + } + }), + }); + }, [council, coreVoting.address, coreVoting.vaults]); +} diff --git a/apps/council-ui/src/ui/council/hooks/useReadWriteCouncil.ts b/apps/council-ui/src/ui/council/hooks/useReadWriteCouncil.ts new file mode 100644 index 00000000..55f4b9c4 --- /dev/null +++ b/apps/council-ui/src/ui/council/hooks/useReadWriteCouncil.ts @@ -0,0 +1,27 @@ +import { ReadWriteCouncil } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { sdkCache } from "src/lib/councilSdk"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; +import { usePublicClient, useWalletClient } from "wagmi"; + +/** + * Use a ReadWriteCouncil instance. + */ +export function useReadWriteCouncil(): ReadWriteCouncil | undefined { + const chainId = useSupportedChainId(); + const publicClient = usePublicClient({ chainId }); + const { data: walletClient } = useWalletClient({ chainId }); + + return useMemo(() => { + if (!walletClient || !publicClient) { + return undefined; + } + + return new ReadWriteCouncil({ + publicClient, + walletClient, + cache: sdkCache, + namespace: "council-viem", + }); + }, [publicClient, walletClient]); +} diff --git a/apps/council-ui/src/ui/council/hooks/useReadWriteGscVoting.ts b/apps/council-ui/src/ui/council/hooks/useReadWriteGscVoting.ts new file mode 100644 index 00000000..510e3b28 --- /dev/null +++ b/apps/council-ui/src/ui/council/hooks/useReadWriteGscVoting.ts @@ -0,0 +1,23 @@ +import { ReadWriteCoreVoting } from "@delvtech/council-viem"; +import { useMemo } from "react"; +import { useCouncilConfig } from "src/ui/config/hooks/useCouncilConfig"; +import { useReadWriteCouncil } from "src/ui/council/hooks/useReadWriteCouncil"; + +/** + * Use a ReadWriteCoreVoting instance for configured gsc voting contract. + */ +export function useReadWriteGscVoting(): ReadWriteCoreVoting | undefined { + const council = useReadWriteCouncil(); + const { gscVoting } = useCouncilConfig(); + + return useMemo(() => { + if (!gscVoting || !council) { + return undefined; + } + + return council.coreVoting({ + address: gscVoting.address, + vaults: [council.gscVault(gscVoting.vault.address)], + }); + }, [council, gscVoting]); +} diff --git a/apps/council-ui/src/ui/council/useCouncil.ts b/apps/council-ui/src/ui/council/useCouncil.ts deleted file mode 100644 index 3cf34101..00000000 --- a/apps/council-ui/src/ui/council/useCouncil.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useContext } from "react"; -import { CouncilClient } from "src/clients/council"; -import { CouncilClientContext } from "./CouncilClientContext"; - -export function useCouncil(): CouncilClient { - return useContext(CouncilClientContext); -} diff --git a/apps/council-ui/src/ui/ens/AdddressWithEtherscan.tsx b/apps/council-ui/src/ui/ens/AdddressWithEtherscan.tsx index a4199a3c..4e956736 100644 --- a/apps/council-ui/src/ui/ens/AdddressWithEtherscan.tsx +++ b/apps/council-ui/src/ui/ens/AdddressWithEtherscan.tsx @@ -1,12 +1,12 @@ import classNames from "classnames"; import { ReactElement } from "react"; -import { makeEtherscanAddressURL } from "src/etherscan/makeEtherscanAddressURL"; import { Address } from "src/ui/base/Address"; import { ExternalLinkSVG } from "src/ui/base/svg/ExternalLink"; -import { useChainId } from "src/ui/network/useChainId"; +import { useSupportedChainId } from "src/ui/network/hooks/useSupportedChainId"; +import { makeEtherscanAddressURL } from "src/utils/etherscan/makeEtherscanAddressURL"; interface AddressWithEtherscanProps { - address: string; + address: `0x${string}`; /** * If provided this will be rendered instead of the formatted address or ens. */ @@ -21,10 +21,10 @@ export function AddressWithEtherscan({ label, iconSize, }: AddressWithEtherscanProps): ReactElement { - const chainId = useChainId(); + const chainId = useSupportedChainId(); return ( { + const client = usePublicClient(); + + const enabled = !!addresses.length && !!client; + + return useQuery({ + queryKey: ["bulkEnsRecords", addresses], + enabled, + queryFn: enabled + ? (): Promise => { + return getBulkEnsRecords(addresses, client); + } + : undefined, + refetchOnWindowFocus: false, + refetchOnMount: false, + }); +} diff --git a/apps/council-ui/src/ui/ens/useBulkEnsRecords.ts b/apps/council-ui/src/ui/ens/useBulkEnsRecords.ts deleted file mode 100644 index 24a6261c..00000000 --- a/apps/council-ui/src/ui/ens/useBulkEnsRecords.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery, UseQueryResult } from "@tanstack/react-query"; -import { EnsRecords, getBulkEnsRecords } from "src/ens/getBulkEnsRecords"; -import { useProvider } from "wagmi"; - -export function useBulkEnsRecords( - addresses: string[], -): UseQueryResult { - const provider = useProvider(); - - return useQuery({ - queryKey: ["bulkEnsRecords", addresses], - enabled: !!addresses.length, - queryFn: (): Promise => { - return getBulkEnsRecords(addresses, provider); - }, - refetchOnWindowFocus: false, - refetchOnMount: false, - }); -} diff --git a/apps/council-ui/src/ui/navigation/Navigation.tsx b/apps/council-ui/src/ui/navigation/Navigation.tsx index 7c7ca75f..17c17b17 100644 --- a/apps/council-ui/src/ui/navigation/Navigation.tsx +++ b/apps/council-ui/src/ui/navigation/Navigation.tsx @@ -7,8 +7,8 @@ import { makeVoterURL, Routes } from "src/routes"; import { useClaimableAirdropAmount } from "src/ui/airdrop/hooks/useClaimableAirdropAmount"; import { AirdropIcon } from "src/ui/base/svg/20/AirdropIcon"; import PushIcon from "src/ui/base/svg/PushLogo"; -import { Tooltip } from "src/ui/base/Tooltip/Tooltip"; -import { useWrongNetworkEffect } from "src/ui/network/useWrongNetworkEffect"; +import { Tooltip } from "src/ui/base/Tooltip"; +import { useWrongNetworkEffect } from "src/ui/network/hooks/useWrongNetworkEffect"; import { usePushSubscribe } from "src/ui/push/usePushSubscribe"; import { useAccount } from "wagmi"; @@ -16,7 +16,7 @@ export function Navigation(): ReactElement { const { address } = useAccount(); const { pathname, query } = useRouter(); const { toggleUserStatus, loading, isSubscribed } = usePushSubscribe(); - const { data: claimableAmount } = useClaimableAirdropAmount(); + const { claimableAmount } = useClaimableAirdropAmount(); useWrongNetworkEffect(); @@ -24,10 +24,10 @@ export function Navigation(): ReactElement {
-
+
-