diff --git a/.env.example b/.env.example index c649f15b97..97c4586995 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ VITE_POLYGON_TESTNET_NODE_URL="" # These 2 settings should be toggled to "true" if you want to use Olympus Give features VITE_GIVE_ENABLED="true" VITE_GIVE_GRANTS_ENABLED="true" +VITE_GIVE_ENABLED="true" +VITE_GIVE_GRANTS_ENABLED="true" # This should be toggled to "true" if you need to use the mock sOHM contract # (which allows for on-demand rebasing) @@ -39,3 +41,6 @@ VITE_ARBITRUM_TESTNET_NODE_URL="" VITE_AVALANCHE_NODE_URL="" VITE_AVALANCHE_TESTNET_NODE_URL="" + +# Optional - for On-Chain Governance - get from https://web3.storage/tokens/ +VITE_WEB3_STORAGE_KEY="" diff --git a/package.json b/package.json index 5d830c3d24..08cb399dc9 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@reduxjs/toolkit": "^1.9.3", "@tanstack/react-query": "^4.2.3", "@types/recharts": "^1.8.24", + "@uiw/react-md-editor": "^3.20.10", "@vitejs/plugin-react-swc": "^3.2.0", "@wundergraph/react-query": "^0.8.22", "@wundergraph/sdk": "^0.152.0", @@ -64,15 +65,17 @@ "react-ga": "^3.3.1", "react-ga4": "^2.1.0", "react-hot-toast": "^2.4.0", - "react-markdown": "^8.0.6", + "react-markdown": "^8.0.7", "react-redux": "^8.0.5", "react-router-dom": "^6.9.0", "react-step-progress-bar": "^1.0.3", "react-uid": "^2.3.2", "recharts": "^2.5.0", + "rehype-sanitize": "^5.0.1", "tinycolor2": "^1.6.0", "typescript": "^4.9.4", - "wagmi": "^0.12.12" + "wagmi": "^0.12.12", + "web3.storage": "^4.3.0" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", diff --git a/src/App.tsx b/src/App.tsx index fb1ff673a1..bb59b6a276 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { girth as gTheme } from "src/themes/girth.js"; import { light as lightTheme } from "src/themes/light.js"; import { BondModalContainer } from "src/views/Bond/components/BondModal/BondModal"; import { BondModalContainerV3 } from "src/views/Bond/components/BondModal/BondModalContainerV3"; +import { Governance } from "src/views/Governance/Governance"; import { Liquidity } from "src/views/Liquidity"; import { ExternalStakePools } from "src/views/Liquidity/ExternalStakePools/ExternalStakePools"; import { Vault } from "src/views/Liquidity/Vault"; @@ -268,6 +269,8 @@ function App() { element={} > } /> + + } /> diff --git a/src/abi/OlympusGovInstructions.json b/src/abi/OlympusGovInstructions.json new file mode 100644 index 0000000000..fd74f03654 --- /dev/null +++ b/src/abi/OlympusGovInstructions.json @@ -0,0 +1,250 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "kernel_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "INSTR_InstructionsCannotBeEmpty", + "type": "error" + }, + { + "inputs": [], + "name": "INSTR_InvalidAction", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "Keycode", + "name": "keycode_", + "type": "bytes5" + } + ], + "name": "InvalidKeycode", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller_", + "type": "address" + } + ], + "name": "KernelAdapter_OnlyKernel", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "policy_", + "type": "address" + } + ], + "name": "Module_PolicyNotPermitted", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "target_", + "type": "address" + } + ], + "name": "TargetNotAContract", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "instructionsId", + "type": "uint256" + } + ], + "name": "InstructionsStored", + "type": "event" + }, + { + "inputs": [], + "name": "INIT", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "KEYCODE", + "outputs": [ + { + "internalType": "Keycode", + "name": "", + "type": "bytes5" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "uint8", + "name": "major", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "minor", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "newKernel_", + "type": "address" + } + ], + "name": "changeKernel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "instructionsId_", + "type": "uint256" + } + ], + "name": "getInstructions", + "outputs": [ + { + "components": [ + { + "internalType": "enum Actions", + "name": "action", + "type": "uint8" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "internalType": "struct Instruction[]", + "name": "", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "kernel", + "outputs": [ + { + "internalType": "contract Kernel", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "enum Actions", + "name": "action", + "type": "uint8" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "internalType": "struct Instruction[]", + "name": "instructions_", + "type": "tuple[]" + } + ], + "name": "store", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "storedInstructions", + "outputs": [ + { + "internalType": "enum Actions", + "name": "action", + "type": "uint8" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalInstructions", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/src/abi/OlympusGovMockGOhm.json b/src/abi/OlympusGovMockGOhm.json new file mode 100644 index 0000000000..7f07082503 --- /dev/null +++ b/src/abi/OlympusGovMockGOhm.json @@ -0,0 +1,358 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "string", + "name": "_name", + "type": "string" + }, + { + "internalType": "string", + "name": "_symbol", + "type": "string" + }, + { + "internalType": "uint8", + "name": "_decimals", + "type": "uint8" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/src/abi/OlympusGovVohmVault.json b/src/abi/OlympusGovVohmVault.json new file mode 100644 index 0000000000..6773e83e7a --- /dev/null +++ b/src/abi/OlympusGovVohmVault.json @@ -0,0 +1,223 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "kernel_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller_", + "type": "address" + } + ], + "name": "KernelAdapter_OnlyKernel", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "Keycode", + "name": "keycode_", + "type": "bytes5" + } + ], + "name": "Policy_ModuleDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "VohmVault_NotVested", + "type": "error" + }, + { + "inputs": [], + "name": "VESTING_PERIOD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VOTES", + "outputs": [ + { + "internalType": "contract VOTESv1", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "newKernel_", + "type": "address" + } + ], + "name": "changeKernel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "configureDependencies", + "outputs": [ + { + "internalType": "Keycode[]", + "name": "dependencies", + "type": "bytes5[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets_", + "type": "uint256" + } + ], + "name": "deposit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "gOHM", + "outputs": [ + { + "internalType": "contract ERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isActive", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "kernel", + "outputs": [ + { + "internalType": "contract Kernel", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares_", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares_", + "type": "uint256" + } + ], + "name": "redeem", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "requestPermissions", + "outputs": [ + { + "components": [ + { + "internalType": "Keycode", + "name": "keycode", + "type": "bytes5" + }, + { + "internalType": "bytes4", + "name": "funcSelector", + "type": "bytes4" + } + ], + "internalType": "struct Permissions[]", + "name": "permissions", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "activate_", + "type": "bool" + } + ], + "name": "setActiveStatus", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets_", + "type": "uint256" + } + ], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/src/abi/OlympusGovVotes.json b/src/abi/OlympusGovVotes.json new file mode 100644 index 0000000000..92925313a6 --- /dev/null +++ b/src/abi/OlympusGovVotes.json @@ -0,0 +1,857 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "kernel_", + "type": "address" + }, + { + "internalType": "contract ERC20", + "name": "gOhm_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller_", + "type": "address" + } + ], + "name": "KernelAdapter_OnlyKernel", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "policy_", + "type": "address" + } + ], + "name": "Module_PolicyNotPermitted", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "Deposit", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "caller", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "receiver", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "assets", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "Withdraw", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "INIT", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "KEYCODE", + "outputs": [ + { + "internalType": "Keycode", + "name": "", + "type": "bytes5" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "uint8", + "name": "major", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "minor", + "type": "uint8" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "asset", + "outputs": [ + { + "internalType": "contract ERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "newKernel_", + "type": "address" + } + ], + "name": "changeKernel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "convertToAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "convertToShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets_", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver_", + "type": "address" + } + ], + "name": "deposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "gOHM", + "outputs": [ + { + "internalType": "contract ERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "kernel", + "outputs": [ + { + "internalType": "contract Kernel", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "lastActionTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "lastDepositTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "maxDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "maxMint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "maxRedeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "maxWithdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares_", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver_", + "type": "address" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "previewDeposit", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "previewMint", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares", + "type": "uint256" + } + ], + "name": "previewRedeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets", + "type": "uint256" + } + ], + "name": "previewWithdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "shares_", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver_", + "type": "address" + }, + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "name": "redeem", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_wallet", + "type": "address" + } + ], + "name": "resetActionTimestamp", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalAssets", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amt_", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from_", + "type": "address" + }, + { + "internalType": "address", + "name": "to_", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount_", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "assets_", + "type": "uint256" + }, + { + "internalType": "address", + "name": "receiver_", + "type": "address" + }, + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "name": "withdraw", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/src/abi/OlympusGovernance.json b/src/abi/OlympusGovernance.json new file mode 100644 index 0000000000..6944f9123f --- /dev/null +++ b/src/abi/OlympusGovernance.json @@ -0,0 +1,718 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "kernel_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "CollateralAlreadyReturned", + "type": "error" + }, + { + "inputs": [], + "name": "DepositedAfterActivation", + "type": "error" + }, + { + "inputs": [], + "name": "ExecutionTimelockStillActive", + "type": "error" + }, + { + "inputs": [], + "name": "ExecutionWindowExpired", + "type": "error" + }, + { + "inputs": [], + "name": "ExecutorNotSubmitter", + "type": "error" + }, + { + "inputs": [], + "name": "GovernanceDisabled", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "caller_", + "type": "address" + } + ], + "name": "KernelAdapter_OnlyKernel", + "type": "error" + }, + { + "inputs": [], + "name": "NotAuthorized", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnoughVoteSupply", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnoughVotesToExecute", + "type": "error" + }, + { + "inputs": [], + "name": "PastVotingPeriod", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "Keycode", + "name": "keycode_", + "type": "bytes5" + } + ], + "name": "Policy_ModuleDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalAlreadyActivated", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalAlreadyExecuted", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalIsNotActive", + "type": "error" + }, + { + "inputs": [], + "name": "UnableToActivate", + "type": "error" + }, + { + "inputs": [], + "name": "UnmetCollateralDuration", + "type": "error" + }, + { + "inputs": [], + "name": "UserAlreadyVoted", + "type": "error" + }, + { + "inputs": [], + "name": "UserHasNoVotes", + "type": "error" + }, + { + "inputs": [], + "name": "WarmupNotCompleted", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokensReclaimed_", + "type": "uint256" + } + ], + "name": "CollateralReclaimed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "timestamp", + "type": "uint256" + } + ], + "name": "ProposalActivated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + } + ], + "name": "ProposalExecuted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "title", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "proposalURI", + "type": "string" + } + ], + "name": "ProposalSubmitted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "voter", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approve", + "type": "bool" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "userVotes", + "type": "uint256" + } + ], + "name": "VotesCast", + "type": "event" + }, + { + "inputs": [], + "name": "ACTIVATION_DEADLINE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ACTIVATION_TIMELOCK", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "COLLATERAL_DURATION", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "COLLATERAL_MINIMUM", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "COLLATERAL_REQUIREMENT", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXECUTION_DEADLINE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXECUTION_THRESHOLD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "EXECUTION_TIMELOCK", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "GOVERNANCE_DISABLED", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "INSTR", + "outputs": [ + { + "internalType": "contract INSTRv1", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MINIMUM_VOTES_THRESHOLD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "ROLES", + "outputs": [ + { + "internalType": "contract ROLESv1", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VOTES", + "outputs": [ + { + "internalType": "contract VOTESv1", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "VOTING_PERIOD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "WARMUP_PERIOD", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "a", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "b", + "type": "uint256" + } + ], + "name": "_max", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "name": "activateProposal", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract Kernel", + "name": "newKernel_", + "type": "address" + } + ], + "name": "changeKernel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "configureDependencies", + "outputs": [ + { + "internalType": "Keycode[]", + "name": "dependencies", + "type": "bytes5[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "disableGovernance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "name": "disableProposal", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "enableGovernance", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "name": "executeProposal", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "getProposalMetadata", + "outputs": [ + { + "internalType": "address", + "name": "submitter", + "type": "address" + }, + { + "internalType": "uint256", + "name": "submissionTimestamp", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "collateralAmt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "activationTimestamp", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "totalRegisteredVotes", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "yesVotes", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "noVotes", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "isExecuted", + "type": "bool" + }, + { + "internalType": "bool", + "name": "isCollateralReturned", + "type": "bool" + }, + { + "internalType": "bool", + "name": "isDisabled", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isActive", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "kernel", + "outputs": [ + { + "internalType": "contract Kernel", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "name": "reclaimCollateral", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "requestPermissions", + "outputs": [ + { + "components": [ + { + "internalType": "Keycode", + "name": "keycode", + "type": "bytes5" + }, + { + "internalType": "bytes4", + "name": "funcSelector", + "type": "bytes4" + } + ], + "internalType": "struct Permissions[]", + "name": "requests", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "components": [ + { + "internalType": "enum Actions", + "name": "action", + "type": "uint8" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + } + ], + "internalType": "struct Instruction[]", + "name": "instructions_", + "type": "tuple[]" + }, + { + "internalType": "string", + "name": "title_", + "type": "string" + }, + { + "internalType": "string", + "name": "proposalURI_", + "type": "string" + } + ], + "name": "submitProposal", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "approve_", + "type": "bool" + } + ], + "name": "vote", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/src/abi/OlympusVoteIssuer.json b/src/abi/OlympusVoteIssuer.json new file mode 100644 index 0000000000..cd7df6eb30 --- /dev/null +++ b/src/abi/OlympusVoteIssuer.json @@ -0,0 +1,82 @@ +{ + "abi": [ + { + "inputs": [{ "internalType": "contract Kernel", "name": "kernel_", "type": "address" }], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [{ "internalType": "Keycode", "name": "keycode_", "type": "bytes5" }], + "name": "Policy_ModuleDoesNotExist", + "type": "error" + }, + { + "inputs": [{ "internalType": "address", "name": "caller_", "type": "address" }], + "name": "Policy_OnlyKernel", + "type": "error" + }, + { + "inputs": [{ "internalType": "Role", "name": "role_", "type": "bytes32" }], + "name": "Policy_OnlyRole", + "type": "error" + }, + { + "inputs": [], + "name": "VOTES", + "outputs": [{ "internalType": "contract DefaultVotes", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "wallet_", "type": "address" }, + { "internalType": "uint256", "name": "amt_", "type": "uint256" } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "configureDependencies", + "outputs": [{ "internalType": "Keycode[]", "name": "dependencies", "type": "bytes5[]" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "kernel", + "outputs": [{ "internalType": "contract Kernel", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "wallet_", "type": "address" }, + { "internalType": "uint256", "name": "amt_", "type": "uint256" } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "requestPermissions", + "outputs": [ + { + "components": [ + { "internalType": "Keycode", "name": "keycode", "type": "bytes5" }, + { "internalType": "bytes4", "name": "funcSelector", "type": "bytes4" } + ], + "internalType": "struct Permissions[]", + "name": "requests", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} diff --git a/src/components/Sidebar/NavContent.tsx b/src/components/Sidebar/NavContent.tsx index cd18262c81..a505761a62 100644 --- a/src/components/Sidebar/NavContent.tsx +++ b/src/components/Sidebar/NavContent.tsx @@ -6,6 +6,7 @@ import { NavLink } from "react-router-dom"; import { ReactComponent as OlympusIcon } from "src/assets/icons/olympus-nav-header.svg"; import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { NetworkId } from "src/networkDetails"; import { BondDiscount } from "src/views/Bond/components/BondDiscount"; import { DetermineRangeDiscount } from "src/views/Range/hooks"; import { useNetwork } from "wagmi"; @@ -59,7 +60,10 @@ const NavContent: React.VFC = () => { - + + {chain.id === NetworkId.TESTNET_GOERLI && ( + + )} ) : ( diff --git a/src/components/TopBar/Wallet/Assets/index.tsx b/src/components/TopBar/Wallet/Assets/index.tsx index 075a3e4dfb..3ef94db900 100644 --- a/src/components/TopBar/Wallet/Assets/index.tsx +++ b/src/components/TopBar/Wallet/Assets/index.tsx @@ -6,7 +6,7 @@ import { NavLink } from "react-router-dom"; import { useNavigate } from "react-router-dom"; import Balances from "src/components/TopBar/Wallet/Assets/Balances"; import { TransactionHistory } from "src/components/TopBar/Wallet/Assets/TransactionHistory"; -import { useFaucet } from "src/components/TopBar/Wallet/hooks/useFaucet"; +import { useFaucet, useGovernanceFaucet } from "src/components/TopBar/Wallet/hooks/useFaucet"; import { GetTokenPrice } from "src/components/TopBar/Wallet/queries"; import { formatCurrency, formatNumber, isTestnet, trim } from "src/helpers"; import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; @@ -29,7 +29,7 @@ import { useTestableNetworks } from "src/hooks/useTestableNetworks"; import { NetworkId } from "src/networkDetails"; import { useBondNotes } from "src/views/Bond/components/ClaimBonds/hooks/useBondNotes"; import { useNextRebaseDate } from "src/views/Stake/components/StakeArea/components/RebaseTimer/hooks/useNextRebaseDate"; -import { useNetwork } from "wagmi"; +import { useAccount, useNetwork } from "wagmi"; const PREFIX = "AssetsIndex"; @@ -87,6 +87,7 @@ const AssetsIndex: FC = (props: { path?: string }) => { const navigate = useNavigate(); const networks = useTestableNetworks(); const { chain = { id: 1 } } = useNetwork(); + const { address } = useAccount(); const { data: ohmPrice = 0 } = useOhmPrice(); const { data: priceFeed = { usd_24h_change: -0 } } = GetTokenPrice(); const { data: currentIndex = new DecimalBigNumber("0", 9) } = useCurrentIndex(); @@ -212,7 +213,8 @@ const AssetsIndex: FC = (props: { path?: string }) => { const walletTotalValueUSD = Object.values(assets).reduce((totalValue, token) => totalValue + token.assetValue, 0); const faucetMutation = useFaucet(); - const isFaucetLoading = faucetMutation.isLoading; + const govFaucetMutation = useGovernanceFaucet(); + const isFaucetLoading = faucetMutation.isLoading || govFaucetMutation.isLoading; return ( @@ -263,11 +265,17 @@ const AssetsIndex: FC = (props: { path?: string }) => { sOHM V2 wsOHM gOHM + gov-gOHM DAI ETH - faucetMutation.mutate(faucetToken)}> + + faucetToken === "govGOHM" ? govFaucetMutation.mutate() : faucetMutation.mutate(faucetToken) + } + disabled={!address} + > {isFaucetLoading ? "Loading..." : "Get Tokens"} diff --git a/src/components/TopBar/Wallet/hooks/useFaucet.ts b/src/components/TopBar/Wallet/hooks/useFaucet.ts index 003b1524cd..375d21fcba 100644 --- a/src/components/TopBar/Wallet/hooks/useFaucet.ts +++ b/src/components/TopBar/Wallet/hooks/useFaucet.ts @@ -2,8 +2,11 @@ import { useMutation } from "@tanstack/react-query"; import { ContractReceipt } from "ethers"; import toast from "react-hot-toast"; import { DEV_FAUCET } from "src/constants/addresses"; +import { GOVERNANCE_MOCK_GOHM_CONTRACT } from "src/constants/contracts"; +import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; import { useDynamicFaucetContract } from "src/hooks/useContract"; import { EthersError } from "src/lib/EthersTypes"; +import { useAccount, useNetwork, useSigner } from "wagmi"; export const useFaucet = () => { const contract = useDynamicFaucetContract(DEV_FAUCET, true); @@ -46,3 +49,32 @@ export const useFaucet = () => { }, ); }; + +export const useGovernanceFaucet = () => { + const contract = GOVERNANCE_MOCK_GOHM_CONTRACT.getEthersContract(5); + const mintQuantity = new DecimalBigNumber("1000", 18); + const { data: signer } = useSigner(); + const { address } = useAccount(); + const { chain } = useNetwork(); + return useMutation( + async () => { + if (!chain || chain.id !== 5) throw new Error("Faucet is only supported on Goerli"); + if (!contract) + throw new Error(`Faucet is not supported on this network. Please switch to Goerli Testnet to use the faucet`); + if (!signer) throw new Error("Signer is not set"); + if (!address) throw new Error("Wallet is not connected"); + + console.log("mutating", address, mintQuantity.toBigNumber(18)); + const transaction = await contract.connect(signer).mint(address, mintQuantity.toBigNumber(18)); + return transaction.wait(); + }, + { + onError: error => { + toast.error("error" in error ? error.error.message : error.message); + }, + onSuccess: async () => { + toast.success(`Successfully requested 1000 governance gOHM from Faucet`); + }, + }, + ); +}; diff --git a/src/constants/addresses.ts b/src/constants/addresses.ts index 46b49412ad..b993a3be25 100644 --- a/src/constants/addresses.ts +++ b/src/constants/addresses.ts @@ -161,6 +161,64 @@ export const DEV_FAUCET = { [NetworkId.TESTNET_GOERLI]: "0x405940141AeE885347ef4C47d933eF4cA6A674D8", }; +/** + * now called Parthenon + */ +export const GOVERNANCE_ADDRESSES = { + [NetworkId.MAINNET]: "", + // [NetworkId.TESTNET_GOERLI]: "0xf1c6848e7b7bc93401262cdeab40dcbaf92e16ac", + // [NetworkId.TESTNET_GOERLI]: "0x0fa391b3ae3a7fc5fa24a4bc7236db854390b7b4", + // [NetworkId.TESTNET_GOERLI]: "0x904fe39c25f53a00A19Cf155B1B73b1CB67F23F8", + [NetworkId.TESTNET_GOERLI]: "0xc8aA75d2797C4Ef42486f125f36F06796Fdb37E4", +}; + +export const GOV_INSTRUCTIONS_ADDRESSES = { + [NetworkId.MAINNET]: "", + // [NetworkId.TESTNET_GOERLI]: "0xa8810f94ABe49Ffe0AA49a1c30930a40C450f288", + // [NetworkId.TESTNET_GOERLI]: "0x1f0c9b03f9d9c2c6214c4557c369ae34c19122e4", + [NetworkId.TESTNET_GOERLI]: "0xda0D88E33eCBfD52e5cb88dB0658d0B758e1cd00", +}; + +export const VOTE_TOKEN_ADDRESSES = { + [NetworkId.MAINNET]: "", + // [NetworkId.TESTNET_GOERLI]: "0xad50790dbaf78572019575bc5dce2abff1544fd0", + // [NetworkId.TESTNET_GOERLI]: "0x7Aef3bDa16bBD12033d93C05df877d0f165F2214", + // [NetworkId.TESTNET_GOERLI]: "0x02741c86c45455c87baa381622f93ebb1141fa63", + [NetworkId.TESTNET_GOERLI]: "0xCdC25b22A89b10BeC99b2c66ed3D850C87a4dbfA", +}; + +/** + * GOVERNANCE - + * the testing flow should go as follows: + * 1) Mint OHM from the Mock OHM contract + * 2) Deposit OHM into the vOHM Vault contract to receive voting tokens ("vOHM") + * 3) Use vOHM to vote in Parthenon.sol + */ + +// export const GOVERNANCE_MOCK_OHM = { +// [NetworkId.MAINNET]: "", +// [NetworkId.TESTNET_GOERLI]: "0xcd69d22753dafbf93843c600110e32df046dd165", +// }; +export const GOVERNANCE_MOCK_GOHM = { + [NetworkId.MAINNET]: "", + // [NetworkId.TESTNET_GOERLI]: "0xBF3BaBd7411628788B0f6cd56BC9D6aE1Bfb1F0F", + [NetworkId.TESTNET_GOERLI]: "0x5eDd8c045c036355d210fE39B9ab11DA3c178c18", +}; + +/** governance gohm combines mock gohm on goerli with mainnet gohm */ +export const GOVERNANCE_GOHM_ADDRESSES = { + [NetworkId.MAINNET]: "0x0ab87046fBb341D058F17CBC4c1133F25a20a52f", // this is the same as GOHM on mainnet + // [NetworkId.TESTNET_GOERLI]: "0xBF3BaBd7411628788B0f6cd56BC9D6aE1Bfb1F0F", + [NetworkId.TESTNET_GOERLI]: "0x5eDd8c045c036355d210fE39B9ab11DA3c178c18", +}; + +export const GOVERNANCE_VOHM_VAULT_ADDRESSES = { + [NetworkId.MAINNET]: "", + // [NetworkId.TESTNET_GOERLI]: "0x4fd8cc1a43377454ac50f9a312fd4fd7974811cb", + // [NetworkId.TESTNET_GOERLI]: "0x0BF5064643998f3211b4555Ce6855D511D33191a", + [NetworkId.TESTNET_GOERLI]: "0xE036E83596012e14FF9597B1EF02FE0B7A7c4562", +}; + export const BOND_AGGREGATOR_ADDRESSES = { [NetworkId.MAINNET]: "0x007A66A2a13415DB3613C1a4dd1C942A285902d1", [NetworkId.TESTNET_GOERLI]: "0x007A66A2a13415DB3613C1a4dd1C942A285902d1", diff --git a/src/constants/contracts.ts b/src/constants/contracts.ts index 5430519310..067419820b 100644 --- a/src/constants/contracts.ts +++ b/src/constants/contracts.ts @@ -8,6 +8,10 @@ import { CROSS_CHAIN_BRIDGE_ADDRESSES_TESTNET, DEV_FAUCET, DISTRIBUTOR_ADDRESSES, + GOV_INSTRUCTIONS_ADDRESSES, + GOVERNANCE_ADDRESSES, + GOVERNANCE_MOCK_GOHM, + GOVERNANCE_VOHM_VAULT_ADDRESSES, LIQUIDITY_REGISTRY_ADDRESSES, MIGRATOR_ADDRESSES, OP_BOND_DEPOSITORY_ADDRESSES, @@ -16,6 +20,7 @@ import { RANGE_PRICE_ADDRESSES, SOHM_ADDRESSES, STAKING_ADDRESSES, + VOTE_TOKEN_ADDRESSES, ZAP_ADDRESSES, } from "src/constants/addresses"; import { Contract } from "src/helpers/contracts/Contract"; @@ -27,6 +32,9 @@ import { CrossChainBridge__factory, CrossChainBridgeTestnet__factory, CrossChainMigrator__factory, + IERC20__factory, + OlympusGovernance__factory, + OlympusGovInstructions__factory, OlympusLiquidityRegistry__factory, OlympusProV2__factory, OlympusStakingv2__factory, @@ -39,6 +47,8 @@ import { import { BondAggregator__factory } from "src/typechain/factories/BondAggregator__factory"; import { DevFaucet__factory } from "src/typechain/factories/DevFaucet__factory"; import { OlympusDistributor__factory } from "src/typechain/factories/OlympusDistributor__factory"; +import { OlympusGovMockGOhm__factory } from "src/typechain/factories/OlympusGovMockGOhm__factory"; +import { OlympusGovVohmVault__factory } from "src/typechain/factories/OlympusGovVohmVault__factory"; export const BOND_DEPOSITORY_CONTRACT = new Contract({ factory: BondDepository__factory, @@ -106,6 +116,36 @@ export const FAUCET = new Contract({ addresses: DEV_FAUCET, }); +export const GOVERNANCE_CONTRACT = new Contract({ + factory: OlympusGovernance__factory, + name: "Olympus Governance", + addresses: GOVERNANCE_ADDRESSES, +}); + +export const GOV_INSTRUCTIONS_CONTRACT = new Contract({ + factory: OlympusGovInstructions__factory, + name: "Olympus Governance Instructions", + addresses: GOV_INSTRUCTIONS_ADDRESSES, +}); + +export const VOTE_TOKEN_CONTRACT = new Contract({ + factory: IERC20__factory, + name: "Olympus Vote Token", + addresses: VOTE_TOKEN_ADDRESSES, +}); + +export const GOVERNANCE_MOCK_GOHM_CONTRACT = new Contract({ + factory: OlympusGovMockGOhm__factory, + name: "Olympus Governance Mock gOHM", + addresses: GOVERNANCE_MOCK_GOHM, +}); + +export const GOVERNANCE_VOHM_VAULT_CONTRACT = new Contract({ + factory: OlympusGovVohmVault__factory, + name: "Olympus Governance vOHM Vault", + addresses: GOVERNANCE_VOHM_VAULT_ADDRESSES, +}); + export const BOND_AGGREGATOR_CONTRACT = new Contract({ factory: BondAggregator__factory, name: "Bond Aggregator Contract", diff --git a/src/helpers/Web3Storage.ts b/src/helpers/Web3Storage.ts new file mode 100644 index 0000000000..7900d7ba19 --- /dev/null +++ b/src/helpers/Web3Storage.ts @@ -0,0 +1,61 @@ +import { Environment } from "src/helpers/environment/Environment/Environment"; +import { IProposalContent } from "src/hooks/useProposals"; +import { Filelike, Web3Storage } from "web3.storage"; +import { CIDString } from "web3.storage/dist/src/lib/interface"; + +export interface IPFSFileData { + path: string; + cid: CIDString; + fileName: string; +} + +/** + * uploads a given file to Web3.Storage with the API Key + */ +export const uploadToIPFS = async (file: Filelike) => { + const accessToken = Environment.getWeb3StorageKey(); + if (accessToken) { + // NOTE(appleseed): the below endpoint is required in typescript, it is simply the default from: https://github.com/web3-storage/web3.storage/blob/main/packages/client/src/lib.js#L92 + const client = new Web3Storage({ token: accessToken, endpoint: new URL("https://api.web3.storage") }); + // NOTE(appleseed): web3Storage client expects an array of files. + const cid = await client.put([file]); + console.log("stored files with cid:", cid); + return { + path: `${cid}/${file.name}`, + cid, + fileName: file.name, + }; + } +}; + +/** + * adhering to ERC-721 metadata standards & mimicking Lens.Protocol: + * @link https://docs.lens.xyz/docs/metadata-standards#metadata-structure + * @example + * ``` + * { + * name: "My Proposal", + * description: "really long text", + * content: "same as description", + * external_url: "https://forum.wherever.com" + * } + * ``` + */ +export interface IProposalJson extends IProposalContent { + content: string; +} + +/** + * expects a js object, turns it into a json file + * @returns an Filelike + */ +export const makeJsonFile = (object: IProposalJson, fileName: string): Filelike => { + // You can create File objects from a Blob of binary data + // see: https://developer.mozilla.org/en-US/docs/Web/API/Blob + // Here we're just storing a JSON object, but you can store images, + // audio, or whatever you want! + const blob = new Blob([JSON.stringify(object)], { type: "application/json" }); + + const file = new File([blob], fileName); + return file; +}; diff --git a/src/helpers/contracts/Contract.ts b/src/helpers/contracts/Contract.ts index b22bb50f88..3d723805ee 100644 --- a/src/helpers/contracts/Contract.ts +++ b/src/helpers/contracts/Contract.ts @@ -1,6 +1,6 @@ import { Contract as EthersContract } from "@ethersproject/contracts"; import { Provider } from "@ethersproject/providers"; -import { Signer } from "ethers"; +import { ethers, Signer } from "ethers"; import { AddressMap } from "src/constants/addresses"; import { Providers } from "src/helpers/providers/Providers/Providers"; import { NetworkId } from "src/networkDetails"; @@ -57,13 +57,14 @@ export class Contract { + getEthersContract = (networkId: keyof TAddressMap, provider?: ethers.providers.Provider) => { if (!this._contractCache[networkId]) { const address = this.getAddress(networkId); - const provider = Providers.getStaticProvider(networkId as NetworkId); + const localProvider = provider || Providers.getStaticProvider(networkId as NetworkId); - this._contractCache[networkId] = this._factory.connect(address, provider) as ReturnType; + this._contractCache[networkId] = this._factory.connect(address, localProvider) as ReturnType; } return this._contractCache[networkId]; diff --git a/src/helpers/environment/Environment/Environment.ts b/src/helpers/environment/Environment/Environment.ts index c6a4b8ad09..f7f18afa4c 100644 --- a/src/helpers/environment/Environment/Environment.ts +++ b/src/helpers/environment/Environment/Environment.ts @@ -45,6 +45,13 @@ export class Environment { fallback: "96e0cc51-a62e-42ca-acee-910ea7d2a241", }); + public static getWeb3StorageKey = () => + this._get({ + first: true, + key: "VITE_WEB3_STORAGE_KEY", + err: "Please provide an Web3.Storage API key in your .env file", + }); + public static getWundergraphNodeUrl = (): string | undefined => this._get({ first: true, diff --git a/src/helpers/index.tsx b/src/helpers/index.tsx index 8025728f5f..32c73d1c9c 100644 --- a/src/helpers/index.tsx +++ b/src/helpers/index.tsx @@ -3,6 +3,7 @@ import { formatUnits } from "@ethersproject/units"; import axios from "axios"; import { NetworkId } from "src/constants"; import { OHM_DAI_LP_TOKEN } from "src/constants/tokens"; +import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; import { Environment } from "src/helpers/environment/Environment/Environment"; /** @@ -41,6 +42,10 @@ export function shorten(str: string) { return `${str.slice(0, 6)}...${str.slice(str.length - 4)}`; } +export function capitalize(str: string) { + return str && str[0].toUpperCase() + str.slice(1); +} + export function formatCurrency(c: number, precision = 0, currency = "USD") { const formatted = new Intl.NumberFormat("en-US", { style: currency === "USD" ? "currency" : undefined, @@ -53,6 +58,12 @@ export function formatCurrency(c: number, precision = 0, currency = "USD") { return formatted; } +export const formatBalance = (decimals: number, balance?: DecimalBigNumber) => { + const zero = new DecimalBigNumber("0"); + const number = balance ? balance : zero; + return number.toString({ decimals, trim: false, format: true }); +}; + export function trim(number = 0, precision = 0) { // why would number ever be undefined??? what are we trimming? const array = Number(number).toFixed(8).split("."); @@ -117,6 +128,14 @@ export const formatNumber = (number: number, precision = 0) => { }).format(number); }; +/** + * trims string to 31 characters max + * - bytes32 string must be less than 32 bytes (31 characters) + */ +export const stringToBytes32String = (str: string) => { + return str.slice(0, 31); +}; + export const isTestnet = (networkId: NetworkId) => { const testnets = [ NetworkId.ARBITRUM_TESTNET, @@ -142,6 +161,24 @@ export const isChainEthereum = ({ return chainId === NetworkId.MAINNET || chainId === NetworkId.TESTNET_GOERLI; }; +const isValidIPFSurl = (url: string) => { + return ~url?.indexOf("ipfs://") === -1; +}; + +export const isValidUrl = (url: string) => { + if (isValidIPFSurl(url)) return true; + const urlPattern = new RegExp( + "^((https)?:\\/\\/)?" + // validate protocol + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // validate domain name + "((\\d{1,3}\\.){3}\\d{1,3}))" + // validate OR ip (v4) address + "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // validate port and path + "(\\?[;&a-z\\d%_.~+=-]*)?" + // validate query string + "(\\#[-a-z\\d_]*)?$", + "i", + ); // validate fragment locator + return !!urlPattern.test(url); +}; + //maps known testnet contracts to mainnet for testing liquidity vaults export const testnetToMainnetContract = (address: string) => { switch (address.toLowerCase()) { diff --git a/src/helpers/timeUtil.ts b/src/helpers/timeUtil.ts index 6effbdab19..ce98b397e4 100644 --- a/src/helpers/timeUtil.ts +++ b/src/helpers/timeUtil.ts @@ -18,6 +18,10 @@ export function prettifySeconds(seconds: number, resolution?: string) { return ""; } + if (seconds < 60) { + return Math.round(seconds) + " second" + (seconds == 1 ? "" : "s"); + } + const d = Math.floor(seconds / (3600 * 24)); const h = Math.floor((seconds % (3600 * 24)) / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -32,6 +36,7 @@ export function prettifySeconds(seconds: number, resolution?: string) { let result = dDisplay + hDisplay + mDisplay; if (mDisplay === "") { + // remove the trailing ", " after hr(s) result = result.slice(0, result.length - 2); } diff --git a/src/hooks/useBalance.ts b/src/hooks/useBalance.ts index 74f4439fed..7a0610ce9c 100644 --- a/src/hooks/useBalance.ts +++ b/src/hooks/useBalance.ts @@ -7,10 +7,12 @@ import { FUSE_POOL_36_ADDRESSES, GOHM_ADDRESSES, GOHM_TOKEMAK_ADDRESSES, + GOVERNANCE_GOHM_ADDRESSES, OHM_ADDRESSES, SOHM_ADDRESSES, V1_OHM_ADDRESSES, V1_SOHM_ADDRESSES, + VOTE_TOKEN_ADDRESSES, WSOHM_ADDRESSES, } from "src/constants/addresses"; import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; @@ -91,3 +93,6 @@ export const useWsohmBalance = () => useBalance(WSOHM_ADDRESSES); export const useV1OhmBalance = () => useBalance(V1_OHM_ADDRESSES); export const useV1SohmBalance = () => useBalance(V1_SOHM_ADDRESSES); export const useGohmTokemakBalance = () => useBalance(GOHM_TOKEMAK_ADDRESSES); +export const useVoteBalance = () => useBalance(VOTE_TOKEN_ADDRESSES); +/** governance gOHM handles the mock gOHM on goerli & the normal mainnet gohm on mainnet */ +export const useGovernanceGohmBalance = () => useBalance(GOVERNANCE_GOHM_ADDRESSES); diff --git a/src/hooks/useProposal.ts b/src/hooks/useProposal.ts new file mode 100644 index 0000000000..a4cb5c1e13 --- /dev/null +++ b/src/hooks/useProposal.ts @@ -0,0 +1,337 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ContractReceipt, ethers } from "ethers"; +import toast from "react-hot-toast"; +import { GOVERNANCE_GOHM_ADDRESSES, VOTE_TOKEN_ADDRESSES } from "src/constants/addresses"; +import { GOV_INSTRUCTIONS_CONTRACT, GOVERNANCE_CONTRACT } from "src/constants/contracts"; +import { parseBigNumber, stringToBytes32String } from "src/helpers"; +import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; +import { nonNullable } from "src/helpers/types/nonNullable"; +import { IPFSFileData, IProposalJson, makeJsonFile, uploadToIPFS } from "src/helpers/Web3Storage"; +import { useArchiveNodeProvider } from "src/hooks/useArchiveNodeProvider"; +/// Import Proposal data type and mock data getters from useProposals +import { + IAnyProposal, + notEnoughVotesToExecute, + parseProposalContent, + parseProposalState, + ProposalAction, + useActivationTimelines, + useGetProposalURIFromEvent, +} from "src/hooks/useProposals"; +import { useVotingCollateralMinimum, useVotingCollateralRequirement, useVotingSupply } from "src/hooks/useVoting"; +import { queryClient } from "src/lib/react-query"; +import { InstructionStructOutput } from "src/typechain/OlympusGovInstructions"; +import { useAccount, useNetwork, useSigner } from "wagmi"; + +/** + * @notice Query key for useProposal which is dependent on instructionsIndex + * @param instructionsIndex The index number of the proposal to fetch + */ +export const proposalQueryKey = (instructionsIndex: number) => ["useProposal", instructionsIndex].filter(nonNullable); +export const proposalMetadataQueryKey = (instructionsIndex: number) => + ["GetProposalMetadata", instructionsIndex].filter(nonNullable); + +/** + * @notice Fetches the metadata, related endorsements, yes votes, and no votes for the proposal + * at the passed index. Uses the proposal metadata to fetch the IPFS URIs containing the + * proposal content. Puts it into a big Proposal object. + * @param instructionsIndex The index number of the proposal to fetch + * @returns Query object in which the data attribute holds a Proposal object for the proposal at + * relevant index + * * TODO: This needs to be refactored to use dependent queries. We cannot nest useQuery calls as mocked here. + */ +export const useProposal = (instructionsIndex: number) => { + /// const IPFSDContract = ""; + /// const governanceContract = ""; + const { chain = { id: 1 } } = useNetwork(); + const archiveProvider = useArchiveNodeProvider(chain?.id); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id, archiveProvider); + const queryKey = proposalQueryKey(instructionsIndex); + const { data: proposalURI } = useGetProposalURIFromEvent({ + proposalId: instructionsIndex, + }); + const { data: metadata, isFetched: metadataIsFetched } = useQuery( + proposalMetadataQueryKey(instructionsIndex), + async () => { + console.log("GetProposalMetadata", instructionsIndex); + return await contract.getProposalMetadata(instructionsIndex); + }, + ); + + const { data: activationTimelines } = useActivationTimelines(); + + const query = useQuery( + queryKey, + async () => { + if (metadata === undefined || activationTimelines === undefined) { + // this should be impossible if the enabled block checks !!metadata, too + // but it's necessary to fix the metadata | undefined typing in the rest of this query + throw new Error("something went wrong with proposalMetadata."); + } else { + /// For the specified proposal index, fetch the relevant data points used in the frontend + const proposalContent = await parseProposalContent({ uri: proposalURI }); + const content: string = proposalContent.description; + const discussionURL: string = proposalContent.external_url; + /** + * submissionTimestamp as a JS Time (milliseconds) from the contract (was a unix time (seconds) on the contract) + * NOTE(appleseed): multiply unixTimestamps by 1000 to convert to JS Time from Unix Time + */ + const submissionTimestamp = parseBigNumber(metadata.submissionTimestamp, 0) * 1000; + const activationTimestamp = parseBigNumber(metadata.activationTimestamp, 0) * 1000; + const activationTimelock = parseBigNumber(activationTimelines.activationTimelock, 0) * 1000; + const activationDeadline = parseBigNumber(activationTimelines.activationDeadline, 0) * 1000; + const executionTimelock = parseBigNumber(activationTimelines.executionTimelock, 0) * 1000; + const executionDeadline = parseBigNumber(activationTimelines.executionDeadline, 0) * 1000; + const collateralDuration = parseBigNumber(activationTimelines.collateralDuration, 0) * 1000; + const votingPeriod = parseBigNumber(activationTimelines.votingPeriod, 0) * 1000; + const earliestActivation = submissionTimestamp + activationTimelock; + const activationExpiry = submissionTimestamp + activationDeadline; + const earliestExecution = activationTimestamp + executionTimelock; + const executionExpiry = activationTimestamp + executionDeadline; + const votingExpiry = activationTimestamp + votingPeriod; + const isActive = votingExpiry > Date.now(); + + const collateralClaimableAt = submissionTimestamp + collateralDuration; + const totalRegisteredVotes = parseBigNumber(metadata.totalRegisteredVotes, 18); + const yesVotes = parseBigNumber(metadata.yesVotes, 18); + const noVotes = parseBigNumber(metadata.noVotes, 18); + const notEnoughVotes = notEnoughVotesToExecute({ + totalRegisteredVotes, + yesVotes, + noVotes, + executionThreshold: activationTimelines.executionThresholdAsNumber, + }); + + const proposalState = parseProposalState({ + isExecuted: metadata.isExecuted, + notEnoughVotes, + activationTimestamp, + earliestActivation, + activationExpiry, + votingExpiry, + earliestExecution, + executionExpiry, + }); + + const currentProposal = { + id: instructionsIndex, + title: proposalContent.name, + submitter: metadata.submitter, + collateralClaimableAt: collateralClaimableAt, + submissionTimestamp: submissionTimestamp, + activationTimestamp: activationTimestamp, + activationDeadline: activationDeadline, + activationExpiry: activationExpiry, + executionDeadline: executionDeadline, + executionExpiry: executionExpiry, + isExecuted: metadata.isExecuted, + votingExpiry: votingExpiry, + timeRemaining: proposalState.jsTimeRemaining, + nextDeadline: proposalState.nextDeadline, + isActive: isActive, + state: proposalState.status, + totalRegisteredVotes, + yesVotes, + noVotes, + notEnoughVotesToExecute: notEnoughVotes, + quorum: totalRegisteredVotes * activationTimelines.executionThresholdAsNumber, + uri: discussionURL, + content, + now: new Date(), + }; + + return currentProposal; + } + }, + { + enabled: + !!chain && + !!archiveProvider && + !!contract && + proposalURI.length > 0 && + !!metadata && + metadataIsFetched && + !!activationTimelines, + }, + ); + + return query as typeof query; +}; + +export type TInstructionSet = { + action: ProposalAction; + target: string; +}; + +export interface ISubmitProposal { + name: string; + proposalURI: string; + instructions: TInstructionSet[]; +} +/** + * submit proposal at: + * https://goerli.etherscan.io/tx/0x7150ffcc290038deab9c89b1630df273273d2b428e6ee6fb6bec0ddeefe25b18 + * + * params: + * [[2,"0x5a46373152Fe723f052117fdc8E5282677808A70"]] + * 0x6d792070726f706f73616c000000000000000000000000000000000000000000 + * [{"action": "2", "target": "0x5a46373152Fe723f052117fdc8E5282677808A70"}] + * + * title (2nd param above): + * ethers.utils.formatBytes32String("my proposal name") + * 0x6d792070726f706f73616c206e616d6500000000000000000000000000000000 + * + * proposalURI: ipfs://bafybeicyta5tlfodbxkgofjoy46iexwhl2d773rjl4cryklqyx7dzikx2u/proposal.json + * + * gotcha: + * there are rules in Instructions.store() around contract addresses & naming for modules, etc + * + * deploy a new proposal for the 2nd element of the 1st param: + * # from bophades repo + * forge create src/policies/Governance.sol:Governance --constructor-args 0x3B294580Fcf1F60B94eca4f4CE78A2f52D23cC83 --rpc-url https://eth-goerli.g.alchemy.com/v2/_gg7wSSi0KMBsdKnGVfHDueq6xMB9EkC --private-key yours + * + */ +export const useSubmitProposal = () => { + const { chain = { id: 1 } } = useNetwork(); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + const { data: signer } = useSigner(); + + // TODO(appleseed): update ANY types below + return useMutation( + async ({ proposal }: { proposal: ISubmitProposal }) => { + if (!signer) throw new Error(`Signer is not set`); + console.log("proposalInstructions", proposal.instructions); + console.log("name", ethers.utils.formatBytes32String(stringToBytes32String(proposal.name))); + console.log("proposalURI", proposal.proposalURI); + // NOTE(appleseed): proposal.name is limited 31 characters, but full proposal name is uploaded in metadata via useIPFSUpload + const transaction = await contract.connect(signer).submitProposal( + proposal.instructions, + ethers.utils.formatBytes32String(stringToBytes32String(proposal.name)), + // TODO(appleseed): add back in name after contract update + proposal.proposalURI, + ); + toast("Submitted transaction to chain"); + return await transaction.wait(); + }, + { + onSuccess: () => { + toast("Successfully Submitted Proposal"); + queryClient.invalidateQueries({ queryKey: ["GetProposalSubmittedEvents", chain.id] }); + }, + }, + ); +}; + +export const useIPFSUpload = () => { + return useMutation( + async ({ proposal }: { proposal: IProposalJson }) => { + const file = makeJsonFile(proposal, "proposal.json"); + const fileInfo = await uploadToIPFS(file); + console.log("after", fileInfo); + return fileInfo; + }, + ); +}; + +/** + * how much voting power does it require to create a proposal + */ +export const useCreateProposalVotingPowerReqd = () => { + const { data: totalSupply, isFetched: supplyFetched, isLoading: supplyLoading } = useVotingSupply(); + const { + data: collateralMinimum, + isFetched: minimumFetched, + isLoading: minimumLoading, + } = useVotingCollateralMinimum(); + const { + data: collateralRequirement, + isFetched: requirementFetched, + isLoading: requirementLoading, + } = useVotingCollateralRequirement(); + // const collateral = _max( + // (VOTES.totalSupply() * COLLATERAL_REQUIREMENT) / 10_000, + // COLLATERAL_MINIMUM + // ); + const everythingFetched = supplyFetched && minimumFetched && requirementFetched; + let collateral = new DecimalBigNumber("0", 18); + if (everythingFetched && !!totalSupply && !!collateralRequirement && !!collateralMinimum) { + const numerator = totalSupply.mul(collateralRequirement); + collateral = new DecimalBigNumber((collateralMinimum.gt(numerator) ? collateralMinimum : numerator).toString(), 18); + } + + return { + data: collateral, + isLoading: supplyLoading && minimumLoading && requirementLoading, + isFetched: everythingFetched, + }; +}; + +/** get the instructions from the proposal */ +export const useGetInstructions = (proposalId: number) => { + const { chain = { id: 1 } } = useNetwork(); + const contract = GOV_INSTRUCTIONS_CONTRACT.getEthersContract(chain.id); + return useQuery( + ["getInstructions", chain.id, proposalId], + async () => { + // using EVENTS + return await contract.getInstructions(String(proposalId)); + }, + { enabled: !!chain && !!chain.id && !!contract && !!proposalId }, + ); +}; + +export const useActivateProposal = () => { + const { chain = { id: 1 } } = useNetwork(); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + const { data: signer } = useSigner(); + + // TODO(appleseed): update ANY types below + return useMutation( + async (proposalId: number) => { + if (!signer) throw new Error(`Signer is not set`); + + // NOTE(appleseed): proposal.name is limited 31 characters, but full proposal name is uploaded in metadata via useIPFSUpload + const tx = await contract.connect(signer).activateProposal(proposalId); + toast("Submitted transaction to chain"); + return await tx.wait(); + }, + { + onSuccess: (data, proposalId) => { + toast("Successfully Activated Proposal"); + queryClient.invalidateQueries({ queryKey: proposalQueryKey(proposalId) }); + queryClient.invalidateQueries({ queryKey: proposalMetadataQueryKey(proposalId) }); + }, + }, + ); +}; + +/** + * used to reclaim your vOHM collateral after the voting period + * - only the proposer should have access to this button. + */ +export const useReClaimVohm = () => { + const { chain = { id: 1 } } = useNetwork(); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + const { data: signer } = useSigner(); + const { address } = useAccount(); + + // TODO(appleseed): update ANY types below + return useMutation( + async (proposalId: number) => { + if (!signer) throw new Error(`Signer is not set`); + + // NOTE(appleseed): proposal.name is limited 31 characters, but full proposal name is uploaded in metadata via useIPFSUpload + const tx = await contract.connect(signer).reclaimCollateral(proposalId); + toast("Submitted transaction to chain"); + return await tx.wait(); + }, + { + onSuccess: (data, proposalId) => { + toast("Successfully Reclaimed Collateral"); + queryClient.invalidateQueries({ queryKey: proposalQueryKey(proposalId) }); + queryClient.invalidateQueries({ queryKey: [["useBalance", address, VOTE_TOKEN_ADDRESSES, chain.id]] }); + queryClient.invalidateQueries({ queryKey: [["useBalance", address, GOVERNANCE_GOHM_ADDRESSES, chain.id]] }); + }, + }, + ); +}; diff --git a/src/hooks/useProposals.ts b/src/hooks/useProposals.ts new file mode 100644 index 0000000000..4e7c6dbb5b --- /dev/null +++ b/src/hooks/useProposals.ts @@ -0,0 +1,394 @@ +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { ethers } from "ethers"; +import { GOVERNANCE_CONTRACT } from "src/constants/contracts"; +import { nonNullable } from "src/helpers/types/nonNullable"; +import { useArchiveNodeProvider } from "src/hooks/useArchiveNodeProvider"; +import { ProposalSubmittedEvent } from "src/typechain/OlympusGovernance"; +import { useNetwork } from "wagmi"; + +export enum ProposalAction { + InstallModule, + UpgradeModule, + ActivatePolicy, + DeactivatePolicy, + ChangeExecutor, +} + +export type IProposalReadable = { + [key in ProposalAction]: string; +}; + +export const ProposalActionsReadable: IProposalReadable = { + [ProposalAction.InstallModule]: "Install Module", + [ProposalAction.UpgradeModule]: "Upgrade Module", + [ProposalAction.ActivatePolicy]: "Activate Policy", + [ProposalAction.DeactivatePolicy]: "De-Activate Policy", + [ProposalAction.ChangeExecutor]: "Change Executor", +}; + +/// Data type for return from getProposalMetadata on Governance.sol +export interface proposalMetadata { + title: string; + submitter: string; + submissionTimestamp: number; +} + +/// Data type for returning full proposal informations +interface IProposal { + id: number; + title: string; + submitter: string; + submissionTimestamp: number; + isActive: boolean; + state: PStatus; + totalRegisteredVotes: number; + yesVotes: number; + noVotes: number; + uri: string; + content: string; +} + +export interface IAnyProposal extends Omit { + timeRemaining: number; + nextDeadline: number; + collateralClaimableAt: number; + isActive: boolean | undefined; + now: Date; + activationTimestamp: number; + activationDeadline: number; + activationExpiry: number; + executionDeadline: number; + executionExpiry: number; + isExecuted: boolean; + votingExpiry: number; + notEnoughVotesToExecute: boolean; + quorum: number; +} + +export interface IProposalContent { + name: string; + description: string; + external_url: string; +} + +/** + * the proposals current state + * - currenly only Active & Endorsements status are stored on chain + * - all other states would be stored off-chain, either from the forum + * - TODO(appleseed): how does a proposal get to "closed" state? + */ +export type PStatus = + | "discussion" // created but not ready to activate + | "ready to activate" // ready to activate for voting + | "expired activation" // missed activation window + | "active" // active for voting + | "awaiting execution" + | "ready to execute" + | "expired execution" + | "executed" // passed & executed / implemented + | "draft" + | "closed"; + +export interface IProposalState { + state: PStatus; +} + +/** + * returns a unix time differential (in seconds) + * @param numDays number of days you want + */ +export const unixDays = (numDays: number) => { + return numDays * 24 * 60 * 60; +}; + +interface IActivationTimelines { + activationDeadline: ethers.BigNumber; + activationTimelock: ethers.BigNumber; + votingPeriod: ethers.BigNumber; + collateralDuration: ethers.BigNumber; + executionThreshold: ethers.BigNumber; + executionThresholdAsNumber: number; + executionDeadline: ethers.BigNumber; + executionTimelock: ethers.BigNumber; +} + +/** time in seconds */ +export const useActivationTimelines = () => { + const { chain = { id: 1 } } = useNetwork(); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + return useQuery( + ["GetActivationTimelines", chain.id], + async () => { + const activationDeadline = await contract.ACTIVATION_DEADLINE(); + const activationTimelock = await contract.ACTIVATION_TIMELOCK(); + const votingPeriod = await contract.VOTING_PERIOD(); + // collateralDuration is time your collateral will be locked after proposing + const collateralDuration = await contract.COLLATERAL_DURATION(); + const executionThreshold = await contract.EXECUTION_THRESHOLD(); + const executionThresholdAsNumber = executionThreshold.toNumber() / 100; + const executionDeadline = await contract.EXECUTION_DEADLINE(); + const executionTimelock = await contract.EXECUTION_TIMELOCK(); + + return { + activationDeadline, + activationTimelock, + votingPeriod, + collateralDuration, + executionThreshold, + executionThresholdAsNumber, + executionDeadline, + executionTimelock, + }; + }, + { enabled: !!chain && !!chain.id && !!contract }, + ); +}; + +export const notEnoughVotesToExecute = ({ + totalRegisteredVotes, + yesVotes, + noVotes, + executionThreshold, +}: { + totalRegisteredVotes: number; + yesVotes: number; + noVotes: number; + executionThreshold: number; +}) => { + return (yesVotes - noVotes) * 100 < totalRegisteredVotes * executionThreshold; +}; + +/** + * All parameters & return values are js timestamps (milliseconds). + */ +export const parseProposalState = ({ + isExecuted, + notEnoughVotes, + activationTimestamp, + earliestActivation, + activationExpiry, + votingExpiry, + earliestExecution, + executionExpiry, +}: { + isExecuted: boolean; + notEnoughVotes: boolean; + activationTimestamp: number; + earliestActivation: number; + activationExpiry: number; + votingExpiry: number; + earliestExecution: number; + executionExpiry: number; +}): { status: PStatus; jsTimeRemaining: number; nextDeadline: number } => { + const now = Date.now(); + let status: PStatus; + let jsTimeRemaining: number; + let nextDeadline: number; + console.log("parse Proposal state", now, earliestActivation, activationExpiry, activationTimestamp, votingExpiry); + if (isExecuted) { + return { + status: "executed", + jsTimeRemaining: 0, + nextDeadline: votingExpiry, + }; + } + if (now < earliestActivation) { + console.log("now < earliestActivation - discussion", now, earliestActivation); + // "discussion" // created but not ready to activate + // block.timestamp < proposal.submissionTimestamp + ACTIVATION_TIMELOCK + status = "discussion"; + jsTimeRemaining = earliestActivation - now; + nextDeadline = earliestActivation; + } else if (now < activationExpiry && activationTimestamp === 0) { + console.log( + "now < activationExpiry && activationTimestamp === 0 - ready to activate", + now, + activationExpiry, + activationTimestamp, + ); + // | "ready to activate" // ready to activate for voting + // block.timestamp < proposal.submissionTimestamp + ACTIVATION_DEADLINE + status = "ready to activate"; + jsTimeRemaining = activationExpiry - now; + nextDeadline = activationExpiry; + } else if (now >= activationExpiry && activationTimestamp === 0) { + console.log( + "now >= activationExpiry && activationTimestamp === 0 - expired activation", + now, + activationExpiry, + activationTimestamp, + ); + // | "expired activation" // missed activation window + // block.timestamp > proposal.submissionTimestamp + ACTIVATION_DEADLINE && activationTimestamp === 0 + status = "expired activation"; + jsTimeRemaining = 0; + nextDeadline = activationExpiry; + } else if (activationTimestamp > 0 && now < votingExpiry) { + console.log("now < activationTimestamp && now < votingExpiry - active", now, activationExpiry, activationTimestamp); + status = "active"; + jsTimeRemaining = votingExpiry - now; + nextDeadline = votingExpiry; + } else if (activationTimestamp > 0 && now >= votingExpiry && notEnoughVotes) { + status = "closed"; + jsTimeRemaining = 0; + nextDeadline = votingExpiry; + } else if (activationTimestamp > 0 && now >= votingExpiry && now < earliestExecution) { + status = "awaiting execution"; + jsTimeRemaining = earliestExecution - now; + nextDeadline = earliestExecution; + } else if (activationTimestamp > 0 && now >= votingExpiry && now >= earliestExecution && now < executionExpiry) { + status = "ready to execute"; + jsTimeRemaining = executionExpiry - now; + nextDeadline = executionExpiry; + } else if (activationTimestamp > 0 && now >= votingExpiry && now >= executionExpiry) { + status = "expired execution"; + jsTimeRemaining = votingExpiry - now; + nextDeadline = votingExpiry; + } else { + console.log("else - closed"); + status = "closed"; + jsTimeRemaining = 0; + nextDeadline = votingExpiry; + } + console.log("status", status); + + // | "executed" // passed & executed / implemented + + return { + status, + jsTimeRemaining, + nextDeadline, + }; +}; + +/** + * expects a uri that returns json metadata with three keys: + * - name {string} + * - description {string} + * - external_url {string} - url link to discussion + */ +export const parseProposalContent = async ({ uri }: { uri: string | undefined }): Promise => { + const placeholder = { + name: "", + description: "", + external_url: "", + }; + if (!uri) return placeholder; + let readURI = uri; + if (~uri.indexOf("ipfs:/")) { + readURI = `https://w3s.link/ipfs/${uri.replace("ipfs://", "")}`; + } + try { + const res = await axios.get(readURI); + return { + name: res.data.name as string, + description: res.data.description as string, + external_url: res.data.external_url as string, + }; + } catch (error) { + // handle error + console.log(error); + return placeholder; + } +}; + +export const useGetProposalSubmittedEvents = () => { + const { chain = { id: 1 } } = useNetwork(); + const archiveProvider = useArchiveNodeProvider(chain?.id); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id, archiveProvider); + return useQuery( + ["GetProposalSubmittedEvents", chain.id], + async () => { + // using EVENTS + return await contract.queryFilter(contract.filters.ProposalSubmitted()); + }, + { enabled: !!chain && !!chain.id && !!archiveProvider && !!contract }, + ); +}; + +/** + * @param proposalId + * @returns `{ data: proposalURI, isLoading, isFetched }` + */ +export const useGetProposalURIFromEvent = ({ proposalId }: { proposalId: number }) => { + const { data: events, isFetched, isLoading } = useGetProposalSubmittedEvents(); + let proposalURI = ""; + if (isFetched && !!events) { + const selectedProposalEvents = events.filter((event: ProposalSubmittedEvent) => + event.args.proposalId.eq(ethers.utils.parseUnits(String(proposalId), 0)), + ); + const selectedProposalArgs = selectedProposalEvents[0]?.args; + proposalURI = selectedProposalArgs.proposalURI; + } + return { + data: proposalURI, + isLoading, + isFetched, + }; +}; + +/** + * Get the most recent Proposal Id + * - returns useQuery isLoading & isFetched results + */ +export const useGetLastProposalId = (): { data: number; isLoading: boolean; isFetched: boolean } => { + const { data: events, isFetched, isLoading } = useGetProposalSubmittedEvents(); + + //////// + // NOTE(appleseed) could also be: (if we didn't already need all the events) + /////// + // const { chain = { id: 1 } } = useNetwork(); + // const contract = GOV_INSTRUCTIONS_CONTRACT.getEthersContract(chain.id); + // return useQuery( + // ["useGetLastProposalId", chain.id], + // async () => { + // // using EVENTS + // return await contract.totalInstructions(); + // }, + // { enabled: !!chain && !!chain.id && !!archiveProvider && !!contract }, + // ); + + let data = 0; + if (isFetched && !!events && events.length > 0) { + const mostRecentEventArgs = events[events.length - 1].args; + const proposalNumber = mostRecentEventArgs.proposalId; + data = Number(ethers.utils.formatUnits(proposalNumber, 0)); + } + return { + data, + isLoading, + isFetched, + }; +}; + +/** + * Query key for useProposals. Doesn't need to be refreshed on address or network changes + * Proposals should be fetched no matter what. + */ +export const proposalsQueryKey = (filters: IProposalState) => { + if (filters) { + return ["useProposals", filters].filter(nonNullable); + } else { + return ["useProposals"].filter(nonNullable); + } +}; + +export const filterStatement = ({ proposal, filters }: { proposal: IAnyProposal; filters: IProposalState }) => { + let result = proposal.isActive; + if (filters) { + switch (filters.state) { + case "active": + result = proposal.isActive; + break; + case "discussion": + case "draft": + case "closed": + result = proposal.isActive === false; + break; + default: + console.log(`Sorry, we are out of somethings wrong.`); + } + } + return result; +}; diff --git a/src/hooks/useVoting.ts b/src/hooks/useVoting.ts new file mode 100644 index 0000000000..49c2564e0c --- /dev/null +++ b/src/hooks/useVoting.ts @@ -0,0 +1,347 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { BigNumber, ContractReceipt, ethers } from "ethers"; +import toast from "react-hot-toast"; +import { GOVERNANCE_GOHM_ADDRESSES, VOTE_TOKEN_ADDRESSES } from "src/constants/addresses"; +import { GOVERNANCE_CONTRACT, GOVERNANCE_VOHM_VAULT_CONTRACT, VOTE_TOKEN_CONTRACT } from "src/constants/contracts"; +import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; +import { useArchiveNodeProvider } from "src/hooks/useArchiveNodeProvider"; +import { useGovernanceGohmBalance, useVoteBalance } from "src/hooks/useBalance"; +import { proposalMetadataQueryKey, proposalQueryKey } from "src/hooks/useProposal"; +import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { queryClient } from "src/lib/react-query"; +import { VotesCastEvent } from "src/typechain/OlympusGovernance"; +import { useAccount, useNetwork, useSigner } from "wagmi"; + +interface Vote { + proposalId: BigNumber; + vote: boolean; +} + +interface ActivatedProposal { + proposalId: BigNumber; + activationTimestamp: BigNumber; +} + +/** + * event is not indexed so can't filter by proposalId in the `VotesCast()` event + * - as a result this returns ALL VOTES CAST EVENTS ON PARTHENON + */ +export const useGetVotesCastEvents = () => { + const { chain = { id: 1 } } = useNetwork(); + const archiveProvider = useArchiveNodeProvider(chain?.id); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id, archiveProvider); + return useQuery( + ["GetVotesCastEvents", chain.id], + async () => { + // using EVENTS + return await contract.queryFilter(contract.filters.VotesCast()); + }, + { enabled: !!chain && !!chain.id && !!archiveProvider && !!contract }, + ); +}; + +const voteCastEventProposalIdComparison = (event: VotesCastEvent, proposalId: number) => { + return event.args?.proposalId.eq(ethers.utils.parseUnits(String(proposalId), 0)); +}; + +const voteCastEventVoterAddressComparison = (event: VotesCastEvent, voterAddress: string) => { + return event.args?.voter === voterAddress; +}; + +/** + * returns all VotesCast events by ALL voters for THIS proposal + */ +export const useGetVotesCastForProposal = ( + proposalId: number, +): { data: VotesCastEvent[]; isFetched: boolean; isLoading: boolean } => { + const { data: votesCastEvents, isFetched, isLoading } = useGetVotesCastEvents(); + let thisProposal: VotesCastEvent[] = []; + if (isFetched && !!votesCastEvents && votesCastEvents?.length > 0) { + thisProposal = votesCastEvents.filter((event: VotesCastEvent) => + voteCastEventProposalIdComparison(event, proposalId), + ); + } + return { + data: thisProposal, + isFetched, + isLoading, + }; +}; + +/** + * returns all VotesCast events by ALL voters for THIS proposal sorted by largest vote power + */ +export const useGetVotesCastForProposalBySize = ( + proposalId: number, +): { data: VotesCastEvent[]; isFetched: boolean; isLoading: boolean } => { + const { data: votesCastEvents, isFetched, isLoading } = useGetVotesCastForProposal(proposalId); + const sorted = votesCastEvents.sort( + (a, b) => Number(a.args.userVotes.toString()) - Number(b.args.userVotes.toString()), + ); + return { + data: sorted, + isFetched, + isLoading, + }; +}; + +/** + * returns all VotesCast events by THIS voter for THIS proposal + */ +export const useGetVotesCastForProposalAndVoter = ( + proposalId: number, + voterAddress: string, +): { data: VotesCastEvent[]; isFetched: boolean; isLoading: boolean } => { + const { data: votesCastEvents, isFetched, isLoading } = useGetVotesCastEvents(); + let thisProposal: VotesCastEvent[] = []; + if (isFetched && !!votesCastEvents && votesCastEvents?.length > 0) { + thisProposal = votesCastEvents.filter( + (event: VotesCastEvent) => + voteCastEventProposalIdComparison(event, proposalId) && + voteCastEventVoterAddressComparison(event, voterAddress), + ); + } + return { + data: thisProposal, + isFetched, + isLoading, + }; +}; + +/** + * returns all VotesCast events by THIS voter for ALL PROPOSALS + */ +export const useGetVotesCastByVoter = ( + voterAddress: string, +): { data: VotesCastEvent[]; isFetched: boolean; isLoading: boolean } => { + const { data: votesCastEvents, isFetched, isLoading } = useGetVotesCastEvents(); + let thisVoter: VotesCastEvent[] = []; + if (isFetched && !!votesCastEvents && votesCastEvents?.length > 0) { + thisVoter = votesCastEvents.filter((event: VotesCastEvent) => + voteCastEventVoterAddressComparison(event, voterAddress), + ); + } + return { + data: thisVoter, + isFetched, + isLoading, + }; +}; + +/** + * returns the total vote value (# of tokens) for the connected wallet for the current proposal + */ +export const useUserVote = (proposalId: number, voterAddress: string) => { + const { data: votesCastEvents, isLoading, isFetched } = useGetVotesCastForProposalAndVoter(proposalId, voterAddress); + let thisVote: DecimalBigNumber = new DecimalBigNumber("0", 18); + let voteYes = false; + if (isFetched && !!votesCastEvents && votesCastEvents?.length > 0) { + thisVote = new DecimalBigNumber(votesCastEvents[0].args.userVotes, 18); + voteYes = votesCastEvents[0].args.approve; + } + return { + data: { amount: thisVote, voteYes }, + isFetched, + isLoading, + }; +}; + +/** + * returns the total supply of the Vote Token + */ +export const useVotingSupply = () => { + const { chain = { id: 1 } } = useNetwork(); + const contract = VOTE_TOKEN_CONTRACT.getEthersContract(chain.id); + + return useQuery( + ["getVoteTokenTotalSupply", chain?.id], + async () => { + const votingSupply = await contract.totalSupply(); + return new DecimalBigNumber(votingSupply, 18); + }, + { enabled: !!chain?.id }, + ); +}; + +/** + * returns the collateral Minimum + */ +export const useVotingCollateralMinimum = () => { + const { chain = { id: 1 } } = useNetwork(); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + + return useQuery( + ["getVotingCollateralMinimum", chain?.id], + async () => { + const collateral = await contract.COLLATERAL_MINIMUM(); + return new DecimalBigNumber(collateral, 18); + }, + { enabled: !!chain?.id }, + ); +}; + +/** + * returns the collateral Minimum as a decimal + * - i.e. 0.05 = 5% + */ +export const useVotingCollateralRequirement = () => { + const { chain = { id: 1 } } = useNetwork(); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + + return useQuery( + ["getVotingCollateralRequirement", chain?.id], + async () => { + const collateral = await contract.COLLATERAL_REQUIREMENT(); + return new DecimalBigNumber(collateral, 4); + }, + { enabled: !!chain?.id }, + ); +}; + +// export const useEndorse = () => { +// const dispatch = useDispatch(); + +// const { chain = { id: 1 } } = useNetwork(); +// const { data: signer } = useSigner(); + +// const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + +// return useMutation( +// async ({ proposalId }: { proposalId: BigNumber }) => { +// if (!signer) throw new Error("No signer connected, cannot endorse"); + +// // NOTE (lienid): can't decide if it is worth calling totalInstructions on the INSTR contract +// // to make sure that the passed ID is valid. If it is being fed through by the +// // site then there's no reason it should be invalid. Also don't know if -1 may +// // ever be passed as some sort of default value +// if (proposalId.eq(-1)) throw new Error(t`Cannot endorse proposal with invalid ID`); + +// const transaction = await contract.connect(signer).registerForProposal(proposalId); +// return transaction.wait(); +// }, +// { +// onError: error => { +// console.error(error.message); +// }, +// onSuccess: () => { +// console.log(`Successfully endorsed proposal`); +// }, +// }, +// ); +// }; + +export const useVote = () => { + const { chain = { id: 1 } } = useNetwork(); + const { data: signer } = useSigner(); + const contract = GOVERNANCE_CONTRACT.getEthersContract(chain.id); + const networks = useTestableNetworks(); + const { data: balance } = useVoteBalance()[networks.MAINNET]; + + return useMutation( + async ({ voteData }: { voteData: Vote }) => { + if (!signer) throw new Error("No signer connected, cannot endorse"); + if (!balance) throw new Error("You cannot Vote without vOHM"); + + const transaction = await contract.connect(signer).vote(voteData.proposalId, voteData.vote); + toast("Submitted transaction to chain"); + return transaction.wait(); + }, + { + onError: error => { + console.error(error.message); + }, + onSuccess: (tx, { voteData }) => { + toast(`Successfully voted for proposal`); + queryClient.invalidateQueries({ queryKey: ["GetVotesCastEvents", chain.id] }); + queryClient.invalidateQueries({ queryKey: proposalQueryKey(voteData.proposalId.toNumber()) }); + queryClient.invalidateQueries({ queryKey: proposalMetadataQueryKey(voteData.proposalId.toNumber()) }); + }, + }, + ); +}; + +/** for a user to get voting power */ +export const useWrapToVohm = () => { + const queryClient = useQueryClient(); + const { address = "" } = useAccount(); + const { chain = { id: 1 } } = useNetwork(); + const { data: signer } = useSigner(); + const contract = GOVERNANCE_VOHM_VAULT_CONTRACT.getEthersContract(chain.id); + const networks = useTestableNetworks(); + const { data: balance } = useGovernanceGohmBalance()[networks.MAINNET]; + + return useMutation( + async (amount: string) => { + if (!signer) throw new Error("No signer connected, cannot endorse"); + if (!amount || isNaN(Number(amount))) throw new Error(`Please enter a number`); + + const _amount = new DecimalBigNumber(amount, 18); + + if (!_amount.gt("0")) throw new Error(`Please enter a number greater than 0`); + + if (!balance) throw new Error(`Please refresh your page and try again`); + + if (_amount.gt(balance)) throw new Error(`You cannot wrap more than your gOHM balance`); + + if (!contract) throw new Error(`Please switch to the Ethereum network to wrap your gOHM`); + + const transaction = await contract.connect(signer).deposit(_amount.toBigNumber()); + toast("Submitted transaction to chain"); + return transaction.wait(); + }, + { + onError: error => { + console.error(error.message); + }, + onSuccess: () => { + toast(`Successfully wrapped to vOHM`); + queryClient.invalidateQueries({ queryKey: [["useBalance", address, VOTE_TOKEN_ADDRESSES, chain.id]] }); + queryClient.invalidateQueries({ queryKey: [["useBalance", address, GOVERNANCE_GOHM_ADDRESSES, chain.id]] }); + queryClient.invalidateQueries({ queryKey: ["getVoteTokenTotalSupply", chain.id] }); + }, + }, + ); +}; + +/** for a user to unwrap voting power to gOHM */ +export const useUnwrapFromVohm = () => { + const queryClient = useQueryClient(); + const { address = "" } = useAccount(); + const { chain = { id: 1 } } = useNetwork(); + const { data: signer } = useSigner(); + const contract = GOVERNANCE_VOHM_VAULT_CONTRACT.getEthersContract(chain.id); + const networks = useTestableNetworks(); + const { data: balance } = useVoteBalance()[networks.MAINNET]; + + return useMutation( + async (amount: string) => { + if (!signer) throw new Error("No signer connected, cannot endorse"); + if (!amount || isNaN(Number(amount))) throw new Error(`Please enter a number`); + + const _amount = new DecimalBigNumber(amount, 18); + + if (!_amount.gt("0")) throw new Error(`Please enter a number greater than 0`); + + if (!balance) throw new Error(`Please refresh your page and try again`); + + if (_amount.gt(balance)) throw new Error(`You cannot unwrap more than your vOHM balance`); + + if (!contract) throw new Error(`Please switch to the Ethereum network to unwrap your vOHM`); + + const transaction = await contract.connect(signer).withdraw(_amount.toBigNumber()); + toast("Submitted transaction to chain"); + return transaction.wait(); + }, + { + onError: error => { + console.error(error.message); + }, + onSuccess: () => { + toast(`Successfully unwrapped to gOHM`); + queryClient.invalidateQueries({ queryKey: [["useBalance", address, VOTE_TOKEN_ADDRESSES, chain.id]] }); + queryClient.invalidateQueries({ queryKey: [["useBalance", address, GOVERNANCE_GOHM_ADDRESSES, chain.id]] }); + queryClient.invalidateQueries({ queryKey: ["getVoteTokenTotalSupply", chain.id] }); + }, + }, + ); +}; diff --git a/src/setupTests.tsx b/src/setupTests.tsx index 4c22f270e9..faf0f0a962 100644 --- a/src/setupTests.tsx +++ b/src/setupTests.tsx @@ -53,6 +53,9 @@ beforeEach(() => { listen: vi.fn(), createHref: vi.fn(), })); + vi.mock("react-markdown", () => ({})); + vi.mock("@uiw/react-markdown-preview", () => ({})); + vi.mock("@uiw/react-md-editor", () => ({})); Object.defineProperty(window, "matchMedia", { writable: true, diff --git a/src/themes/dark.js b/src/themes/dark.js index d8ef0dd42f..457be87cd7 100644 --- a/src/themes/dark.js +++ b/src/themes/dark.js @@ -50,6 +50,13 @@ export const dark = createTheme( } `, }, + MuiLinearProgress: { + styleOverrides: { + barColorSecondary: { + backgroundColor: colors.primary[300], + }, + }, + }, MuiSwitch: { styleOverrides: { switchBase: { @@ -353,6 +360,13 @@ export const dark = createTheme( }, }, }, + MuiSvgIcon: { + styleOverrides: { + colorAction: { + color: colors.primary[300], + }, + }, + }, MuiTypography: { styleOverrides: { root: { diff --git a/src/themes/global.js b/src/themes/global.js index 69202d62f4..ab8f9453a8 100644 --- a/src/themes/global.js +++ b/src/themes/global.js @@ -227,6 +227,9 @@ const commonSettings = { MuiTab: { styleOverrides: { root: { + "&.MuiTab-root": { + fontSize: "15px", + }, minWidth: "min-content !important", padding: "0px", margin: "0px 10px", diff --git a/src/themes/light.js b/src/themes/light.js index 16d2bb96a6..91270028f2 100644 --- a/src/themes/light.js +++ b/src/themes/light.js @@ -53,6 +53,13 @@ export const light = createTheme( } `, }, + MuiLinearProgress: { + styleOverrides: { + barColorSecondary: { + backgroundColor: colors.primary[300], + }, + }, + }, MuiSwitch: { styleOverrides: { switchBase: { @@ -371,6 +378,13 @@ export const light = createTheme( }, }, }, + MuiSvgIcon: { + styleOverrides: { + colorAction: { + color: colors.primary[300], + }, + }, + }, MuiTypography: { styleOverrides: { root: { diff --git a/src/views/Governance/Governance.scss b/src/views/Governance/Governance.scss new file mode 100644 index 0000000000..67ecd8885d --- /dev/null +++ b/src/views/Governance/Governance.scss @@ -0,0 +1,9 @@ +.proposals-dash { + width: 100%; + display: flex; + justify-content: center; + + .dashboard-actions { + margin-bottom: 25px; + } +} diff --git a/src/views/Governance/Governance.tsx b/src/views/Governance/Governance.tsx new file mode 100644 index 0000000000..bcdbe0540e --- /dev/null +++ b/src/views/Governance/Governance.tsx @@ -0,0 +1,20 @@ +import { Route, Routes } from "react-router-dom"; +import PageTitle from "src/components/PageTitle"; +import { CreateProposal } from "src/views/Governance/components/CreateProposal"; +import { ProposalPage } from "src/views/Governance/components/ProposalPage"; +import { VotingPower } from "src/views/Governance/components/ProposalPage/components/VotingPower"; +import { ProposalsDashboard } from "src/views/Governance/ProposalsDashboard"; + +export const Governance = () => { + return ( + <> + + + } /> + } /> + } /> + } /> + + + ); +}; diff --git a/src/views/Governance/ProposalsDashboard.tsx b/src/views/Governance/ProposalsDashboard.tsx new file mode 100644 index 0000000000..ba76c5b249 --- /dev/null +++ b/src/views/Governance/ProposalsDashboard.tsx @@ -0,0 +1,121 @@ +import "src/views/Governance/Governance.scss"; + +import { Box, Grid, Link, Typography } from "@mui/material"; +import { Skeleton } from "@mui/material"; +import { Paper, Tab, Tabs } from "@olympusdao/component-library"; +import { useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { useProposal } from "src/hooks/useProposal"; +import { useGetLastProposalId } from "src/hooks/useProposals"; +import ActionButtons from "src/views/Governance/components/ActionButtons"; +import { FilterModal } from "src/views/Governance/components/FilterModal"; +import { Proposal } from "src/views/Governance/components/Proposal"; +import { SearchBar } from "src/views/Governance/components/SearchBar/SearchBar"; +import { toCapitalCase } from "src/views/Governance/helpers"; + +export const ProposalsDashboard = () => { + const [isFilterModalOpen, setIsFilterModalOpen] = useState(false); + const { data: numberOfProposals, isLoading } = useGetLastProposalId(); + + const handleFilterClick = () => { + setIsFilterModalOpen(true); + }; + + const handleFilterModalCancel = () => { + setIsFilterModalOpen(false); + }; + + const renderProposals = () => { + const coercedNumber = Number(numberOfProposals); + // TODO(appleseed): properly handle 0 proposals + if (numberOfProposals && coercedNumber > 0) { + // TODO(appleseed): just parsing last 10 proposals right now + const proposals = []; + for (let i = coercedNumber; i > Math.max(coercedNumber - 10, 0); i--) { + proposals.push(); + } + return proposals; + } + return ; + }; + + return ( +
+ + + + Proposals + + + + + + + + + + + {/* + + Filter + + */} + + <>{isLoading ? : renderProposals()} + + + + +
+ ); +}; + +const ProposalContainer = ({ instructionsId }: { instructionsId: number }) => { + const { data: proposal, isLoading } = useProposal(instructionsId); + + return ( + <> + {isLoading || !proposal ? ( + + ) : ( + + + + + + )} + + ); +}; + +export const ProposalSkeleton = () => { + return ( + + + + ); +}; diff --git a/src/views/Governance/components/ActionButtons/ActionButtons.tsx b/src/views/Governance/components/ActionButtons/ActionButtons.tsx new file mode 100644 index 0000000000..bbdb14919a --- /dev/null +++ b/src/views/Governance/components/ActionButtons/ActionButtons.tsx @@ -0,0 +1,35 @@ +import { Box, Link } from "@mui/material"; +import { PrimaryButton, SecondaryButton } from "@olympusdao/component-library"; +import { FC, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import DelegateModal from "src/views/Governance/components/DelegateModal"; + +/** + * Component for Displaying ActionButtons + */ +const ActionButtons: FC = () => { + const [open, setOpen] = useState(false); + const handleClose = () => { + setOpen(false); + }; + + return ( + // + + + Create new Proposal + + {/* setOpen(true)}>Get Voting Power */} + {/* */} + + + Get More Voting Power + + + {/* */} + + + ); +}; + +export default ActionButtons; diff --git a/src/views/Governance/components/ActionButtons/index.ts b/src/views/Governance/components/ActionButtons/index.ts new file mode 100644 index 0000000000..b11e646db5 --- /dev/null +++ b/src/views/Governance/components/ActionButtons/index.ts @@ -0,0 +1 @@ +export { default } from "./ActionButtons"; diff --git a/src/views/Governance/components/BackButton/BackButton.tsx b/src/views/Governance/components/BackButton/BackButton.tsx new file mode 100644 index 0000000000..b99e0b7a51 --- /dev/null +++ b/src/views/Governance/components/BackButton/BackButton.tsx @@ -0,0 +1,20 @@ +import { ChevronLeft } from "@mui/icons-material"; +import { Box, Grid, Link, Typography } from "@mui/material"; +import { Link as RouterLink } from "react-router-dom"; + +export const BackButton = () => { + return ( + + + + + + + + Back + + + + + ); +}; diff --git a/src/views/Governance/components/BackButton/index.ts b/src/views/Governance/components/BackButton/index.ts new file mode 100644 index 0000000000..f2438ee192 --- /dev/null +++ b/src/views/Governance/components/BackButton/index.ts @@ -0,0 +1 @@ +export { BackButton } from "./BackButton"; diff --git a/src/views/Governance/components/CreateProposal/CreateProposal.tsx b/src/views/Governance/components/CreateProposal/CreateProposal.tsx new file mode 100644 index 0000000000..a91b883655 --- /dev/null +++ b/src/views/Governance/components/CreateProposal/CreateProposal.tsx @@ -0,0 +1,212 @@ +import { Box, Grid, InputLabel, Select, Skeleton, styled, Typography } from "@mui/material"; +import { Paper, PrimaryButton } from "@olympusdao/component-library"; +import MDEditor from "@uiw/react-md-editor"; +import { ethers } from "ethers"; +import { FC, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import rehypeSanitize from "rehype-sanitize"; +import { TokenAllowanceGuard } from "src/components/TokenAllowanceGuard/TokenAllowanceGuard"; +import { GOVERNANCE_ADDRESSES, GOVERNANCE_GOHM_ADDRESSES } from "src/constants/addresses"; +import { isValidUrl } from "src/helpers"; +import { useBalance } from "src/hooks/useBalance"; +import { useCreateProposalVotingPowerReqd, useIPFSUpload, useSubmitProposal } from "src/hooks/useProposal"; +import { ProposalAction } from "src/hooks/useProposals"; +import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { BackButton } from "src/views/Governance/components/BackButton"; +import { TextEntry } from "src/views/Governance/components/CreateProposal/components/TextEntry"; +import { MarkdownPreview } from "src/views/Governance/components/MarkdownPreview"; +import { InstructionsDetails } from "src/views/Governance/components/ProposalPage/components/PollDetailsTab"; + +export const CreateProposal = () => { + const ipfsUpload = useIPFSUpload(); + const submitProposal = useSubmitProposal(); + const [proposalTitle, setProposalTitle] = useState(); + const [proposalDescription, setProposalDescription] = useState(); + const [proposalDiscussion, setProposalDiscussion] = useState(); + const { data: collateralRequired, isLoading: collateralRequiredLoading } = useCreateProposalVotingPowerReqd(); + const networks = useTestableNetworks(); + // const { data: gOhmBalance } = useVoteBalance()[networks.MAINNET]; + const { data: gOhmBalance } = useBalance(GOVERNANCE_GOHM_ADDRESSES)[networks.MAINNET]; + // TODO(appleseed): need to allow multiple instructions + const [proposalAction, setProposalAction] = useState(ProposalAction.InstallModule); + const [proposalContract, setProposalContract] = useState(); + const navigate = useNavigate(); + + const StyledInputLabel = styled(InputLabel)(() => ({ + lineHeight: "24px", + fontSize: "15px", + marginBottom: "3px", + })); + + const SelectionInput: FC = () => { + return ( + + + Action + {/* // TODO(appleseed): need to allow multiple instructions */} + + + + ); + }; + + const discussionIsValidURL = isValidUrl(proposalDiscussion as string); + const isValidAddress = ethers.utils.isAddress(proposalContract as string); + const proposalFieldsComplete = !!proposalTitle && !!proposalDescription && !!discussionIsValidURL && !!isValidAddress; + const canSubmit = () => { + if ( + !proposalFieldsComplete || + submitProposal.isLoading || + collateralRequiredLoading || + !gOhmBalance || + collateralRequired.gt(gOhmBalance) + ) + return false; + return true; + }; + + // https://goerli.etherscan.io/tx/0x7150ffcc290038deab9c89b1630df273273d2b428e6ee6fb6bec0ddeefe25b18 + const handleFormSubmission = async () => { + if (!proposalFieldsComplete) { + return; + } else { + const proposal = { + name: proposalTitle as string, + description: proposalDescription as string, + content: proposalDescription as string, + external_url: proposalDiscussion as string, + }; + const fileData = await ipfsUpload.mutateAsync({ proposal }); + if (fileData) { + const proposalURI = `ipfs://${fileData.path}`; + // TODO(appleseed): need to allow multiple instructions + const instructions = [{ action: proposalAction as ProposalAction, target: proposalContract as string }]; + submitProposal.mutate( + { proposal: { name: proposal.name, instructions, proposalURI } }, + { + onSuccess: () => { + navigate("/governance"); + }, + }, + ); + } else { + // TODO(appleseed): there was a problem uploading your proposal to IPFS + } + } + }; + + return ( + + + + + + + Description + (value ? setProposalDescription(value) : setProposalDescription(""))} + height={400} + visibleDragbar={false} + previewOptions={{ + rehypePlugins: [[rehypeSanitize]], + }} + /> + + {proposalDescription?.length || 0}/14,400 + + + + + + + + + + + {!!proposalContract && } + + + + + {submitProposal.isLoading ? "Submitting..." : "Continue"} + + + + + + + + + ); +}; + +const CollateralRequiredText = () => { + const { data: collateralRequired, isLoading: collateralRequiredLoading } = useCreateProposalVotingPowerReqd(); + const networks = useTestableNetworks(); + const { data: gOhmBalance, isLoading: gOHMLoading } = useBalance(GOVERNANCE_GOHM_ADDRESSES)[networks.MAINNET]; + + return ( + + + {collateralRequiredLoading ? ( + <> + + •{` Creating a Proposal requires `} + + + + {` gOHM`} + + + ) : ( + + •{` Creating a Proposal requires ${collateralRequired.toApproxNumber()} gOHM`} + + )} + + + {gOHMLoading || !gOhmBalance ? ( + <> + + •{` You have `} + + + + {` gOHM`} + + + ) : ( + + •{` You have ${gOhmBalance.toApproxNumber()} gOHM`} + + )} + + + ); +}; diff --git a/src/views/Governance/components/CreateProposal/components/TextEntry.tsx b/src/views/Governance/components/CreateProposal/components/TextEntry.tsx new file mode 100644 index 0000000000..6505627ecc --- /dev/null +++ b/src/views/Governance/components/CreateProposal/components/TextEntry.tsx @@ -0,0 +1,19 @@ +import { Box, styled } from "@mui/material"; +import { Input } from "@olympusdao/component-library"; + +type TextEntryProps = { + label: "Title" | "Description" | "Discussion" | "Target"; + handleChange: (value: string) => void; + placeholder?: string; +}; + +const StyledProposalBox = styled(Box)(() => ({ + padding: "10px 0px 10px 0px", +})); +export const TextEntry = ({ label, handleChange, placeholder }: TextEntryProps) => { + return ( + + handleChange(e.target.value)} /> + + ); +}; diff --git a/src/views/Governance/components/CreateProposal/index.ts b/src/views/Governance/components/CreateProposal/index.ts new file mode 100644 index 0000000000..881e6fe273 --- /dev/null +++ b/src/views/Governance/components/CreateProposal/index.ts @@ -0,0 +1 @@ +export { CreateProposal } from "./CreateProposal"; diff --git a/src/views/Governance/components/DelegateModal/DelegateModal.tsx b/src/views/Governance/components/DelegateModal/DelegateModal.tsx new file mode 100644 index 0000000000..695d1b8da3 --- /dev/null +++ b/src/views/Governance/components/DelegateModal/DelegateModal.tsx @@ -0,0 +1,66 @@ +import { Box, Typography } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { Input, Modal, OHMModalProps, PrimaryButton } from "@olympusdao/component-library"; +import { FC, useState } from "react"; +import { isValidAddress } from "src/helpers/misc/isValidAddress"; + +type DelegateModalProps = { + open: OHMModalProps["open"]; + handleClose: OHMModalProps["onClose"]; +}; + +/* +TODO: Need to add address where votes are currently delegated and display in the input area. +TODO: Need to implement the hook when PrimaryButton is clicked. Should delegate votes. +If votes aren't delegated anywhere this input should either be empty or it should populate the current address +*/ + +/** + * Component for Displaying DelegateModal + */ +const DelegateModal: FC = ({ open, handleClose }) => { + const theme = useTheme(); + const [delegatedAddress, setDelegatedAddress] = useState(""); + const [isDelegatedAddressValid, setIsDelegatedAddressValid] = useState(true); + + const handleSetDelegatedAddress = (value: string) => { + setDelegatedAddress(value); + + if (!isValidAddress(value)) { + setIsDelegatedAddressValid(false); + return; + } + setIsDelegatedAddressValid(true); + }; + + return ( + } + headerText="Delegate Vote" + open={open} + onClose={handleClose} + maxWidth="465px" + minHeight="278px" + > + <> + + Delegate all your voting power to this address. +
You can always re-delegate to yourself or someone else. +
+ handleSetDelegatedAddress(e.target.value)} + /> + + + Delegate Votes + + + +
+ ); +}; + +export default DelegateModal; diff --git a/src/views/Governance/components/DelegateModal/index.ts b/src/views/Governance/components/DelegateModal/index.ts new file mode 100644 index 0000000000..63a4c1e4ab --- /dev/null +++ b/src/views/Governance/components/DelegateModal/index.ts @@ -0,0 +1 @@ +export { default } from "./DelegateModal"; diff --git a/src/views/Governance/components/FilterModal/FilterModal.scss b/src/views/Governance/components/FilterModal/FilterModal.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/views/Governance/components/FilterModal/FilterModal.tsx b/src/views/Governance/components/FilterModal/FilterModal.tsx new file mode 100644 index 0000000000..824483d4af --- /dev/null +++ b/src/views/Governance/components/FilterModal/FilterModal.tsx @@ -0,0 +1,96 @@ +import { CheckBoxOutlineBlank, CheckBoxOutlined } from "@mui/icons-material"; +import { Box, Checkbox, FormControlLabel } from "@mui/material"; +import { Modal, PrimaryButton, SecondaryButton } from "@olympusdao/component-library"; +import { useState } from "react"; +import { CancelCallback } from "src/views/Governance/interfaces"; + +type FilterModalProps = { + isModalOpen: boolean; + cancelFunc: CancelCallback; +}; + +export const FilterModal = ({ isModalOpen, cancelFunc }: FilterModalProps) => { + const [checkedActive, setCheckedActive] = useState(false); + const [checkedEndorsements, setCheckedEndorsements] = useState(false); + const [checkedDiscussions, setCheckedDiscussions] = useState(false); + const [checkedDraft, setCheckedDraft] = useState(false); + const [checkedClosed, setCheckedClosed] = useState(false); + + return ( + + <> + + setCheckedActive(event.target.checked)} + icon={} + checkedIcon={} + /> + } + label={`Active`} + /> + + + setCheckedEndorsements(event.target.checked)} + icon={} + checkedIcon={} + /> + } + label={`Endorsements`} + /> + + + setCheckedDiscussions(event.target.checked)} + icon={} + checkedIcon={} + /> + } + label={`Discussions`} + /> + + + setCheckedDraft(event.target.checked)} + icon={} + checkedIcon={} + /> + } + label={`Draft`} + /> + + + setCheckedClosed(event.target.checked)} + icon={} + checkedIcon={} + /> + } + label={`Closed`} + /> + + + + Cancel + + Save + + + + ); +}; diff --git a/src/views/Governance/components/FilterModal/index.ts b/src/views/Governance/components/FilterModal/index.ts new file mode 100644 index 0000000000..65b7f68c0a --- /dev/null +++ b/src/views/Governance/components/FilterModal/index.ts @@ -0,0 +1 @@ +export { FilterModal } from "./FilterModal"; diff --git a/src/views/Governance/components/MarkdownPreview/MarkdownPreview.tsx b/src/views/Governance/components/MarkdownPreview/MarkdownPreview.tsx new file mode 100644 index 0000000000..55e02325b1 --- /dev/null +++ b/src/views/Governance/components/MarkdownPreview/MarkdownPreview.tsx @@ -0,0 +1,56 @@ +import { Box, Link, styled, useTheme } from "@mui/material"; +import { TertiaryButton } from "@olympusdao/component-library"; +import { ClassAttributes, ImgHTMLAttributes, useState } from "react"; +import ReactMarkdown from "react-markdown"; + +export const MarkdownPreview = (props: { content: string }) => { + const { content } = props; + const StyledBoxItem = styled(Box)(() => ({ + "p > img": { + maxWidth: "100%", + }, + p: { + fontSize: "16px", + }, + })); + + const allowedImgDomains = ["i.imgur.com"]; + const PreviewImage = ( + props: JSX.IntrinsicAttributes & ClassAttributes & ImgHTMLAttributes, + ) => { + const srcUrl = new URL(props.src ? props.src : ""); + + const whitelistedDomain = allowedImgDomains.find(domain => domain === srcUrl.hostname); + + const returnedComponent = whitelistedDomain ? : ; + return returnedComponent; + }; + + const Warning = (props: any) => { + const theme = useTheme(); + const [displayImage, setDisplayImage] = useState(false); + return displayImage ? ( + + ) : ( + + + Image is not hosted on an approved pinning domain. Image may have been updated after proposal submission. + + setDisplayImage(true)}>View Image Anyway + + + + ); + }; + return ( + + , + }} + children={content} + /> + + ); +}; diff --git a/src/views/Governance/components/MarkdownPreview/index.tsx b/src/views/Governance/components/MarkdownPreview/index.tsx new file mode 100644 index 0000000000..094a9e46f0 --- /dev/null +++ b/src/views/Governance/components/MarkdownPreview/index.tsx @@ -0,0 +1 @@ +export { MarkdownPreview } from "./MarkdownPreview"; diff --git a/src/views/Governance/components/Proposal.tsx b/src/views/Governance/components/Proposal.tsx new file mode 100644 index 0000000000..42a05f447b --- /dev/null +++ b/src/views/Governance/components/Proposal.tsx @@ -0,0 +1,232 @@ +import { ChevronRight } from "@mui/icons-material"; +import { Box, Typography } from "@mui/material"; +import { styled, useTheme } from "@mui/material/styles"; +import { Chip, Icon } from "@olympusdao/component-library"; +import { FC } from "react"; +import { PStatus } from "src/hooks/useProposals"; +import { mapProposalStatus } from "src/views/Governance/components/ProposalPage/ProposalPage"; + +interface OHMProposalProps { + /** + * Returns appropriate chip label and card styling depending on status passed + */ + status: PStatus; + /** + * Label for the Chip + */ + chipLabel?: string; + /** + * Voting End Date + */ + voteEndDate: Date; + /** + * Title of the Proposal; + */ + proposalTitle: string; + /** + * Date Proposa was Published + */ + publishedDate: Date; + /** + * Count of Votes For + */ + votesFor?: number; + /** + * Count of Votes Against + */ + votesAgainst?: number; + /** + * Count of Quorum needed + */ + quorum?: number; +} + +type BoxProps = { + status: OHMProposalProps["status"]; +}; + +const StyledBox = styled(Box, { + shouldForwardProp: prop => prop !== "status", +})(({ theme, status }) => { + return { + background: status === "active" ? theme.colors.paper.cardHover : theme.colors.paper.card, + borderRadius: "9px", + padding: "18px", + width: "100%", + }; +}); + +/** + * Component for Displaying A Single Governance Proposal Card + */ +export const Proposal: FC = ({ + status, + chipLabel, + voteEndDate, + proposalTitle, + publishedDate, + quorum = 0, + votesAgainst = 0, + votesFor = 0, +}) => { + const theme = useTheme(); + const dateFormat = new Intl.DateTimeFormat([], { + month: "short", + day: "numeric", + year: "numeric", + timeZoneName: "short", + hour: "numeric", + minute: "numeric", + }); + const formattedEndDate = dateFormat.format(voteEndDate); + const formattedPublishedDate = dateFormat.format(publishedDate); + + const currentDate = Date.now(); + const twelveHours = 43200000; + const timeLeft = Number(voteEndDate) - currentDate; + const insideTwelveHours = timeLeft > 0 && timeLeft <= twelveHours; + const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutesLeft = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)); + + //calculate votes + + const totalVoteCount = votesAgainst + votesFor; + + type formattedVoteLabelType = { + label: string; + color?: any; + }; + const FormattedVoteLabel = ({ label, color }: formattedVoteLabelType) => ( + + {label} + + ); + + const VoteThresholdBar = () => { + return ( + <> + {totalVoteCount > 0 && ( + + + + Threshold + + + | + + + + + + + {/* */} + + + + + + )} + + ); + }; + return ( + + + + + {proposalTitle} + + {chipLabel && } + + + <> + + {publishedDate && `Posted On ${formattedPublishedDate} `} + + + + {voteEndDate && ( + <> + + {insideTwelveHours ? `ends in ${hoursLeft} Hours ${minutesLeft} minutes` : formattedEndDate} + + + + )} + + + + + + + + + + + View Proposal + + + + + + ); +}; diff --git a/src/views/Governance/components/ProposalPage/ProposalPage.scss b/src/views/Governance/components/ProposalPage/ProposalPage.scss new file mode 100644 index 0000000000..8da2f70c3c --- /dev/null +++ b/src/views/Governance/components/ProposalPage/ProposalPage.scss @@ -0,0 +1,3 @@ +#simple-tabpanel-1 { + width: 100%; +} \ No newline at end of file diff --git a/src/views/Governance/components/ProposalPage/ProposalPage.tsx b/src/views/Governance/components/ProposalPage/ProposalPage.tsx new file mode 100644 index 0000000000..53bfb46cd4 --- /dev/null +++ b/src/views/Governance/components/ProposalPage/ProposalPage.tsx @@ -0,0 +1,177 @@ +import "src/views/Governance/components/ProposalPage/ProposalPage.scss"; + +import { Box, Grid, Link, Typography, useTheme } from "@mui/material"; +import { Chip, Icon, OHMChipProps, Paper, Tab, Tabs } from "@olympusdao/component-library"; +import { FC, useMemo } from "react"; +import { NavLink, Outlet, Route, Routes, useParams } from "react-router-dom"; +import { shorten } from "src/helpers"; +import { prettifySeconds } from "src/helpers/timeUtil"; +import { useProposal } from "src/hooks/useProposal"; +import { IAnyProposal, PStatus } from "src/hooks/useProposals"; +import ActionButtons from "src/views/Governance/components/ActionButtons"; +import { BackButton } from "src/views/Governance/components/BackButton"; +import { CastVote } from "src/views/Governance/components/ProposalPage/components/CastVote"; +import { PollDetailsTab } from "src/views/Governance/components/ProposalPage/components/PollDetailsTab"; +import { StatusBar } from "src/views/Governance/components/ProposalPage/components/StatusBar"; +import { VotesTab } from "src/views/Governance/components/ProposalPage/components/VotesTab"; +import { toCapitalCase } from "src/views/Governance/helpers"; + +export const mapProposalStatus = (status: PStatus) => { + switch (status) { + case "active": + return "success" as OHMChipProps["template"]; + case "executed": + return "purple" as OHMChipProps["template"]; + case "discussion": + case "ready to activate": + case "ready to execute": + return "userFeedback" as OHMChipProps["template"]; + case "closed": + case "expired activation": + case "expired execution": + return "gray" as OHMChipProps["template"]; + case "draft": + return "darkGray" as OHMChipProps["template"]; + } +}; + +export const proposalDateFormat = new Intl.DateTimeFormat([], { + month: "short", + day: "numeric", + year: "numeric", + timeZoneName: "short", + hour: "numeric", + minute: "numeric", +}); + +export const PageWrapper = ({ proposal }: { proposal: IAnyProposal }) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const TimeRemaining = ({ proposal }: { proposal: IAnyProposal }) => { + console.log("timeRemaining const"); + const theme = useTheme(); + console.log("after useTheme"); + let boundedTimeRemaining = 0; + // const earliest = 1668456876000 + // const deadline = 1668456996000 + // const now = 1668456906000; // must be activated within 1 minute + // const now = 1668456816000; // can be activated in 3 minutes + const now = Date.now(); + const timeRemainingDate = proposal.now.getTime() + proposal.timeRemaining; + if (proposal.timeRemaining && timeRemainingDate - now > 0) { + const timeRemainingDate = proposal.now.getTime() + proposal.timeRemaining; + boundedTimeRemaining = (timeRemainingDate - now) / 1000; + } + + console.log( + "timeremaining", + proposal.id, + "now", + now, + proposal.state, + proposal.nextDeadline, + proposal.timeRemaining, + boundedTimeRemaining, + ); + return ( + <> + + + {proposal.state === "expired activation" + ? `Activation Period Expired at ${proposalDateFormat.format(proposal.nextDeadline)}` + : proposal.state === "discussion" + ? `Can be activated in ${prettifySeconds(boundedTimeRemaining)}` + : proposal.state === "ready to activate" + ? `Must be activated within ${prettifySeconds(boundedTimeRemaining)}` + : proposal.state === "active" && boundedTimeRemaining == 0 + ? `Vote Finished at ${proposalDateFormat.format(proposal.nextDeadline)}` + : proposal.state === "active" + ? `Ends in ${prettifySeconds(boundedTimeRemaining)}` + : proposal.state === "closed" + ? `Expired at ${proposalDateFormat.format(proposal.nextDeadline)}` + : `Expired at ${proposalDateFormat.format(proposal.nextDeadline)}`} + + + ); +}; + +const ProposalHeader = (props: { proposal: IAnyProposal }) => { + const { proposal } = props; + const theme = useTheme(); + + const formattedPublishedDate = proposalDateFormat.format(proposal.submissionTimestamp); + + return ( + + + + Posted on {formattedPublishedDate} by:{" "} + {shorten(proposal.submitter)} + + + + + {proposal.title} + + + + + + {proposal && } + + + + ); +}; + +export const ProposalPage: FC = () => { + const { passedId } = useParams(); + const proposalId = useMemo(() => { + if (!passedId) return -1; + return parseInt(passedId); + }, [passedId]); + + const { data: proposal, isLoading } = useProposal(proposalId); + return ( + <> + {!isLoading && !!proposal && ( + + }> + } /> + } /> + + + )} + + ); +}; diff --git a/src/views/Governance/components/ProposalPage/components/ActivateVoting.tsx b/src/views/Governance/components/ProposalPage/components/ActivateVoting.tsx new file mode 100644 index 0000000000..5b632f6906 --- /dev/null +++ b/src/views/Governance/components/ProposalPage/components/ActivateVoting.tsx @@ -0,0 +1,34 @@ +import { Box } from "@mui/material"; +import { PrimaryButton } from "@olympusdao/component-library"; +import { useActivateProposal } from "src/hooks/useProposal"; +import { ProposalTabProps } from "src/views/Governance/interfaces"; +import { useAccount } from "wagmi"; + +export const ActivateVoting = ({ proposal }: ProposalTabProps) => { + const { address, isConnected } = useAccount(); + const connectedWalletIsProposer = + isConnected && !!address && proposal.submitter.toLowerCase() === address.toLowerCase(); + const proposalStateIsCorrect = proposal.state === "ready to activate" && Date.now() < proposal.nextDeadline; + const proposalStatesToShow = ["ready to activate", "discussion"].includes(proposal.state); + const activateProposal = useActivateProposal(); + const handleActivate = () => { + activateProposal.mutate(proposal.id); + }; + + return ( + <> + {connectedWalletIsProposer && proposalStatesToShow && ( + + + {activateProposal.isLoading ? "Activating..." : "Activate for Voting"} + + + )} + + ); +}; diff --git a/src/views/Governance/components/ProposalPage/components/CastVote.tsx b/src/views/Governance/components/ProposalPage/components/CastVote.tsx new file mode 100644 index 0000000000..2bafac7ad0 --- /dev/null +++ b/src/views/Governance/components/ProposalPage/components/CastVote.tsx @@ -0,0 +1,108 @@ +import { Box, capitalize, useTheme } from "@mui/material"; +import { Metric, PrimaryButton, TertiaryButton } from "@olympusdao/component-library"; +import { BigNumber } from "ethers"; +import { useState } from "react"; +import { WalletConnectedGuard } from "src/components/WalletConnectedGuard"; +import { formatBalance } from "src/helpers"; +import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; +import { useVoteBalance } from "src/hooks/useBalance"; +import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { useUserVote, useVote } from "src/hooks/useVoting"; +import { ActivateVoting } from "src/views/Governance/components/ProposalPage/components/ActivateVoting"; +import { UserVote } from "src/views/Governance/components/ProposalPage/components/UserVote"; +import { proposalDateFormat } from "src/views/Governance/components/ProposalPage/ProposalPage"; +import { ProposalTabProps } from "src/views/Governance/interfaces"; +import { useAccount } from "wagmi"; + +export const CastVote = ({ proposal }: ProposalTabProps) => { + const theme = useTheme(); + const { isConnected } = useAccount(); + const [vote, setVote] = useState(""); + const submitVote = useVote(); + const { address: voterAddress } = useAccount(); + const { data: voteValue, isLoading: isLoadingVoteValue } = useUserVote(proposal.id, voterAddress as string); + const networks = useTestableNetworks(); + const votesBalance = useVoteBalance()[networks.MAINNET].data; + + const handleVoteSubmission = () => { + submitVote.mutate({ voteData: { proposalId: BigNumber.from(proposal.id), vote: vote === "yes" } }); + }; + return ( + + + + {/* + Your Vote + */} + + + {voteValue && voteValue.amount.gt("0") ? ( + <> + + + + ) : proposal.state === "active" ? ( + <> + + + + ) : proposal.state === "closed" ? ( + <> + + + + ) : ( + <> + + + + )} + + <> + {voteValue && !voteValue.amount.gt("0") && ( + <> + + setVote("yes")} + > + Yes + + setVote("no")} + > + No + + + + + + {submitVote.isLoading ? `Voting ${capitalize(vote)}...` : `Vote ${capitalize(vote)}`} + + + + + )} + + {voterAddress && proposal.isActive && } + + {(proposal.state === "discussion" || proposal.state === "ready to activate") && ( +

