diff --git a/contracts/README.md b/contracts/README.md index 07fcf5c46..6677fc429 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -20,6 +20,7 @@ Refresh the list of deployed contracts by running `./scripts/generateDeployments - [PolicyRegistry: proxy](https://arbiscan.io/address/0x26c1980120F1C82cF611D666CE81D2b54d018547), [implementation](https://arbiscan.io/address/0x2AC2EdFD336732bc6963f1AD03ED98B22dB949da) - [RandomizerRNG: proxy](https://arbiscan.io/address/0xC3dB344755b15c8Edfd834db79af4f8860029FB4), [implementation](https://arbiscan.io/address/0xA995C172d286f8F4eE137CC662e2844E59Cf4836) - [SortitionModuleNeo: proxy](https://arbiscan.io/address/0x614498118850184c62f82d08261109334bFB050f), [implementation](https://arbiscan.io/address/0xf327200420F21BAafce8F1C03B1EEdF926074B95) +- [TransactionBatcher](https://arbiscan.io/address/0xBC5ef8d9ad307154447AE148c088f083d2dEa4eF) ### Official Testnet @@ -88,6 +89,7 @@ Refresh the list of deployed contracts by running `./scripts/generateDeployments - [SortitionModule: proxy](https://sepolia.arbiscan.io/address/0x19cb28BAB40C3585955798f5EEabd71Eec14471C), [implementation](https://sepolia.arbiscan.io/address/0xBC82B29e5aE8a749D82b7919118Ab7C0D41fA3D3) - [SortitionModuleNeo: proxy](https://sepolia.arbiscan.io/address/0x809533c303c10915BB5c0585f2d8D738e2a4fB64), [implementation](https://sepolia.arbiscan.io/address/0xD9ddceb7C399518F23b69D155a67C6AFF13f9fF0) - [SortitionModuleUniversity: proxy](https://sepolia.arbiscan.io/address/0xBEEb15EF1DEf96c569c97A703E649B0251ceFB04), [implementation](https://sepolia.arbiscan.io/address/0xaA2833b174D4e29ae2aFc0b11dF9160EDB28BF9d) +- [TransactionBatcher](https://sepolia.arbiscan.io/address/0x35f93986950804ac1F93519BF68C2a7Dd776db0E) - [WETH](https://sepolia.arbiscan.io/address/0x3829A2486d53ee984a0ca2D76552715726b77138) - [WETHFaucet](https://sepolia.arbiscan.io/address/0x6F8C10E0030aDf5B8030a5E282F026ADdB6525fd) diff --git a/contracts/deploy/00-transaction-batcher.ts b/contracts/deploy/00-transaction-batcher.ts new file mode 100644 index 000000000..16410ed83 --- /dev/null +++ b/contracts/deploy/00-transaction-batcher.ts @@ -0,0 +1,26 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; +import { HomeChains, isSkipped } from "./utils"; + +const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { deployments, getNamedAccounts, getChainId } = hre; + const { deploy } = deployments; + + // fallback to hardhat node signers on local network + const deployer = (await getNamedAccounts()).deployer ?? (await hre.ethers.getSigners())[0].address; + const chainId = Number(await getChainId()); + console.log("deploying to %s with deployer %s", HomeChains[chainId], deployer); + + await deploy("TransactionBatcher", { + from: deployer, + args: [], + log: true, + }); +}; + +deployArbitration.tags = ["TransactionBatcher"]; +deployArbitration.skip = async ({ network }) => { + return isSkipped(network, !HomeChains[network.config.chainId ?? 0]); +}; + +export default deployArbitration; diff --git a/contracts/deployments/arbitrum/TransactionBatcher.json b/contracts/deployments/arbitrum/TransactionBatcher.json new file mode 100644 index 000000000..343ec6344 --- /dev/null +++ b/contracts/deployments/arbitrum/TransactionBatcher.json @@ -0,0 +1,87 @@ +{ + "address": "0xBC5ef8d9ad307154447AE148c088f083d2dEa4eF", + "abi": [ + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "datas", + "type": "bytes[]" + } + ], + "name": "batchSend", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "datas", + "type": "bytes[]" + } + ], + "name": "batchSendUnchecked", + "outputs": [], + "stateMutability": "payable", + "type": "function" + } + ], + "transactionHash": "0x50a763cdca3efd37d6f33e98d7ea345ef4f6ad821949aba64ca8ce2a06c8b06a", + "receipt": { + "to": null, + "from": "0xf1C7c037891525E360C59f708739Ac09A7670c59", + "contractAddress": "0xBC5ef8d9ad307154447AE148c088f083d2dEa4eF", + "transactionIndex": 4, + "gasUsed": "458947", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "blockHash": "0x1bfe2ba4e8defa670819e72dbe2fbac4d9b7ea05cfbbdafe0335df02d9410842", + "transactionHash": "0x50a763cdca3efd37d6f33e98d7ea345ef4f6ad821949aba64ca8ce2a06c8b06a", + "logs": [], + "blockNumber": 235536061, + "cumulativeGasUsed": "1233814", + "status": 1, + "byzantium": true + }, + "args": [], + "numDeployments": 1, + "solcInputHash": "036e2ca71d8ebdd78fd6317e15ba1f3c", + "metadata": "{\"compiler\":{\"version\":\"0.8.24+commit.e11b9ed9\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"address[]\",\"name\":\"targets\",\"type\":\"address[]\"},{\"internalType\":\"uint256[]\",\"name\":\"values\",\"type\":\"uint256[]\"},{\"internalType\":\"bytes[]\",\"name\":\"datas\",\"type\":\"bytes[]\"}],\"name\":\"batchSend\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address[]\",\"name\":\"targets\",\"type\":\"address[]\"},{\"internalType\":\"uint256[]\",\"name\":\"values\",\"type\":\"uint256[]\"},{\"internalType\":\"bytes[]\",\"name\":\"datas\",\"type\":\"bytes[]\"}],\"name\":\"batchSendUnchecked\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"}],\"devdoc\":{\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"src/utils/TransactionBatcher.sol\":\"TransactionBatcher\"},\"evmVersion\":\"shanghai\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\",\"useLiteralContent\":true},\"optimizer\":{\"enabled\":true,\"runs\":100},\"remappings\":[]},\"sources\":{\"src/utils/TransactionBatcher.sol\":{\"content\":\"// SPDX-License-Identifier: MIT\\npragma solidity ^0.8.0;\\n\\n// Adapted from https://github.com/daostack/web3-transaction-batcher/blob/1b88d2ea062f8f2d9fdfdf9bbe85d2bbef780151/contracts/Batcher.sol\\n\\ncontract TransactionBatcher {\\n\\n function batchSend(address[] memory targets, uint256[] memory values, bytes[] memory datas) public payable {\\n for (uint256 i = 0; i < targets.length; i++) {\\n (bool success,) = targets[i].call{value: values[i]}(datas[i]);\\n if (!success) revert('transaction failed'); // All the calls must succeed.\\n }\\n }\\n\\n function batchSendUnchecked(address[] memory targets, uint256[] memory values, bytes[] memory datas) public payable {\\n for (uint256 i = 0; i < targets.length; i++) {\\n targets[i].call{value: values[i]}(datas[i]); // Intentionally ignoring return value.\\n }\\n }\\n}\\n\",\"keccak256\":\"0x1983237012c29ef487ca47d60b197eb30d5b072ffec3078685d43fcc5fcc10a0\",\"license\":\"MIT\"}},\"version\":1}", + "bytecode": "0x608060405234801561000f575f80fd5b506105238061001d5f395ff3fe608060405260043610610028575f3560e01c8063a8f0802e1461002c578063cef591aa14610041575b5f80fd5b61003f61003a3660046103c4565b610054565b005b61003f61004f3660046103c4565b61015c565b5f5b8351811015610156575f848281518110610072576100726104ad565b60200260200101516001600160a01b0316848381518110610095576100956104ad565b60200260200101518484815181106100af576100af6104ad565b60200260200101516040516100c491906104c1565b5f6040518083038185875af1925050503d805f81146100fe576040519150601f19603f3d011682016040523d82523d5f602084013e610103565b606091505b505090508061014d5760405162461bcd60e51b81526020600482015260126024820152711d1c985b9cd858dd1a5bdb8819985a5b195960721b604482015260640160405180910390fd5b50600101610056565b50505050565b5f5b835181101561015657838181518110610179576101796104ad565b60200260200101516001600160a01b031683828151811061019c5761019c6104ad565b60200260200101518383815181106101b6576101b66104ad565b60200260200101516040516101cb91906104c1565b5f6040518083038185875af1925050503d805f8114610205576040519150601f19603f3d011682016040523d82523d5f602084013e61020a565b606091505b50505060010161015e565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff8111828210171561025257610252610215565b604052919050565b5f67ffffffffffffffff82111561027357610273610215565b5060051b60200190565b5f82601f83011261028c575f80fd5b813560206102a161029c8361025a565b610229565b8083825260208201915060208460051b8701019350868411156102c2575f80fd5b602086015b848110156102de57803583529183019183016102c7565b509695505050505050565b5f601f83601f8401126102fa575f80fd5b8235602061030a61029c8361025a565b82815260059290921b85018101918181019087841115610328575f80fd5b8287015b848110156103b857803567ffffffffffffffff8082111561034b575f80fd5b818a0191508a603f83011261035e575f80fd5b8582013560408282111561037457610374610215565b610385828b01601f19168901610229565b92508183528c8183860101111561039a575f80fd5b8181850189850137505f90820187015284525091830191830161032c565b50979650505050505050565b5f805f606084860312156103d6575f80fd5b833567ffffffffffffffff808211156103ed575f80fd5b818601915086601f830112610400575f80fd5b8135602061041061029c8361025a565b82815260059290921b8401810191818101908a84111561042e575f80fd5b948201945b838610156104605785356001600160a01b0381168114610451575f80fd5b82529482019490820190610433565b97505087013592505080821115610475575f80fd5b6104818783880161027d565b93506040860135915080821115610496575f80fd5b506104a3868287016102e9565b9150509250925092565b634e487b7160e01b5f52603260045260245ffd5b5f82515f5b818110156104e057602081860181015185830152016104c6565b505f92019182525091905056fea264697066735822122089797200888ad757484ded7bc0ffbb0769e9e974d73188d61385b7f6675e9f2064736f6c63430008180033", + "deployedBytecode": "0x608060405260043610610028575f3560e01c8063a8f0802e1461002c578063cef591aa14610041575b5f80fd5b61003f61003a3660046103c4565b610054565b005b61003f61004f3660046103c4565b61015c565b5f5b8351811015610156575f848281518110610072576100726104ad565b60200260200101516001600160a01b0316848381518110610095576100956104ad565b60200260200101518484815181106100af576100af6104ad565b60200260200101516040516100c491906104c1565b5f6040518083038185875af1925050503d805f81146100fe576040519150601f19603f3d011682016040523d82523d5f602084013e610103565b606091505b505090508061014d5760405162461bcd60e51b81526020600482015260126024820152711d1c985b9cd858dd1a5bdb8819985a5b195960721b604482015260640160405180910390fd5b50600101610056565b50505050565b5f5b835181101561015657838181518110610179576101796104ad565b60200260200101516001600160a01b031683828151811061019c5761019c6104ad565b60200260200101518383815181106101b6576101b66104ad565b60200260200101516040516101cb91906104c1565b5f6040518083038185875af1925050503d805f8114610205576040519150601f19603f3d011682016040523d82523d5f602084013e61020a565b606091505b50505060010161015e565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff8111828210171561025257610252610215565b604052919050565b5f67ffffffffffffffff82111561027357610273610215565b5060051b60200190565b5f82601f83011261028c575f80fd5b813560206102a161029c8361025a565b610229565b8083825260208201915060208460051b8701019350868411156102c2575f80fd5b602086015b848110156102de57803583529183019183016102c7565b509695505050505050565b5f601f83601f8401126102fa575f80fd5b8235602061030a61029c8361025a565b82815260059290921b85018101918181019087841115610328575f80fd5b8287015b848110156103b857803567ffffffffffffffff8082111561034b575f80fd5b818a0191508a603f83011261035e575f80fd5b8582013560408282111561037457610374610215565b610385828b01601f19168901610229565b92508183528c8183860101111561039a575f80fd5b8181850189850137505f90820187015284525091830191830161032c565b50979650505050505050565b5f805f606084860312156103d6575f80fd5b833567ffffffffffffffff808211156103ed575f80fd5b818601915086601f830112610400575f80fd5b8135602061041061029c8361025a565b82815260059290921b8401810191818101908a84111561042e575f80fd5b948201945b838610156104605785356001600160a01b0381168114610451575f80fd5b82529482019490820190610433565b97505087013592505080821115610475575f80fd5b6104818783880161027d565b93506040860135915080821115610496575f80fd5b506104a3868287016102e9565b9150509250925092565b634e487b7160e01b5f52603260045260245ffd5b5f82515f5b818110156104e057602081860181015185830152016104c6565b505f92019182525091905056fea264697066735822122089797200888ad757484ded7bc0ffbb0769e9e974d73188d61385b7f6675e9f2064736f6c63430008180033", + "devdoc": { + "kind": "dev", + "methods": {}, + "version": 1 + }, + "userdoc": { + "kind": "user", + "methods": {}, + "version": 1 + }, + "storageLayout": { + "storage": [], + "types": null + } +} diff --git a/contracts/deployments/arbitrumSepoliaDevnet/TransactionBatcher.json b/contracts/deployments/arbitrumSepoliaDevnet/TransactionBatcher.json new file mode 100644 index 000000000..6372364e0 --- /dev/null +++ b/contracts/deployments/arbitrumSepoliaDevnet/TransactionBatcher.json @@ -0,0 +1,87 @@ +{ + "address": "0x35f93986950804ac1F93519BF68C2a7Dd776db0E", + "abi": [ + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "datas", + "type": "bytes[]" + } + ], + "name": "batchSend", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "targets", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "values", + "type": "uint256[]" + }, + { + "internalType": "bytes[]", + "name": "datas", + "type": "bytes[]" + } + ], + "name": "batchSendUnchecked", + "outputs": [], + "stateMutability": "payable", + "type": "function" + } + ], + "transactionHash": "0x3c4a6f233fda3dc940b9aba1e04ee5993b515e4834b73365c4cd613718db46b2", + "receipt": { + "to": null, + "from": "0xf1C7c037891525E360C59f708739Ac09A7670c59", + "contractAddress": "0x35f93986950804ac1F93519BF68C2a7Dd776db0E", + "transactionIndex": 2, + "gasUsed": "3685628", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "blockHash": "0xe9853220bdd1f19aee5024da03ad2d91d98090a74ab599bdcf2af279f02d9744", + "transactionHash": "0x3c4a6f233fda3dc940b9aba1e04ee5993b515e4834b73365c4cd613718db46b2", + "logs": [], + "blockNumber": 66236728, + "cumulativeGasUsed": "5055051", + "status": 1, + "byzantium": true + }, + "args": [], + "numDeployments": 1, + "solcInputHash": "036e2ca71d8ebdd78fd6317e15ba1f3c", + "metadata": "{\"compiler\":{\"version\":\"0.8.24+commit.e11b9ed9\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"address[]\",\"name\":\"targets\",\"type\":\"address[]\"},{\"internalType\":\"uint256[]\",\"name\":\"values\",\"type\":\"uint256[]\"},{\"internalType\":\"bytes[]\",\"name\":\"datas\",\"type\":\"bytes[]\"}],\"name\":\"batchSend\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address[]\",\"name\":\"targets\",\"type\":\"address[]\"},{\"internalType\":\"uint256[]\",\"name\":\"values\",\"type\":\"uint256[]\"},{\"internalType\":\"bytes[]\",\"name\":\"datas\",\"type\":\"bytes[]\"}],\"name\":\"batchSendUnchecked\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"}],\"devdoc\":{\"kind\":\"dev\",\"methods\":{},\"version\":1},\"userdoc\":{\"kind\":\"user\",\"methods\":{},\"version\":1}},\"settings\":{\"compilationTarget\":{\"src/utils/TransactionBatcher.sol\":\"TransactionBatcher\"},\"evmVersion\":\"shanghai\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\",\"useLiteralContent\":true},\"optimizer\":{\"enabled\":true,\"runs\":100},\"remappings\":[]},\"sources\":{\"src/utils/TransactionBatcher.sol\":{\"content\":\"// SPDX-License-Identifier: MIT\\npragma solidity ^0.8.0;\\n\\n// Adapted from https://github.com/daostack/web3-transaction-batcher/blob/1b88d2ea062f8f2d9fdfdf9bbe85d2bbef780151/contracts/Batcher.sol\\n\\ncontract TransactionBatcher {\\n\\n function batchSend(address[] memory targets, uint256[] memory values, bytes[] memory datas) public payable {\\n for (uint256 i = 0; i < targets.length; i++) {\\n (bool success,) = targets[i].call{value: values[i]}(datas[i]);\\n if (!success) revert('transaction failed'); // All the calls must succeed.\\n }\\n }\\n\\n function batchSendUnchecked(address[] memory targets, uint256[] memory values, bytes[] memory datas) public payable {\\n for (uint256 i = 0; i < targets.length; i++) {\\n targets[i].call{value: values[i]}(datas[i]); // Intentionally ignoring return value.\\n }\\n }\\n}\\n\",\"keccak256\":\"0x1983237012c29ef487ca47d60b197eb30d5b072ffec3078685d43fcc5fcc10a0\",\"license\":\"MIT\"}},\"version\":1}", + "bytecode": "0x608060405234801561000f575f80fd5b506105238061001d5f395ff3fe608060405260043610610028575f3560e01c8063a8f0802e1461002c578063cef591aa14610041575b5f80fd5b61003f61003a3660046103c4565b610054565b005b61003f61004f3660046103c4565b61015c565b5f5b8351811015610156575f848281518110610072576100726104ad565b60200260200101516001600160a01b0316848381518110610095576100956104ad565b60200260200101518484815181106100af576100af6104ad565b60200260200101516040516100c491906104c1565b5f6040518083038185875af1925050503d805f81146100fe576040519150601f19603f3d011682016040523d82523d5f602084013e610103565b606091505b505090508061014d5760405162461bcd60e51b81526020600482015260126024820152711d1c985b9cd858dd1a5bdb8819985a5b195960721b604482015260640160405180910390fd5b50600101610056565b50505050565b5f5b835181101561015657838181518110610179576101796104ad565b60200260200101516001600160a01b031683828151811061019c5761019c6104ad565b60200260200101518383815181106101b6576101b66104ad565b60200260200101516040516101cb91906104c1565b5f6040518083038185875af1925050503d805f8114610205576040519150601f19603f3d011682016040523d82523d5f602084013e61020a565b606091505b50505060010161015e565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff8111828210171561025257610252610215565b604052919050565b5f67ffffffffffffffff82111561027357610273610215565b5060051b60200190565b5f82601f83011261028c575f80fd5b813560206102a161029c8361025a565b610229565b8083825260208201915060208460051b8701019350868411156102c2575f80fd5b602086015b848110156102de57803583529183019183016102c7565b509695505050505050565b5f601f83601f8401126102fa575f80fd5b8235602061030a61029c8361025a565b82815260059290921b85018101918181019087841115610328575f80fd5b8287015b848110156103b857803567ffffffffffffffff8082111561034b575f80fd5b818a0191508a603f83011261035e575f80fd5b8582013560408282111561037457610374610215565b610385828b01601f19168901610229565b92508183528c8183860101111561039a575f80fd5b8181850189850137505f90820187015284525091830191830161032c565b50979650505050505050565b5f805f606084860312156103d6575f80fd5b833567ffffffffffffffff808211156103ed575f80fd5b818601915086601f830112610400575f80fd5b8135602061041061029c8361025a565b82815260059290921b8401810191818101908a84111561042e575f80fd5b948201945b838610156104605785356001600160a01b0381168114610451575f80fd5b82529482019490820190610433565b97505087013592505080821115610475575f80fd5b6104818783880161027d565b93506040860135915080821115610496575f80fd5b506104a3868287016102e9565b9150509250925092565b634e487b7160e01b5f52603260045260245ffd5b5f82515f5b818110156104e057602081860181015185830152016104c6565b505f92019182525091905056fea264697066735822122089797200888ad757484ded7bc0ffbb0769e9e974d73188d61385b7f6675e9f2064736f6c63430008180033", + "deployedBytecode": "0x608060405260043610610028575f3560e01c8063a8f0802e1461002c578063cef591aa14610041575b5f80fd5b61003f61003a3660046103c4565b610054565b005b61003f61004f3660046103c4565b61015c565b5f5b8351811015610156575f848281518110610072576100726104ad565b60200260200101516001600160a01b0316848381518110610095576100956104ad565b60200260200101518484815181106100af576100af6104ad565b60200260200101516040516100c491906104c1565b5f6040518083038185875af1925050503d805f81146100fe576040519150601f19603f3d011682016040523d82523d5f602084013e610103565b606091505b505090508061014d5760405162461bcd60e51b81526020600482015260126024820152711d1c985b9cd858dd1a5bdb8819985a5b195960721b604482015260640160405180910390fd5b50600101610056565b50505050565b5f5b835181101561015657838181518110610179576101796104ad565b60200260200101516001600160a01b031683828151811061019c5761019c6104ad565b60200260200101518383815181106101b6576101b66104ad565b60200260200101516040516101cb91906104c1565b5f6040518083038185875af1925050503d805f8114610205576040519150601f19603f3d011682016040523d82523d5f602084013e61020a565b606091505b50505060010161015e565b634e487b7160e01b5f52604160045260245ffd5b604051601f8201601f1916810167ffffffffffffffff8111828210171561025257610252610215565b604052919050565b5f67ffffffffffffffff82111561027357610273610215565b5060051b60200190565b5f82601f83011261028c575f80fd5b813560206102a161029c8361025a565b610229565b8083825260208201915060208460051b8701019350868411156102c2575f80fd5b602086015b848110156102de57803583529183019183016102c7565b509695505050505050565b5f601f83601f8401126102fa575f80fd5b8235602061030a61029c8361025a565b82815260059290921b85018101918181019087841115610328575f80fd5b8287015b848110156103b857803567ffffffffffffffff8082111561034b575f80fd5b818a0191508a603f83011261035e575f80fd5b8582013560408282111561037457610374610215565b610385828b01601f19168901610229565b92508183528c8183860101111561039a575f80fd5b8181850189850137505f90820187015284525091830191830161032c565b50979650505050505050565b5f805f606084860312156103d6575f80fd5b833567ffffffffffffffff808211156103ed575f80fd5b818601915086601f830112610400575f80fd5b8135602061041061029c8361025a565b82815260059290921b8401810191818101908a84111561042e575f80fd5b948201945b838610156104605785356001600160a01b0381168114610451575f80fd5b82529482019490820190610433565b97505087013592505080821115610475575f80fd5b6104818783880161027d565b93506040860135915080821115610496575f80fd5b506104a3868287016102e9565b9150509250925092565b634e487b7160e01b5f52603260045260245ffd5b5f82515f5b818110156104e057602081860181015185830152016104c6565b505f92019182525091905056fea264697066735822122089797200888ad757484ded7bc0ffbb0769e9e974d73188d61385b7f6675e9f2064736f6c63430008180033", + "devdoc": { + "kind": "dev", + "methods": {}, + "version": 1 + }, + "userdoc": { + "kind": "user", + "methods": {}, + "version": 1 + }, + "storageLayout": { + "storage": [], + "types": null + } +} diff --git a/contracts/src/utils/TransactionBatcher.sol b/contracts/src/utils/TransactionBatcher.sol new file mode 100644 index 000000000..1bbff78a2 --- /dev/null +++ b/contracts/src/utils/TransactionBatcher.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Adapted from https://github.com/daostack/web3-transaction-batcher/blob/1b88d2ea062f8f2d9fdfdf9bbe85d2bbef780151/contracts/Batcher.sol + +contract TransactionBatcher { + function batchSend(address[] memory targets, uint256[] memory values, bytes[] memory datas) public payable { + for (uint256 i = 0; i < targets.length; i++) { + (bool success, ) = targets[i].call{value: values[i]}(datas[i]); + if (!success) revert("transaction failed"); // All the calls must succeed. + } + } + + function batchSendUnchecked( + address[] memory targets, + uint256[] memory values, + bytes[] memory datas + ) public payable { + for (uint256 i = 0; i < targets.length; i++) { + targets[i].call{value: values[i]}(datas[i]); // Intentionally ignoring return value. + } + } +} diff --git a/subgraph/core/schema.graphql b/subgraph/core/schema.graphql index 597914610..36192bc77 100644 --- a/subgraph/core/schema.graphql +++ b/subgraph/core/schema.graphql @@ -192,6 +192,8 @@ type Round @entity { court: Court! feeToken: FeeToken timeline: [BigInt!]! + jurorsDrawn: Boolean! + jurorRewardsDispersed: Boolean! } type Draw @entity(immutable: true) { @@ -264,6 +266,8 @@ type ClassicRound implements DisputeKitRound @entity { paidFees: [BigInt!]! contributions: [ClassicContribution!]! @derivedFrom(field: "localRound") feeRewards: BigInt! + totalFeeDispersed: BigInt! + appealFeesDispersed: Boolean! fundedChoices: [BigInt!]! justifications: [ClassicJustification!] @derivedFrom(field: "localRound") } diff --git a/subgraph/core/src/DisputeKitClassic.ts b/subgraph/core/src/DisputeKitClassic.ts index 4004af2f4..4c8c37729 100644 --- a/subgraph/core/src/DisputeKitClassic.ts +++ b/subgraph/core/src/DisputeKitClassic.ts @@ -107,7 +107,12 @@ export function handleChoiceFunded(event: ChoiceFunded): void { if (localRound.fundedChoices.length > 1) { const disputeKitClassic = DisputeKitClassic.bind(event.address); const klerosCore = KlerosCore.bind(disputeKitClassic.core()); - const appealCost = klerosCore.appealCost(BigInt.fromString(coreDisputeID)); + + // cannot use core.appealCost as that will give the cost for the newly created round + const numberOfRounds = klerosCore.getNumberOfRounds(BigInt.fromString(coreDisputeID)); + const roundInfo = klerosCore.getRoundInfo(BigInt.fromString(coreDisputeID), numberOfRounds.minus(ONE)); + const appealCost = roundInfo.totalFeesForJurors; + localRound.feeRewards = localRound.feeRewards.minus(appealCost); const localDispute = ClassicDispute.load(`${DISPUTEKIT_ID}-${coreDisputeID}`); @@ -127,5 +132,21 @@ export function handleWithdrawal(event: Withdrawal): void { if (!contribution) return; contribution.rewardWithdrawn = true; contribution.rewardAmount = event.params._amount; + + // check if all appeal fees have been withdrawn + const coreDisputeID = event.params._coreDisputeID.toString(); + const coreRoundIndex = event.params._coreRoundID.toString(); + const roundID = `${DISPUTEKIT_ID}-${coreDisputeID}-${coreRoundIndex}`; + + const localRound = ClassicRound.load(roundID); + if (!localRound) return; + + localRound.totalFeeDispersed = localRound.totalFeeDispersed.plus(event.params._amount); + + if (localRound.totalFeeDispersed.equals(localRound.feeRewards)) { + localRound.appealFeesDispersed = true; + } + contribution.save(); + localRound.save(); } diff --git a/subgraph/core/src/KlerosCore.ts b/subgraph/core/src/KlerosCore.ts index b7048279a..650d5a2ff 100644 --- a/subgraph/core/src/KlerosCore.ts +++ b/subgraph/core/src/KlerosCore.ts @@ -24,7 +24,7 @@ import { updateJurorStake } from "./entities/JurorTokensPerCourt"; import { createDrawFromEvent } from "./entities/Draw"; import { updateTokenAndEthShiftFromEvent } from "./entities/TokenAndEthShift"; import { updateArbitrableCases } from "./entities/Arbitrable"; -import { Court, Dispute, User } from "../generated/schema"; +import { Court, Dispute, Round, User } from "../generated/schema"; import { BigInt } from "@graphprotocol/graph-ts"; import { updatePenalty } from "./entities/Penalty"; import { ensureFeeToken } from "./entities/FeeToken"; @@ -185,6 +185,17 @@ export function handleDraw(event: DrawEvent): void { const jurorAddress = event.params._address.toHexString(); updateJurorStake(jurorAddress, dispute.court, sortitionModule, event.block.timestamp); addUserActiveDispute(jurorAddress, disputeID); + + const roundIndex = event.params._roundID; + const roundID = `${disputeID}-${roundIndex.toString()}`; + + const currentRound = Round.load(roundID); + if (!currentRound) return; + + if (currentRound.nbVotes.toI32() === currentRound.drawnJurors.load().length) { + currentRound.jurorsDrawn = true; + currentRound.save(); + } } export function handleTokenAndETHShift(event: TokenAndETHShiftEvent): void { @@ -199,6 +210,21 @@ export function handleTokenAndETHShift(event: TokenAndETHShiftEvent): void { const klerosCore = KlerosCore.bind(event.address); const sortitionModule = SortitionModule.bind(klerosCore.sortitionModule()); updateJurorStake(jurorAddress, court.id, sortitionModule, event.block.timestamp); + + const roundIndex = event.params._roundID; + const roundID = `${disputeID}-${roundIndex.toString()}`; + + const round = Round.load(roundID); + if (!round) return; + + const roundInfo = klerosCore.getRoundInfo(event.params._disputeID, roundIndex); + const repartitions = roundInfo.repartitions; + const nbVotes = roundInfo.nbVotes; + + if (repartitions >= nbVotes) { + round.jurorRewardsDispersed = true; + round.save(); + } } export function handleAcceptedFeeToken(event: AcceptedFeeToken): void { diff --git a/subgraph/core/src/entities/ClassicRound.ts b/subgraph/core/src/entities/ClassicRound.ts index 39670fdd1..93c3ae00e 100644 --- a/subgraph/core/src/entities/ClassicRound.ts +++ b/subgraph/core/src/entities/ClassicRound.ts @@ -16,6 +16,8 @@ export function createClassicRound(disputeID: string, numberOfChoices: BigInt, r classicRound.totalCommited = ZERO; classicRound.paidFees = new Array(choicesLength.toI32()).fill(ZERO); classicRound.feeRewards = ZERO; + classicRound.appealFeesDispersed = false; + classicRound.totalFeeDispersed = ZERO; classicRound.fundedChoices = []; classicRound.save(); } diff --git a/subgraph/core/src/entities/Round.ts b/subgraph/core/src/entities/Round.ts index f69624b1a..24de32510 100644 --- a/subgraph/core/src/entities/Round.ts +++ b/subgraph/core/src/entities/Round.ts @@ -24,6 +24,8 @@ export function createRoundFromRoundInfo( const courtID = contract.disputes(disputeID).value0.toString(); round.court = courtID; round.timeline = new Array(4).fill(new BigInt(0)); + round.jurorsDrawn = false; + round.jurorRewardsDispersed = false; round.save(); } diff --git a/web/src/assets/svgs/icons/dotted-menu.svg b/web/src/assets/svgs/icons/dotted-menu.svg new file mode 100644 index 000000000..41caab13f --- /dev/null +++ b/web/src/assets/svgs/icons/dotted-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/hooks/queries/useClassicAppealQuery.ts b/web/src/hooks/queries/useClassicAppealQuery.ts index 22c41bd43..d12a76238 100644 --- a/web/src/hooks/queries/useClassicAppealQuery.ts +++ b/web/src/hooks/queries/useClassicAppealQuery.ts @@ -27,6 +27,8 @@ const classicAppealQuery = graphql(` winningChoice paidFees fundedChoices + appealFeesDispersed + totalFeeDispersed } } } diff --git a/web/src/hooks/queries/useDisputeDetailsQuery.ts b/web/src/hooks/queries/useDisputeDetailsQuery.ts index 76d5c0b67..4fd0e5029 100644 --- a/web/src/hooks/queries/useDisputeDetailsQuery.ts +++ b/web/src/hooks/queries/useDisputeDetailsQuery.ts @@ -27,6 +27,7 @@ const disputeDetailsQuery = graphql(` tied currentRound { id + nbVotes } currentRoundIndex } diff --git a/web/src/hooks/queries/useDisputeMaintenanceQuery.ts b/web/src/hooks/queries/useDisputeMaintenanceQuery.ts new file mode 100644 index 000000000..0703804d2 --- /dev/null +++ b/web/src/hooks/queries/useDisputeMaintenanceQuery.ts @@ -0,0 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useGraphqlBatcher } from "context/GraphqlBatcher"; + +import { graphql } from "src/graphql"; +import { DisputeMaintenanceQuery } from "src/graphql/graphql"; +import { isUndefined } from "src/utils"; + +const disputeMaintenance = graphql(` + query DisputeMaintenance($disputeId: ID!, $disputeIdAsString: String!) { + dispute(id: $disputeId) { + currentRound { + jurorsDrawn + } + rounds { + id + jurorRewardsDispersed + nbVotes + } + } + contributions(where: { coreDispute: $disputeIdAsString }) { + contributor { + id + } + ... on ClassicContribution { + choice + rewardWithdrawn + } + coreDispute { + currentRoundIndex + } + } + } +`); + +const useDisputeMaintenanceQuery = (id?: string) => { + const isEnabled = !isUndefined(id); + + const { graphqlBatcher } = useGraphqlBatcher(); + return useQuery({ + queryKey: [`disputeMaintenanceQuery-${id}`], + enabled: isEnabled, + queryFn: async () => + await graphqlBatcher.fetch({ + id: crypto.randomUUID(), + document: disputeMaintenance, + variables: { disputeId: id?.toString(), disputeIdAsString: id?.toString() }, + }), + }); +}; + +export default useDisputeMaintenanceQuery; diff --git a/web/src/hooks/useTransactionBatcher.tsx b/web/src/hooks/useTransactionBatcher.tsx new file mode 100644 index 000000000..8cb10abad --- /dev/null +++ b/web/src/hooks/useTransactionBatcher.tsx @@ -0,0 +1,64 @@ +import { useCallback } from "react"; + +import { encodeFunctionData, type SimulateContractParameters } from "viem"; + +import { isUndefined } from "src/utils"; + +import { useSimulateTransactionBatcherBatchSend, useWriteTransactionBatcherBatchSend } from "./contracts/generated"; + +export type TransactionBatcherConfig = SimulateContractParameters[]; + +type TransactionBatcherOptions = { + // determines if simulation query is enabled + enabled: boolean; +}; + +/** + * @param configs SimulateContractParameters[] - an array of useWriteContract Parameters + * @param options TransactionBatcherOptions - an object containing options to apply to hook behaviour + * @description This takes in multiple write calls and batches them into a single transaction + * @example useTransactionBatcher([ + * { address : "contract one address", + * abi : "contract one abi", + * functionName : "...", + * args: [...] + * value: 0 + * }, + * { address : "contract 2 address", + * abi : "contract 2 abi", + * functionName : "...", + * args: [...] + * value: 0 + * }, + * ]) + */ +const useTransactionBatcher = ( + configs?: TransactionBatcherConfig, + options: TransactionBatcherOptions = { enabled: true } +) => { + const validatedConfigs = configs ?? []; + const { + data: batchConfig, + isLoading, + isError, + } = useSimulateTransactionBatcherBatchSend({ + query: { + enabled: !isUndefined(configs) && options.enabled, + }, + args: [ + validatedConfigs.map((config) => config?.address), + validatedConfigs.map((config) => config?.value ?? BigInt(0)), + validatedConfigs.map((config) => encodeFunctionData(config)), + ], + }); + const { writeContractAsync } = useWriteTransactionBatcherBatchSend(); + + const executeBatch = useCallback( + (config: NonNullable) => writeContractAsync(config?.request), + [writeContractAsync] + ); + + return { executeBatch, batchConfig, isError, isLoading }; +}; + +export default useTransactionBatcher; diff --git a/web/src/pages/Cases/CaseDetails/MaintenanceButtons/DistributeRewards.tsx b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/DistributeRewards.tsx new file mode 100644 index 000000000..21a9cad24 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/DistributeRewards.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { useAccount, usePublicClient } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { DEFAULT_CHAIN } from "consts/chains"; +import { klerosCoreAbi, klerosCoreAddress } from "hooks/contracts/generated"; +import useTransactionBatcher, { type TransactionBatcherConfig } from "hooks/useTransactionBatcher"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import useDisputeMaintenanceQuery from "queries/useDisputeMaintenanceQuery"; + +import { Period } from "src/graphql/graphql"; +import { isUndefined } from "src/utils"; + +import { IBaseMaintenanceButton } from "."; + +const StyledButton = styled(Button)` + width: 100%; +`; + +interface IDistributeRewards extends IBaseMaintenanceButton { + roundIndex?: string; + period?: string; +} + +const DistributeRewards: React.FC = ({ id, roundIndex, setIsOpen, period }) => { + const [isSending, setIsSending] = useState(false); + const [contractConfigs, setContractConfigs] = useState(); + const publicClient = usePublicClient(); + const { chainId } = useAccount(); + + const { data: maintenanceData } = useDisputeMaintenanceQuery(id); + + const rewardsDispersed = useMemo( + () => maintenanceData?.dispute?.rounds.every((round) => round.jurorRewardsDispersed), + [maintenanceData] + ); + + useEffect(() => { + const rounds = maintenanceData?.dispute?.rounds; + if (isUndefined(id) || isUndefined(roundIndex) || isUndefined(rounds)) return; + + const baseArgs = { + abi: klerosCoreAbi, + address: klerosCoreAddress[chainId ?? DEFAULT_CHAIN], + functionName: "execute", + }; + + const argsArr: TransactionBatcherConfig = []; + + for (const round of rounds) { + argsArr.push({ ...baseArgs, args: [BigInt(id), BigInt(round.id.split("-")[1]), BigInt(round.nbVotes)] }); + } + + setContractConfigs(argsArr); + }, [id, roundIndex, chainId, maintenanceData]); + + const { + executeBatch, + batchConfig, + isLoading: isLoadingConfig, + isError, + } = useTransactionBatcher(contractConfigs, { + enabled: !isUndefined(period) && period === Period.Execution && !rewardsDispersed, + }); + + const isLoading = useMemo(() => isLoadingConfig || isSending, [isLoadingConfig, isSending]); + const isDisabled = useMemo( + () => isUndefined(id) || isError || isLoading || period !== Period.Execution || rewardsDispersed, + [id, isError, isLoading, period, rewardsDispersed] + ); + + const handleClick = () => { + if (!publicClient || !batchConfig) return; + setIsSending(true); + + wrapWithToast(async () => await executeBatch(batchConfig), publicClient).finally(() => { + setIsSending(false); + setIsOpen(false); + }); + }; + return ; +}; + +export default DistributeRewards; diff --git a/web/src/pages/Cases/CaseDetails/MaintenanceButtons/DrawButton.tsx b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/DrawButton.tsx new file mode 100644 index 000000000..f4a23a654 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/DrawButton.tsx @@ -0,0 +1,71 @@ +import React, { useMemo, useState } from "react"; +import styled from "styled-components"; + +import { usePublicClient } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { useSimulateKlerosCoreDraw, useWriteKlerosCoreDraw } from "hooks/contracts/generated"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import useDisputeMaintenanceQuery from "queries/useDisputeMaintenanceQuery"; + +import { Period } from "src/graphql/graphql"; +import { isUndefined } from "src/utils"; + +import { IBaseMaintenanceButton } from "."; + +const StyledButton = styled(Button)` + width: 100%; +`; + +interface IDrawButton extends IBaseMaintenanceButton { + numberOfVotes?: string; + period?: string; +} + +const DrawButton: React.FC = ({ id, numberOfVotes, setIsOpen, period }) => { + const [isSending, setIsSending] = useState(false); + const publicClient = usePublicClient(); + const { data: maintenanceData } = useDisputeMaintenanceQuery(id); + + const isDrawn = useMemo(() => maintenanceData?.dispute?.currentRound.jurorsDrawn, [maintenanceData]); + + const { + data: drawConfig, + isLoading: isLoadingConfig, + isError, + } = useSimulateKlerosCoreDraw({ + query: { + enabled: + !isUndefined(id) && + !isUndefined(numberOfVotes) && + !isUndefined(period) && + period === Period.Evidence && + !isDrawn, + }, + args: [BigInt(id ?? 0), BigInt(numberOfVotes ?? 0)], + }); + + const { writeContractAsync: draw } = useWriteKlerosCoreDraw(); + + const isLoading = useMemo(() => isLoadingConfig || isSending, [isLoadingConfig, isSending]); + const isDisabled = useMemo( + () => + isUndefined(id) || isUndefined(numberOfVotes) || isError || isLoading || period !== Period.Evidence || isDrawn, + [id, numberOfVotes, isError, isLoading, period, isDrawn] + ); + const handleClick = () => { + if (!drawConfig || !publicClient) return; + + setIsSending(true); + + wrapWithToast(async () => await draw(drawConfig.request), publicClient).finally(() => { + setIsSending(false); + setIsOpen(false); + }); + }; + return ; +}; + +export default DrawButton; diff --git a/web/src/pages/Cases/CaseDetails/MaintenanceButtons/ExecuteRuling.tsx b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/ExecuteRuling.tsx new file mode 100644 index 000000000..42f5e9ac7 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/ExecuteRuling.tsx @@ -0,0 +1,60 @@ +import React, { useMemo, useState } from "react"; +import styled from "styled-components"; + +import { usePublicClient } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { useSimulateKlerosCoreExecuteRuling, useWriteKlerosCoreExecuteRuling } from "hooks/contracts/generated"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import { Period } from "src/graphql/graphql"; +import { isUndefined } from "src/utils"; + +import { IBaseMaintenanceButton } from "."; + +const StyledButton = styled(Button)` + width: 100%; +`; + +interface IExecuteRulingButton extends IBaseMaintenanceButton { + period?: string; + ruled?: boolean; +} + +const ExecuteRulingButton: React.FC = ({ id, setIsOpen, period, ruled }) => { + const [isSending, setIsSending] = useState(false); + const publicClient = usePublicClient(); + + const { + data: ruleConfig, + isLoading: isLoadingConfig, + isError, + } = useSimulateKlerosCoreExecuteRuling({ + query: { + enabled: !isUndefined(id) && !isUndefined(period) && period === Period.Execution && !ruled, + }, + args: [BigInt(id ?? 0)], + }); + + const { writeContractAsync: rule } = useWriteKlerosCoreExecuteRuling(); + + const isLoading = useMemo(() => isLoadingConfig || isSending, [isLoadingConfig, isSending]); + const isDisabled = useMemo( + () => isUndefined(id) || isError || isLoading || period !== Period.Execution || ruled, + [id, isError, isLoading, period, ruled] + ); + const handleClick = () => { + if (!ruleConfig) return; + + setIsSending(true); + + wrapWithToast(async () => await rule(ruleConfig.request), publicClient).finally(() => { + setIsSending(false); + setIsOpen(false); + }); + }; + return ; +}; + +export default ExecuteRulingButton; diff --git a/web/src/pages/Cases/CaseDetails/MaintenanceButtons/MenuButton.tsx b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/MenuButton.tsx new file mode 100644 index 000000000..6b6b2397e --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/MenuButton.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import styled, { css, keyframes } from "styled-components"; + +import DottedMenu from "svgs/icons/dotted-menu.svg"; + +const ripple = keyframes` + 0% { + opacity: 0; + transform: scale3d(0.5, 0.5, 1); + } + 10% { + opacity: 0.5; + transform: scale3d(0.75, 0.75, 1); + } + + 100% { + opacity: 0; + transform: scale3d(1.75, 1.75, 1); + } +`; + +const ring = (duration: string, delay: string) => css` + opacity: 0; + position: absolute; + top: 0; + left: 0; + transform: translate(50%); + content: ""; + height: 36px; + width: 36px; + border: 3px solid ${({ theme }) => theme.primaryBlue}; + border-radius: 100%; + animation-name: ${ripple}; + animation-duration: ${duration}; + animation-delay: ${delay}; + animation-iteration-count: infinite; + animation-timing-function: cubic-bezier(0.65, 0, 0.34, 1); + z-index: 0; +`; + +const Container = styled.div<{ displayRipple: boolean }>` + display: flex; + justify-content: center; + align-items: center; + ${({ displayRipple }) => + displayRipple && + css` + &::after { + ${ring("3s", "0s")} + } + &::before { + ${ring("3s", "0.5s")} + } + `} +`; + +const ButtonContainer = styled.div` + border-radius: 50%; + z-index: 1; + background-color: ${({ theme }) => theme.lightBackground}; +`; + +const StyledDottedMenu = styled(DottedMenu)` + cursor: pointer; + width: 36px; + height: 36px; + fill: ${({ theme }) => theme.primaryBlue}; +`; + +interface IMenuButton { + toggle: () => void; + displayRipple: boolean; +} + +const MenuButton: React.FC = ({ toggle, displayRipple }) => { + return ( + + + + + + ); +}; + +export default MenuButton; diff --git a/web/src/pages/Cases/CaseDetails/MaintenanceButtons/PassPeriodButton.tsx b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/PassPeriodButton.tsx new file mode 100644 index 000000000..533eb8908 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/PassPeriodButton.tsx @@ -0,0 +1,73 @@ +import React, { useMemo, useState } from "react"; +import styled from "styled-components"; + +import { usePublicClient } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { useSimulateKlerosCorePassPeriod, useWriteKlerosCorePassPeriod } from "hooks/contracts/generated"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import useDisputeMaintenanceQuery from "queries/useDisputeMaintenanceQuery"; + +import { Period } from "src/graphql/graphql"; +import { isUndefined } from "src/utils"; + +import { IBaseMaintenanceButton } from "."; + +const StyledButton = styled(Button)` + width: 100%; +`; + +interface IPassPeriodButton extends IBaseMaintenanceButton { + period?: string; +} + +const PassPeriodButton: React.FC = ({ id, setIsOpen, period }) => { + const [isSending, setIsSending] = useState(false); + const publicClient = usePublicClient(); + const { data: maintenanceData } = useDisputeMaintenanceQuery(id); + + const isDrawn = useMemo(() => maintenanceData?.dispute?.currentRound.jurorsDrawn, [maintenanceData]); + + const { + data: passPeriodConfig, + isLoading: isLoadingConfig, + isError, + } = useSimulateKlerosCorePassPeriod({ + query: { + enabled: + !isUndefined(id) && + !isUndefined(period) && + period !== Period.Execution && + !(period === Period.Evidence && !isDrawn), + }, + args: [BigInt(id ?? 0)], + }); + + const { writeContractAsync: passPeriod } = useWriteKlerosCorePassPeriod(); + + const isLoading = useMemo(() => isLoadingConfig || isSending, [isLoadingConfig, isSending]); + const isDisabled = useMemo( + () => + isUndefined(id) || + isError || + isLoading || + period === Period.Execution || + (period === Period.Evidence && !isDrawn), + [id, isError, isLoading, period, isDrawn] + ); + const handleClick = () => { + if (!passPeriodConfig || !publicClient) return; + + setIsSending(true); + + wrapWithToast(async () => await passPeriod(passPeriodConfig.request), publicClient).finally(() => { + setIsSending(false); + setIsOpen(false); + }); + }; + return ; +}; + +export default PassPeriodButton; diff --git a/web/src/pages/Cases/CaseDetails/MaintenanceButtons/WithdrawAppealFees.tsx b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/WithdrawAppealFees.tsx new file mode 100644 index 000000000..3d53883cb --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/WithdrawAppealFees.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { useAccount, usePublicClient } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { DEFAULT_CHAIN } from "consts/chains"; +import { disputeKitClassicAbi, disputeKitClassicAddress } from "hooks/contracts/generated"; +import useTransactionBatcher, { type TransactionBatcherConfig } from "hooks/useTransactionBatcher"; +import { getLocalRounds } from "utils/getLocalRounds"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import { useClassicAppealQuery } from "queries/useClassicAppealQuery"; +import useDisputeMaintenanceQuery from "queries/useDisputeMaintenanceQuery"; + +import { Period } from "src/graphql/graphql"; +import { isUndefined } from "src/utils"; + +import { IBaseMaintenanceButton } from "."; + +const StyledButton = styled(Button)` + width: 100%; +`; + +interface IWithdrawAppealFees extends IBaseMaintenanceButton { + roundIndex?: number; + period?: string; + ruled?: boolean; +} + +const WithdrawAppealFees: React.FC = ({ id, roundIndex, setIsOpen, period, ruled }) => { + const [isSending, setIsSending] = useState(false); + const [contractConfigs, setContractConfigs] = useState(); + const publicClient = usePublicClient(); + const { chainId } = useAccount(); + + const { data: maintenanceData } = useDisputeMaintenanceQuery(id); + const { data: appealData } = useClassicAppealQuery(id); + + const localRounds = useMemo(() => getLocalRounds(appealData?.dispute?.disputeKitDispute), [appealData]); + console.log({ localRounds }); + + const feeDispersed = useMemo( + () => + localRounds ? localRounds.slice(0, localRounds.length - 1).every((round) => round.appealFeesDispersed) : false, + [localRounds] + ); + + const filteredContributions = useMemo(() => { + const deDuplicatedContributions = [ + ...new Set(maintenanceData?.contributions.filter((contribution) => !contribution.rewardWithdrawn)), + ]; + + return deDuplicatedContributions; + }, [maintenanceData]); + + useEffect(() => { + if (isUndefined(id) || isUndefined(roundIndex)) return; + + const baseArgs = { + abi: disputeKitClassicAbi, + address: disputeKitClassicAddress[chainId ?? DEFAULT_CHAIN], + functionName: "withdrawFeesAndRewards", + }; + + const argsArr: TransactionBatcherConfig = []; + + for (const contribution of filteredContributions) { + for (let round = roundIndex; round >= 0; round--) { + argsArr.push({ + ...baseArgs, + args: [BigInt(id), contribution.contributor.id, BigInt(round), contribution.choice], + }); + } + } + + setContractConfigs(argsArr); + }, [id, roundIndex, chainId, filteredContributions]); + + const { + executeBatch, + batchConfig, + isLoading: isLoadingConfig, + isError, + } = useTransactionBatcher(contractConfigs, { + enabled: !isUndefined(period) && period === Period.Execution && Boolean(ruled) && !feeDispersed, + }); + + const isLoading = useMemo(() => isLoadingConfig || isSending, [isLoadingConfig, isSending]); + const isDisabled = useMemo( + () => isUndefined(id) || isError || isLoading || period !== Period.Execution || feeDispersed || !ruled, + [id, isError, isLoading, period, feeDispersed, ruled] + ); + + const handleClick = () => { + if (!publicClient || !batchConfig) return; + setIsSending(true); + + wrapWithToast(async () => await executeBatch(batchConfig), publicClient).finally(() => { + setIsSending(false); + setIsOpen(false); + }); + }; + return ; +}; + +export default WithdrawAppealFees; diff --git a/web/src/pages/Cases/CaseDetails/MaintenanceButtons/index.tsx b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/index.tsx new file mode 100644 index 000000000..1b42555c0 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/MaintenanceButtons/index.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; + +import { useParams } from "react-router-dom"; + +import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; + +import { Periods } from "src/consts/periods"; +import { Period } from "src/graphql/graphql"; + +import { EnsureChain } from "components/EnsureChain"; +import { Overlay } from "components/Overlay"; + +import DistributeRewards from "./DistributeRewards"; +import DrawButton from "./DrawButton"; +import ExecuteRulingButton from "./ExecuteRuling"; +import MenuButton from "./MenuButton"; +import PassPeriodButton from "./PassPeriodButton"; +import WithdrawAppealFees from "./WithdrawAppealFees"; + +const Container = styled.div` + width: 36px; + height: 36px; + display: flex; + justify-content: center; + align-items: center; + position: relative; +`; + +const PopupContainer = styled.div` + display: flex; + flex-direction: column; + position: absolute; + height: fit-content; + overflow-y: auto; + z-index: 31; + padding: 27px; + gap: 16px; + border: 1px solid ${({ theme }) => theme.stroke}; + background-color: ${({ theme }) => theme.whiteBackground}; + border-radius: 3px; + box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.06); + + bottom: 0; + left: 0; + transform: translate(-100%, 100%); +`; + +export interface IBaseMaintenanceButton { + setIsOpen: (open: boolean) => void; + id?: string; +} + +const MaintenanceButtons: React.FC = () => { + const { id } = useParams(); + const [isOpen, setIsOpen] = useState(false); + const [displayRipple, setDisplayRipple] = useState(false); + + const { data } = useDisputeDetailsQuery(id); + const dispute = data?.dispute; + + // using interval here instead of useMemo with dispute, since we can't tell when period has timed out, + // we can use useCountdown, but that would trigger the update every 1 sec. so this is ideal. + useEffect(() => { + const rippleCheck = () => { + if (!dispute) return; + + const period = Periods[dispute?.period] ?? 0; + const now = Date.now() / 1000; + + if ( + (dispute.period !== Period.Execution && + now > parseInt(dispute.lastPeriodChange) + parseInt(dispute.court.timesPerPeriod[period])) || + (dispute.period === Period.Execution && !dispute.ruled) + ) { + setDisplayRipple(true); + return; + } + + setDisplayRipple(false); + }; + + // initial check + rippleCheck(); + + const intervalId = setInterval(() => { + if (!dispute) return; + + if (dispute.ruled) { + clearInterval(intervalId); + return; + } + rippleCheck(); + }, 5000); + + return () => clearInterval(intervalId); + }, [dispute]); + + const toggle = () => setIsOpen((prevValue) => !prevValue); + return ( + + {isOpen ? ( + <> + setIsOpen(false)} /> + + + <> + + + + + + + + + + ) : null} + + + ); +}; + +export default MaintenanceButtons; diff --git a/web/src/pages/Cases/CaseDetails/index.tsx b/web/src/pages/Cases/CaseDetails/index.tsx index 1b2c42c5c..3f624c000 100644 --- a/web/src/pages/Cases/CaseDetails/index.tsx +++ b/web/src/pages/Cases/CaseDetails/index.tsx @@ -14,6 +14,7 @@ import { responsiveSize } from "styles/responsiveSize"; import Appeal from "./Appeal"; import Evidence from "./Evidence"; +import MaintenanceButtons from "./MaintenanceButtons"; import Overview from "./Overview"; import Tabs from "./Tabs"; import Timeline from "./Timeline"; @@ -27,8 +28,16 @@ const StyledCard = styled(Card)` min-height: 100px; `; +const HeaderContainer = styled.div` + width: 100%; + display: flex; + align-items: center; + margin-bottom: ${responsiveSize(32, 54)}; +`; + const Header = styled.h1` - margin-bottom: ${responsiveSize(16, 48)}; + margin: 0; + flex: 1; `; const CaseDetails: React.FC = () => { @@ -41,7 +50,10 @@ const CaseDetails: React.FC = () => { return ( -
Case #{id}
+ +
Case #{id}
+ +
diff --git a/web/src/utils/getLocalRounds.ts b/web/src/utils/getLocalRounds.ts index 6113f2bc9..1724247a9 100644 --- a/web/src/utils/getLocalRounds.ts +++ b/web/src/utils/getLocalRounds.ts @@ -1,20 +1,12 @@ -import { ClassicAppealQuery } from "queries/useClassicAppealQuery"; -import { VotingHistoryQuery } from "queries/useVotingHistory"; - -type IVotingHistoryLocalRounds = NonNullable< - NonNullable["disputeKitDispute"] ->["localRounds"]; - -type IClassicAppealQueryLocalRounds = NonNullable< - NonNullable["disputeKitDispute"] ->["localRounds"]; - -type ILocalRounds = IClassicAppealQueryLocalRounds | IVotingHistoryLocalRounds; - -interface IDisputeKitDisputes { - localRounds: ILocalRounds; +interface DisputeKitDispute { + localRounds: T[]; } -export const getLocalRounds = (disputeKitDisputes: IDisputeKitDisputes | undefined | null) => { - return disputeKitDisputes?.reduce((acc: ILocalRounds, { localRounds }) => acc.concat(localRounds), []); +/** + * @param disputeKitDisputes an array of dispute kit disputes with field localRounds + * @returns a flattened array of localRounds + */ +export const getLocalRounds = (disputeKitDisputes: DisputeKitDispute[] | undefined | null): T[] => { + if (!disputeKitDisputes) return []; + return disputeKitDisputes.flatMap(({ localRounds }) => localRounds); };