This Proposal is not yet active for voting.

+ )} + {proposal.state === "expired activation" &&

This Proposal missed it's activation window.

} +
+ ); +}; diff --git a/src/views/Governance/components/ProposalPage/components/PollDetailsTab.tsx b/src/views/Governance/components/ProposalPage/components/PollDetailsTab.tsx new file mode 100644 index 0000000000..48ca085bef --- /dev/null +++ b/src/views/Governance/components/ProposalPage/components/PollDetailsTab.tsx @@ -0,0 +1,119 @@ +import { Box, Skeleton, styled, Typography, useTheme } from "@mui/material"; +import { Paper, TertiaryButton } from "@olympusdao/component-library"; +import { useGetInstructions } from "src/hooks/useProposal"; +import { ProposalAction, ProposalActionsReadable } from "src/hooks/useProposals"; +import { MarkdownPreview } from "src/views/Governance/components/MarkdownPreview"; +import { ActivateVoting } from "src/views/Governance/components/ProposalPage/components/ActivateVoting"; +import ReclaimVohmButton from "src/views/Governance/components/ReclaimVohmButton"; +import { ProposalTabProps } from "src/views/Governance/interfaces"; +import { useNetwork } from "wagmi"; + +export const GovHTypography = styled(Typography)({ + fontSize: "18px", + lineHeight: "28px", + fontWeight: 600, +}); + +export const PollDetailsTab = ({ proposal }: ProposalTabProps) => { + const theme = useTheme(); + const { data: instructions, isLoading } = useGetInstructions(proposal.id); + + const renderInstructions = () => { + if (instructions && instructions.length > 0) { + return instructions.map((instruction, index) => { + return ; + }); + } + return ; + }; + + return ( + + + + + Details + + + + + Join the Discussion + + + + + Current Timestamp  + {Date.now()} + + + Submission Timestamp  + {proposal.submissionTimestamp} + + + Activation Timestamp  + {proposal.activationTimestamp} + + + Activation Deadline  + {proposal.activationDeadline} + + + Activation Expiry  + {proposal.activationExpiry} + + + Voting Expiry  + {proposal.votingExpiry} + + + + + + Implementation Details + <>{isLoading ? : renderInstructions()} + + + + ); +}; + +export const InstructionsDetails = ({ action, target }: { action: ProposalAction; target: string }) => { + const { chain } = useNetwork(); + const etherscanURI = + chain?.id === 5 ? `https://goerli.etherscan.io/address/${target}` : `https://etherscan.io/address/${target}`; + const dethcodeURI = + chain?.id === 5 + ? `https://goerli.etherscan.deth.net/address/${target}` + : `https://etherscan.deth.net/address/${target}`; + + return ( + + {`${ProposalActionsReadable[action]}: ${target}`} + + + {`Inspect Code: `} + + + etherscan + + + dethcode + + + + ); +}; + +const InstructionSkeleton = () => { + return ( + + {1} + + {`instruction[1]`} + + + ); +}; diff --git a/src/views/Governance/components/ProposalPage/components/StatusBar.tsx b/src/views/Governance/components/ProposalPage/components/StatusBar.tsx new file mode 100644 index 0000000000..59832a8a83 --- /dev/null +++ b/src/views/Governance/components/ProposalPage/components/StatusBar.tsx @@ -0,0 +1,62 @@ +import { Box, LinearProgress, Typography, useTheme } from "@mui/material"; +import { ProposalTabProps } from "src/views/Governance/interfaces"; + +export const StatusBar = ({ proposal }: ProposalTabProps) => { + const theme = useTheme(); + const gold = theme.colors.primary[300]; + const gray = theme.colors.gray[40]; + + const ProgressBarAndLabel = ({ label, active }: { label: string; active: boolean }) => { + return ( + <> + + + {label} + + + ); + }; + + return ( + + + + + + + + {["closed", "expired activation", "expired execution"].includes(proposal.state) ? ( + + + + ) : ( + <> + + + + + + + + )} + + ); +}; diff --git a/src/views/Governance/components/ProposalPage/components/UserVote.tsx b/src/views/Governance/components/ProposalPage/components/UserVote.tsx new file mode 100644 index 0000000000..ddccef28ad --- /dev/null +++ b/src/views/Governance/components/ProposalPage/components/UserVote.tsx @@ -0,0 +1,25 @@ +import Skeleton from "@mui/material/Skeleton"; +import { Metric } from "@olympusdao/component-library"; +import { formatBalance } from "src/helpers"; +import { useUserVote } from "src/hooks/useVoting"; + +export const UserVote = ({ proposalId, voterAddress }: { proposalId: number; voterAddress: string }) => { + const { data: voteValue, isLoading: isLoadingVoteValue } = useUserVote(proposalId, voterAddress); + return ( + <> + {isLoadingVoteValue && ( + + + + )} + {voteValue && ( + <> + + + )} + + ); +}; diff --git a/src/views/Governance/components/ProposalPage/components/VotesTab.tsx b/src/views/Governance/components/ProposalPage/components/VotesTab.tsx new file mode 100644 index 0000000000..a6fd53d213 --- /dev/null +++ b/src/views/Governance/components/ProposalPage/components/VotesTab.tsx @@ -0,0 +1,98 @@ +import { + Box, + styled, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { Paper, VoteBreakdown } from "@olympusdao/component-library"; +import { utils } from "ethers"; +import { IAnyProposal, useActivationTimelines } from "src/hooks/useProposals"; +import { useGetVotesCastByVoter, useGetVotesCastForProposalBySize } from "src/hooks/useVoting"; +import { VotesCastEvent } from "src/typechain/OlympusGovernance"; + +/** + * parses proposal status & displays votes breakdown + */ +export const VotesTab = ({ proposal }: { proposal: IAnyProposal }) => { + // const { data: totalVoteSupply, isLoading: isLoadingTotalSupply } = useVotingSupply(); + const { data: votesCast } = useGetVotesCastForProposalBySize(proposal.id); + const { data: timelines } = useActivationTimelines(); + + const StyledTableCell = styled(TableCell)(() => ({ + padding: "0px", + fontSize: "12px", + lineHeight: "18px", + fontWeight: "400", + })); + + const VoteTableRow = ({ voteEvent }: { voteEvent: VotesCastEvent }) => { + const { data: votesByVoter } = useGetVotesCastByVoter(voteEvent.args.voter); + const userVotes = utils.formatEther(voteEvent.args.userVotes); + const percentVotingPower = Number(userVotes) / proposal.totalRegisteredVotes; + + return ( + + {voteEvent.args.voter} + + {votesByVoter.length} + + + {utils.commify(userVotes)} + + + {`${(percentVotingPower * 100).toFixed(2)} %`} + + + {voteEvent.args.approve ? "Yes" : "No"} + + + ); + }; + + return ( + + + + Vote Breakdown + + + + Top Voters + + {votesCast && votesCast.length > 0 ? ( + + + + + Voter + Proposals voted + Total Votes (vOHM) + Voting Power + Yes/No + + + {votesCast && votesCast.map(voteEvent => )} +
+
+ ) : ( + + Zero Votes have been cast. + + )} +
+ ); +}; diff --git a/src/views/Governance/components/ProposalPage/components/VotingPower.tsx b/src/views/Governance/components/ProposalPage/components/VotingPower.tsx new file mode 100644 index 0000000000..a0fa3bfa7d --- /dev/null +++ b/src/views/Governance/components/ProposalPage/components/VotingPower.tsx @@ -0,0 +1,62 @@ +import { Box, Grid, Typography, useTheme } from "@mui/material"; +import { Metric } from "@olympusdao/component-library"; +import { Paper } from "@olympusdao/component-library"; +import { formatBalance } from "src/helpers"; +import { useVoteBalance } from "src/hooks/useBalance"; +import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { useVotingSupply } from "src/hooks/useVoting"; +import { BackButton } from "src/views/Governance/components/BackButton"; +import { VohmArea } from "src/views/Governance/components/VohmArea/VohmArea"; +import { useAccount } from "wagmi"; + +export const VotingPower = () => { + return ( + <> + + + + + + + + + + + + + + + ); +}; + +export const VotingPowerMetrics = () => { + const theme = useTheme(); + const { isConnected } = useAccount(); + const networks = useTestableNetworks(); + const votesBalance = useVoteBalance()[networks.MAINNET].data; + + const { data: totalVoteSupply, isLoading: isLoadingTotalSupply } = useVotingSupply(); + + return ( + + + + Voting Power + + + + {isConnected && } + {totalVoteSupply && ( + + )} + + {/* + + + Get More Voting Power + + + */} + + ); +}; diff --git a/src/views/Governance/components/ProposalPage/index.ts b/src/views/Governance/components/ProposalPage/index.ts new file mode 100644 index 0000000000..3ddad00079 --- /dev/null +++ b/src/views/Governance/components/ProposalPage/index.ts @@ -0,0 +1 @@ +export { ProposalPage } from "./ProposalPage"; diff --git a/src/views/Governance/components/ReclaimVohmButton/ReclaimVohmButton.tsx b/src/views/Governance/components/ReclaimVohmButton/ReclaimVohmButton.tsx new file mode 100644 index 0000000000..013298a13c --- /dev/null +++ b/src/views/Governance/components/ReclaimVohmButton/ReclaimVohmButton.tsx @@ -0,0 +1,28 @@ +import { TertiaryButton } from "@olympusdao/component-library"; +import { useReClaimVohm } from "src/hooks/useProposal"; +import { proposalDateFormat } from "src/views/Governance/components/ProposalPage/ProposalPage"; +import { ProposalTabProps } from "src/views/Governance/interfaces"; +import { useAccount } from "wagmi"; + +/** + * Component for Displaying ReclaimVohm button + */ +const ReclaimVohmButton = ({ proposal }: ProposalTabProps) => { + const { address: connectedWallet } = useAccount(); + + const reclaimCollateral = useReClaimVohm(); + return ( + <> + {connectedWallet?.toLowerCase() === proposal.submitter.toLowerCase() && ( + reclaimCollateral.mutate(proposal.id)} + > + {`Claim Your gOHM on or after ${proposalDateFormat.format(proposal.collateralClaimableAt)}`} + + )} + + ); +}; + +export default ReclaimVohmButton; diff --git a/src/views/Governance/components/ReclaimVohmButton/index.ts b/src/views/Governance/components/ReclaimVohmButton/index.ts new file mode 100644 index 0000000000..3120d9ad18 --- /dev/null +++ b/src/views/Governance/components/ReclaimVohmButton/index.ts @@ -0,0 +1 @@ +export { default } from "./ReclaimVohmButton"; diff --git a/src/views/Governance/components/SearchBar/SearchBar.tsx b/src/views/Governance/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..13e185017a --- /dev/null +++ b/src/views/Governance/components/SearchBar/SearchBar.tsx @@ -0,0 +1,27 @@ +import SearchIcon from "@mui/icons-material/Search"; +import { Box, Input, InputAdornment, useTheme } from "@mui/material"; + +export const SearchBar = () => { + const theme = useTheme(); + return ( + + + + + } + placeholder="Search proposal" + /> + + ); +}; diff --git a/src/views/Governance/components/SearchBar/index.ts b/src/views/Governance/components/SearchBar/index.ts new file mode 100644 index 0000000000..5932700db5 --- /dev/null +++ b/src/views/Governance/components/SearchBar/index.ts @@ -0,0 +1 @@ +export { SearchBar } from "./SearchBar"; diff --git a/src/views/Governance/components/VohmArea/VohmArea.tsx b/src/views/Governance/components/VohmArea/VohmArea.tsx new file mode 100644 index 0000000000..dd24cd5b7a --- /dev/null +++ b/src/views/Governance/components/VohmArea/VohmArea.tsx @@ -0,0 +1,29 @@ +import { Box } from "@mui/material"; +import { useState } from "react"; +import { VohmInputArea } from "src/views/Governance/components/VohmArea/VohmInputArea/VohmInputArea"; +import { useAccount } from "wagmi"; + +export const VohmArea: React.FC = () => { + const [isZoomed, setIsZoomed] = useState(false); + const { isConnected } = useAccount(); + + return ( + <> + {/* */} + + + {isConnected && ( + + + {/* + + + + */} + + + )} + + + ); +}; diff --git a/src/views/Governance/components/VohmArea/VohmInputArea/VohmInputArea.tsx b/src/views/Governance/components/VohmArea/VohmInputArea/VohmInputArea.tsx new file mode 100644 index 0000000000..e03adb2d89 --- /dev/null +++ b/src/views/Governance/components/VohmArea/VohmInputArea/VohmInputArea.tsx @@ -0,0 +1,218 @@ +import { Box, Tab, Tabs } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { OHMSwapCardProps, PrimaryButton, SwapCard, SwapCollection } from "@olympusdao/component-library"; +import React, { useEffect, useState } from "react"; +import { TokenAllowanceGuard } from "src/components/TokenAllowanceGuard/TokenAllowanceGuard"; +import { WalletConnectedGuard } from "src/components/WalletConnectedGuard"; +import { + GOVERNANCE_GOHM_ADDRESSES, + GOVERNANCE_VOHM_VAULT_ADDRESSES, + VOTE_TOKEN_ADDRESSES, +} from "src/constants/addresses"; +import { DecimalBigNumber } from "src/helpers/DecimalBigNumber/DecimalBigNumber"; +import { useBalance, useVoteBalance } from "src/hooks/useBalance"; +import { useTestableNetworks } from "src/hooks/useTestableNetworks"; +import { useUnwrapFromVohm, useWrapToVohm } from "src/hooks/useVoting"; +import { ModalHandleSelectProps } from "src/views/Stake/components/StakeArea/components/StakeInputArea/components/TokenModal"; +import { useNetwork } from "wagmi"; + +enum EvOHM { + Wrap = "WRAP", + Unwrap = "UNWRAP", +} +const PREFIX = "StakeInputArea"; + +const classes = { + inputRow: `${PREFIX}-inputRow`, + gridItem: `${PREFIX}-gridItem`, + input: `${PREFIX}-input`, + button: `${PREFIX}-button`, +}; + +const StyledBox = styled(Box)(({ theme }) => ({ + [`& .${classes.inputRow}`]: { + justifyContent: "space-around", + alignItems: "center", + height: "auto", + marginTop: "4px", + }, + + [`& .${classes.gridItem}`]: { + width: "100%", + paddingRight: "5px", + alignItems: "center", + justifyContent: "center", + }, + + [`& .${classes.input}`]: { + [theme.breakpoints.down("md")]: { + marginBottom: "10px", + }, + [theme.breakpoints.up("sm")]: { + marginBottom: "0", + }, + }, + + [`& .${classes.button}`]: { + width: "100%", + minWidth: "163px", + maxWidth: "542px", + }, +})); + +export const VohmInputArea: React.FC<{ isZoomed: boolean }> = props => { + const networks = useTestableNetworks(); + const [stakedAssetType, setStakedAssetType] = useState({ name: "vOHM" }); + const [swapAssetType, setSwapAssetType] = useState({ name: "gOHM" }); + const { chain = { id: 1 } } = useNetwork(); + + const [currentAction, setCurrentAction] = useState(EvOHM.Wrap); + + const fromToken = currentAction === EvOHM.Wrap ? swapAssetType.name : stakedAssetType.name; + + // Max balance stuff + const [amount, setAmount] = useState(""); + const addresses = fromToken === "gOHM" ? GOVERNANCE_GOHM_ADDRESSES : VOTE_TOKEN_ADDRESSES; + + const balance = useBalance(addresses)[networks.MAINNET].data; + const gOhmBalance = useBalance(GOVERNANCE_GOHM_ADDRESSES)[networks.MAINNET].data; + const vOhmBalance = useVoteBalance()[networks.MAINNET].data; + + const contractRouting = swapAssetType.name === "gOHM" ? "Wrap" : "Unwrap"; + + // Staking/unstaking mutation stuff + // TODO + const wrapMutation = useWrapToVohm(); + const unwrapMutation = useUnwrapFromVohm(); + const isMutating = (currentAction === EvOHM.Wrap ? wrapMutation : unwrapMutation).isLoading; + + const amountExceedsBalance = balance && new DecimalBigNumber(amount, 18).gt(balance) ? true : false; + + useEffect(() => { + setAmount("0"); + //If we're unstaking we reset swap asset back to OHM. this is all you can receive when unstaking. + if (currentAction === EvOHM.Unwrap) { + setSwapAssetType({ name: "gOHM" }); + } + if (currentAction === EvOHM.Wrap) { + setStakedAssetType({ name: "vOHM" }); + } + }, [currentAction]); + + const GohmSwapCard = () => { + const balance = + swapAssetType.name === "gOHM" + ? gOhmBalance + ? gOhmBalance.toString({ decimals: 2 }) + : "0.00" + : swapAssetType.name === "vOHM" + ? vOhmBalance + ? vOhmBalance.toString({ decimals: 2 }) + : "0.00" + : swapAssetType.balance; + + return ( + +event.target.value >= 0 && setAmount(event.target.value)} + info={`Balance: ${balance} ${swapAssetType.name}`} + endString={currentAction === EvOHM.Wrap ? "Max" : ""} + endStringOnClick={() => balance && setAmount(balance)} + disabled={isMutating} + inputWidth={`${amount.length > 0 ? amount.length : 1}ch`} + /> + ); + }; + + const VohmSwapCard = () => { + const balance = stakedAssetType.name === "gOHM" ? gOhmBalance : vOhmBalance; + + return ( + +event.target.value >= 0 && setAmount(event.target.value)} + info={`Balance: ${balance ? balance.toString({ decimals: 2 }) : "0.00"} ${stakedAssetType.name}`} + endString={currentAction === EvOHM.Unwrap ? "Max" : ""} + endStringOnClick={() => balance && setAmount(balance.toString())} + inputWidth={`${amount.length > 0 ? amount.length : 1}ch`} + disabled={isMutating} + /> + ); + }; + + return ( + + is loading + TabIndicatorProps={!props.isZoomed ? { style: { display: "none" } } : undefined} + onChange={(_, view: number) => setCurrentAction(view === 0 ? EvOHM.Wrap : EvOHM.Unwrap)} + > + + + + + + + + + setCurrentAction(currentAction === EvOHM.Wrap ? EvOHM.Unwrap : EvOHM.Wrap)} + /> + + + + + {contractRouting === "Wrap" && ( + + currentAction === EvOHM.Wrap ? wrapMutation.mutate(amount) : unwrapMutation.mutate(amount) + } + > + {amountExceedsBalance + ? "Amount exceeds balance" + : !amount || parseFloat(amount) === 0 + ? "Enter an amount" + : currentAction === EvOHM.Wrap + ? isMutating + ? "Confirming Wrapping in your wallet" + : "Wrap to vOHM" + : isMutating + ? "Confirming Unwrapping in your wallet " + : "Unwrap to gOHM"} + + )} + + + + + + + ); +}; diff --git a/src/views/Governance/helpers/index.ts b/src/views/Governance/helpers/index.ts new file mode 100644 index 0000000000..3c95f95ff6 --- /dev/null +++ b/src/views/Governance/helpers/index.ts @@ -0,0 +1,3 @@ +export const toCapitalCase = (value: string): string => { + return value.charAt(0).toUpperCase() + value.slice(1); +}; diff --git a/src/views/Governance/interfaces.ts b/src/views/Governance/interfaces.ts new file mode 100644 index 0000000000..9ddc9a5947 --- /dev/null +++ b/src/views/Governance/interfaces.ts @@ -0,0 +1,9 @@ +import { IAnyProposal } from "src/hooks/useProposals"; + +export type ProposalTabProps = { + proposal: IAnyProposal; +}; + +export interface CancelCallback { + (): void; +} diff --git a/src/views/Stake/__tests__/__snapshots__/StakeMobile.unit.test.jsx.snap b/src/views/Stake/__tests__/__snapshots__/StakeMobile.unit.test.jsx.snap index b12de5f83b..bad034e29f 100644 --- a/src/views/Stake/__tests__/__snapshots__/StakeMobile.unit.test.jsx.snap +++ b/src/views/Stake/__tests__/__snapshots__/StakeMobile.unit.test.jsx.snap @@ -166,7 +166,7 @@ exports[`Mobile Resolution > should render all supported multi chain staking con