diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..3ec5a25cb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,79 @@ +name: "CodeQL Advanced" + +on: + merge_group: + branches: + - master + - dev + push: + branches: + - master + - dev + pull_request: + # The branches below must be a subset of the branches above + branches: + - master + - dev + schedule: + - cron: '26 1 * * 6' + +permissions: + actions: read + +jobs: + codeql-advanced-analysis: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 360 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: + - javascript + + steps: + - name: Harden Runner + uses: step-security/harden-runner@128a63446a954579617e875aaab7d2978154e969 # v2.4.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@29b1f65c5e92e24fe6b6647da1eaabe529cec70f # v2.3.3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/contracts-testing.yml b/.github/workflows/contracts-testing.yml index 0aa124185..f17e1e4ab 100644 --- a/.github/workflows/contracts-testing.yml +++ b/.github/workflows/contracts-testing.yml @@ -6,9 +6,20 @@ on: push: branches: - master + - dev + paths-ignore: + - "kleros-sdk/**" + - "services/**" + - "subgraph/**" + - "web/**" pull_request: branches: - "*" + paths-ignore: + - "kleros-sdk/**" + - "services/**" + - "subgraph/**" + - "web/**" permissions: # added using https://github.com/step-security/secure-workflows contents: read @@ -34,14 +45,14 @@ jobs: 54.185.253.63:443 - name: Setup Node.js environment - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c + uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d with: node-version: 16.x - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 + - uses: actions/checkout@7739b9ba2efcda9dde65ad1e3c2dbe65b41dfba7 - name: Cache node modules - uses: actions/cache@67b839edb68371cc5014f6cea11c9aa77238de78 + uses: actions/cache@f7ebb81a3f195b4fb88dab7c14e2f7aff52045aa env: cache-name: cache-node-modules with: @@ -72,7 +83,7 @@ jobs: working-directory: contracts - name: Upload a build artifact - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce + uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05 with: name: code-coverage-report path: contracts/coverage diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 8967e929a..e2d45d0f3 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -31,5 +31,9 @@ jobs: - name: 'Checkout Repository' uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: 'Dependency Review' - uses: actions/dependency-review-action@7d90b4f05fea31dde1c4a1fb3fa787e197ea93ab # v3.0.7 + uses: actions/dependency-review-action@f6fff72a3217f580d5afd49a46826795305b63c7 # v3.0.8 + with: + base-ref: ${{ github.event.pull_request.base.sha || 'dev' }} + head-ref: ${{ github.event.pull_request.head.sha || github.ref }} diff --git a/.github/workflows/deploy-bots.yml b/.github/workflows/deploy-bots.yml index c8a4d9f21..7a0705675 100644 --- a/.github/workflows/deploy-bots.yml +++ b/.github/workflows/deploy-bots.yml @@ -12,10 +12,10 @@ jobs: with: egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 + - uses: actions/checkout@7739b9ba2efcda9dde65ad1e3c2dbe65b41dfba7 - uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 - uses: aws-actions/setup-sam@12a6719db503425e98edcc798b6779590a450e8f - - uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1fff382a39b10d03659f355 + - uses: aws-actions/configure-aws-credentials@131c7b6fd10c0d7f36e1e49650b241d91ee327b9 with: aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_KEY }} diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 68e83d9dd..c626f8e4f 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -86,6 +86,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@5b6282e01c62d02e720b81eb8a51204f527c3624 # v2.21.3 + uses: github/codeql-action/upload-sarif@a09933a12a80f87b87005513f0abb1494c27a716 # v2.21.4 with: sarif_file: results.sarif diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml index 7cd838994..2249fc87f 100644 --- a/.github/workflows/sentry-release.yml +++ b/.github/workflows/sentry-release.yml @@ -29,10 +29,10 @@ jobs: sentry.io:443 54.185.253.63:443 - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 + - uses: actions/checkout@7739b9ba2efcda9dde65ad1e3c2dbe65b41dfba7 - name: Cache node modules - uses: actions/cache@67b839edb68371cc5014f6cea11c9aa77238de78 + uses: actions/cache@f7ebb81a3f195b4fb88dab7c14e2f7aff52045aa env: cache-name: cache-node-modules with: @@ -45,7 +45,7 @@ jobs: ${{ runner.os }}-build-${{ secrets.CACHE_VERSION }}-${{ env.cache-name }}- - name: Set up Node.js - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c + uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d with: node-version: 16 diff --git a/.vscode/settings.json b/.vscode/settings.json index 0386cc80f..0f801b6f5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ }, "solidity-va.test.defaultUnittestTemplate": "hardhat", "solidity-language-server.trace.server.verbosity": "message", + "typescript.tsdk": "node_modules/typescript/lib", "eslint.packageManager": "yarn", "prettier.useEditorConfig": true, "prettier.configPath": "prettier-config/.prettierrc.js" diff --git a/contracts/package.json b/contracts/package.json index be24cbb53..a933e65ed 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -29,6 +29,7 @@ "bot:relayer-from-chiado": "NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-fetch hardhat run ./scripts/disputeRelayerBotFromChiado.ts", "bot:relayer-from-goerli": "NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-fetch hardhat run ./scripts/disputeRelayerBotFromGoerli.ts", "bot:relayer-from-hardhat": "NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-fetch hardhat run ./scripts/disputeRelayerBotFromHardhat.ts", + "bot:disputor": "NODE_NO_WARNINGS=1 yarn hardhat run ./scripts/disputeCreatorBot.ts", "etherscan-verify": "hardhat etherscan-verify", "sourcify": "hardhat sourcify --write-failing-metadata", "size": "hardhat size-contracts --no-compile", @@ -75,7 +76,7 @@ "solhint-plugin-prettier": "^0.0.5", "solidity-coverage": "0.8.2", "ts-node": "^10.9.1", - "typechain": "^8.2.0", + "typechain": "^8.3.1", "typescript": "^4.9.5" }, "dependencies": { diff --git a/contracts/scripts/disputeCreatorBot.ts b/contracts/scripts/disputeCreatorBot.ts new file mode 100644 index 000000000..d487a2267 --- /dev/null +++ b/contracts/scripts/disputeCreatorBot.ts @@ -0,0 +1,78 @@ +import env from "./utils/env"; +import loggerFactory from "./utils/logger"; +import hre = require("hardhat"); +import { KlerosCore, DisputeResolver } from "../typechain-types"; +import { BigNumber } from "ethers"; +const { ethers } = hre; + +const HEARTBEAT_URL = env.optionalNoDefault("HEARTBEAT_URL_DISPUTOR_BOT"); +const loggerOptions = env.optionalNoDefault("LOGTAIL_TOKEN_DISPUTOR_BOT") + ? { + transportTargetOptions: { + target: "@logtail/pino", + options: { sourceToken: env.require("LOGTAIL_TOKEN_DISPUTOR_BOT") }, + level: env.optional("LOG_LEVEL", "info"), + }, + level: env.optional("LOG_LEVEL", "info"), // for pino-pretty + } + : {}; +const logger = loggerFactory.createLogger(loggerOptions); + +export default async function main() { + logger.info("Starting up"); + + const core = (await ethers.getContract("KlerosCore")) as KlerosCore; + const resolver = (await ethers.getContract("DisputeResolver")) as DisputeResolver; + + if (HEARTBEAT_URL) { + logger.debug("Sending heartbeat"); + fetch(HEARTBEAT_URL); + } else { + logger.debug("Heartbeat not set up, skipping"); + } + const extraData = + "0x" + + "0000000000000000000000000000000000000000000000000000000000000001" + // courtId 1 + "000000000000000000000000000000000000000000000000000000000000000B" + // minJurors 11 + "0000000000000000000000000000000000000000000000000000000000000002"; // disputeKitId 2 + const templates = [ + `{"title":"A reality.eth question","description":"A reality.eth question has been raised to arbitration.","question":"**Kleros Moderate:** Did the user, **degenape6** (ID: 1554345080), break the Telegram group, ***[Kleros Trading Group]()*** (ID: -1001151472172), ***[rules](https://ipfs.kleros.io/ipfs/Qme3Qbj9rKUNHUe9vj9rqCLnTVUCWKy2YfveQF8HiuWQSu/Kleros%20Moderate%20Community%20Rules.pdf)*** due to conduct related to the ***[message](https://t.me/c/1151472172/116662)*** (***[backup](https://ipfs.kleros.io/ipfs/QmVbFrZR1bcyQzZjvLyXwL9ekDxrqHERykdreRxXrw4nqg/animations_file_23.mp4)***)?","answers":[{"id":"0x01","title":"Yes","reserved":false},{"id":"0x02","title":"No","reserved":false},{"id":"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF","title":"Answered Too Soon","reserved":true}],"policyURI":"/ipfs/QmZ5XaV2RVgBADq5qMpbuEwgCuPZdRgCeu8rhGtJWLV6yz","frontendUrl":"https://reality.eth.limo/app/#!/question/0xe78996a233895be74a66f451f1019ca9734205cc-0xe2a3bd38e3ad4e22336ac35b221bbbdd808d716209f84014c7bc3bf62f8e3b39","arbitrableChainID":"100","arbitrableAddress":"0x2e39b8f43d0870ba896f516f78f57cde773cf805","arbitratorChainID":"421613","arbitratorAddress":"0xD08Ab99480d02bf9C092828043f611BcDFEA917b","category":"Oracle","lang":"en_US","specification":"KIP99"}`, + `{"title":"Add an entry to Ledger Contract Domain Name registry v2","description":"Someone requested to add an entry to Ledger Contract Domain Name registry v2","question":"Does the entry comply with the required criteria?","answers":[{"title":"Yes, Add It","description":"Select this if you think the entry complies with the required criteria and should be added."},{"title":"No, Don't Add It","description":"Select this if you think the entry does not comply with the required criteria and should not be added."}],"policyURI":"/ipfs/QmW3nQcMW2adyqe6TujRTYkyq26PiDqcmmTjdgKiz9ynPV","frontendUrl":"https://curate.kleros.io/tcr/100/0x957a53a994860be4750810131d9c876b2f52d6e1/0xc2c1aa705632f53051f22a9f65967c0944370020a7489aba608bd0d755ca1234","arbitratorChainID":"421613","arbitratorAddress":"0x791812B0B9f2ba260B2DA432BB02Ee23BC1bB509","category":"Curation","specification":"KIP0X","lang":"en_US"}`, + `{"title":"Omen Question: News & Politics","description":"This reality dispute has been created by Omen, we advise you to read [the Omen Rules](https://cdn.kleros.link/ipfs/QmU1oZzsduGwtC7vCUQPw1QcBP6BDNDkg4t6zkowPucVcx) and consult the evidence provided in [the Market Comments](https://omen.eth.limo/#/0x95b2271039b020aba31b933039e042b60b063800).","question":"**Assuming that today is December 20th 2020, will Joe Biden win the 2020 United States presidential election?**","answers":[{"title":"Yes"},{"title":"No"}],"policyURI":"/ipfs/QmU1oZzsduGwtC7vCUQPw1QcBP6BDNDkg4t6zkowPucVcx","frontendUrl":"https://omen.eth.limo/#/0x95b2271039b020aba31b933039e042b60b063800","arbitratorChainID":"421613","arbitratorAddress":"0x791812B0B9f2ba260B2DA432BB02Ee23BC1bB509","category":"Oracle","specification":"KIP0X","lang":"en_US"}`, + `{"title":"Proof of Humanity Registration Request","description":"A request to register the specified entry to a list of provable humans.","question":"Should the request to register be accepted?","answers":[{"title":"Yes","description":"Accept the request to register the entry."},{"title":"No","description":"Deny the request."}],"policyURI":"/ipfs/QmYPf2fdSyr9BiSy6pJFUmB1oTUPwg6dhEuFqL1n4ZosgH","frontendUrl":"https://app.proofofhumanity.id/profile/0x00de4b13153673bcae2616b67bf822500d325fc3?network=mainnet","arbitratorChainID":"421613","arbitratorAddress":"0x791812B0B9f2ba260B2DA432BB02Ee23BC1bB509","category":"Curated List","specification":"KIP0X","lang":"en_US"}`, + ]; + const randomTemplate = templates[Math.floor(Math.random() * templates.length)]; + const nbOfChoices = 2; + const cost = (await core.functions["arbitrationCost(bytes)"](extraData)).cost; + const tx = await resolver.createDisputeForTemplate( + extraData, + randomTemplate, + "disputeTemplateMapping: TODO", + nbOfChoices, + { + value: cost, + } + ); + + logger.info(`Dispute creation tx: ${tx.hash}`); + const blockNumber = await tx.wait().then((receipt) => receipt.blockNumber); + const disputeId = await resolver + .queryFilter(resolver.filters.DisputeRequest(), blockNumber, blockNumber) + .then((events) => BigNumber.from(events[0].args[1])); + logger.info(`Dispute created with disputeId ${disputeId.toString()}`); + + logger.info("Shutting down"); + await delay(2000); // Some log messages may be lost otherwise +} + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => { + logger.flush(); + }); diff --git a/contracts/scripts/keeperBot.ts b/contracts/scripts/keeperBot.ts index 83f23fbb9..8f97965b7 100644 --- a/contracts/scripts/keeperBot.ts +++ b/contracts/scripts/keeperBot.ts @@ -8,9 +8,10 @@ import hre = require("hardhat"); const { ethers } = hre; const MAX_DRAW_ITERATIONS = 30; const MAX_EXECUTE_ITERATIONS = 20; +const MAX_DELAYED_STAKES_ITERATIONS = 50; const WAIT_FOR_RNG_DURATION = 5 * 1000; // 5 seconds -const ITERATIONS_COOLDOWN_PERIOD = 20 * 1000; // 20 seconds -const HIGH_GAS_LIMIT = { gasLimit: 50000000 }; // 50M gas +const ITERATIONS_COOLDOWN_PERIOD = 10 * 1000; // 10 seconds +const HIGH_GAS_LIMIT = { gasLimit: 50_000_000 }; // 50M gas const HEARTBEAT_URL = env.optionalNoDefault("HEARTBEAT_URL_KEEPER_BOT"); const SUBGRAPH_URL = env.require("SUBGRAPH_URL"); const MAX_JURORS_PER_DISPUTE = 1000; // Skip disputes with more than this number of jurors @@ -219,7 +220,7 @@ const drawJurors = async (dispute: { id: string; currentRoundIndex: string }, it try { await core.callStatic.draw(dispute.id, iterations, HIGH_GAS_LIMIT); } catch (e) { - logger.info(`Draw: will fail for ${dispute.id}, skipping`); + logger.error(`Draw: will fail for ${dispute.id}, skipping`); return success; } try { @@ -241,7 +242,7 @@ const executeRepartitions = async (dispute: { id: string; currentRoundIndex: str try { await core.callStatic.execute(dispute.id, dispute.currentRoundIndex, iterations, HIGH_GAS_LIMIT); } catch (e) { - logger.info(`Execute: will fail for ${dispute.id}, skipping`); + logger.error(`Execute: will fail for ${dispute.id}, skipping`); return success; } try { @@ -260,7 +261,7 @@ const executeRuling = async (dispute: { id: string }) => { try { await core.callStatic.executeRuling(dispute.id); } catch (e) { - logger.info(`ExecuteRuling: will fail for ${dispute.id}, skipping`); + logger.error(`ExecuteRuling: will fail for ${dispute.id}, skipping`); return success; } try { @@ -290,7 +291,7 @@ const withdrawAppealContribution = async ( contribution.choice ); } catch (e) { - logger.info( + logger.warn( `WithdrawFeesAndRewards: will fail for dispute #${disputeId}, round #${roundId}, choice ${contribution.choice} and beneficiary ${contribution.contributor.id}, skipping` ); return success; @@ -323,6 +324,40 @@ const withdrawAppealContribution = async ( return success; }; +const executeDelayedStakes = async () => { + const { sortition } = await getContracts(); + + // delayedStakes = 1 + delayedStakeWriteIndex - delayedStakeReadIndex + const delayedStakesRemaining = BigNumber.from(1) + .add(await sortition.delayedStakeWriteIndex()) + .sub(await sortition.delayedStakeReadIndex()); + + const delayedStakes = delayedStakesRemaining.lt(MAX_DELAYED_STAKES_ITERATIONS) + ? delayedStakesRemaining + : BigNumber.from(MAX_DELAYED_STAKES_ITERATIONS); + + if (delayedStakes.eq(0)) { + logger.info("No delayed stakes to execute"); + return true; + } + logger.info(`Executing ${delayedStakes} delayed stakes, ${delayedStakesRemaining} remaining`); + let success = false; + try { + await sortition.callStatic.executeDelayedStakes(delayedStakes); + } catch (e) { + logger.error(`executeDelayedStakes: will fail because of ${JSON.stringify(e)}`); + return success; + } + try { + const gas = (await sortition.estimateGas.executeDelayedStakes(delayedStakes)).mul(150).div(100); // 50% extra gas + const tx = await (await sortition.executeDelayedStakes(delayedStakes, { gasLimit: gas })).wait(); + logger.info(`executeDelayedStakes txID: ${tx?.transactionHash}`); + } catch (e) { + handleError(e); + } + return success; +}; + const getMissingJurors = async (dispute: { id: string; currentRoundIndex: string }) => { const { core } = await getContracts(); const { nbVotes, drawnJurors } = await core.getRoundInfo(dispute.id, dispute.currentRoundIndex); @@ -594,18 +629,9 @@ async function main() { // ----------------------------------------------- // // EXECUTE DELAYED STAKES // // ----------------------------------------------- // - // delayedStakes = 1 + delayedStakeWriteIndex - delayedStakeReadIndex - const delayedStakes = BigNumber.from(1) - .add(await sortition.delayedStakeWriteIndex()) - .sub(await sortition.delayedStakeReadIndex()); if (await isPhaseStaking()) { - if (delayedStakes.gt(0)) { - logger.info("Executing delayed stakes"); - await sortition.executeDelayedStakes(delayedStakes); - } else { - logger.info("No delayed stakes to execute"); - } + await executeDelayedStakes(); } await sendHeartbeat(); diff --git a/contracts/src/arbitration/KlerosCore.sol b/contracts/src/arbitration/KlerosCore.sol index 819d250d8..5e7616882 100644 --- a/contracts/src/arbitration/KlerosCore.sol +++ b/contracts/src/arbitration/KlerosCore.sol @@ -64,12 +64,14 @@ contract KlerosCore is IArbitratorV2 { uint256 sumFeeRewardPaid; // Total sum of arbitration fees paid to coherent jurors as a reward in this round. uint256 sumPnkRewardPaid; // Total sum of PNK paid to coherent jurors as a reward in this round. IERC20 feeToken; // The token used for paying fees in this round. + uint256 drawIterations; // The number of iterations passed drawing the jurors for this round. } struct Juror { uint96[] courtIDs; // The IDs of courts where the juror's stake path ends. A stake path is a path from the general court to a court the juror directly staked in using `_setStake`. - mapping(uint96 => uint256) stakedPnk; // The amount of PNKs the juror has staked in the court in the form `stakedPnk[courtID]`. - mapping(uint96 => uint256) lockedPnk; // The amount of PNKs the juror has locked in the court in the form `lockedPnk[courtID]`. + uint256 stakedPnk; // The juror's total amount of tokens staked in subcourts. Reflects actual pnk balance. + uint256 lockedPnk; // The juror's total amount of tokens locked in disputes. Can reflect actual pnk balance when stakedPnk are fully withdrawn. + mapping(uint96 => uint256) stakedPnkByCourt; // The amount of PNKs the juror has staked in the court in the form `stakedPnkByCourt[courtID]`. } struct DisputeKitNode { @@ -126,7 +128,7 @@ contract KlerosCore is IArbitratorV2 { // ************************************* // event StakeSet(address indexed _address, uint256 _courtID, uint256 _amount); - event StakeDelayed(address indexed _address, uint256 _courtID, uint256 _amount, uint256 _penalty); + event StakeDelayed(address indexed _address, uint256 _courtID, uint256 _amount); event NewPeriod(uint256 indexed _disputeID, Period _period); event AppealPossible(uint256 indexed _disputeID, IArbitrableV2 indexed _arbitrable); event AppealDecision(uint256 indexed _disputeID, IArbitrableV2 indexed _arbitrable); @@ -481,14 +483,14 @@ contract KlerosCore is IArbitratorV2 { /// @dev Sets the caller's stake in a court. /// @param _courtID The ID of the court. - /// @param _stake The new stake. - function setStake(uint96 _courtID, uint256 _stake) external { - if (!_setStakeForAccount(msg.sender, _courtID, _stake, 0)) revert StakingFailed(); + /// @param _newStake The new stake. + function setStake(uint96 _courtID, uint256 _newStake) external { + if (!_setStakeForAccount(msg.sender, _courtID, _newStake)) revert StakingFailed(); } - function setStakeBySortitionModule(address _account, uint96 _courtID, uint256 _stake, uint256 _penalty) external { + function setStakeBySortitionModule(address _account, uint96 _courtID, uint256 _newStake) external { if (msg.sender != address(sortitionModule)) revert WrongCaller(); - _setStakeForAccount(_account, _courtID, _stake, _penalty); + _setStakeForAccount(_account, _courtID, _newStake); } /// @inheritdoc IArbitratorV2 @@ -608,21 +610,21 @@ contract KlerosCore is IArbitratorV2 { IDisputeKit disputeKit = disputeKitNodes[round.disputeKitID].disputeKit; - uint256 startIndex = round.drawnJurors.length; - uint256 endIndex = startIndex + _iterations <= round.nbVotes ? startIndex + _iterations : round.nbVotes; - - for (uint256 i = startIndex; i < endIndex; i++) { - address drawnAddress = disputeKit.draw(_disputeID); - if (drawnAddress != address(0)) { - jurors[drawnAddress].lockedPnk[dispute.courtID] += round.pnkAtStakePerJuror; - emit Draw(drawnAddress, _disputeID, currentRound, round.drawnJurors.length); - round.drawnJurors.push(drawnAddress); - - if (round.drawnJurors.length == round.nbVotes) { - sortitionModule.postDrawHook(_disputeID, currentRound); - } + uint256 startIndex = round.drawIterations; // for gas: less storage reads + uint256 i; + while (i < _iterations && round.drawnJurors.length < round.nbVotes) { + address drawnAddress = disputeKit.draw(_disputeID, startIndex + i++); + if (drawnAddress == address(0)) { + continue; + } + jurors[drawnAddress].lockedPnk += round.pnkAtStakePerJuror; + emit Draw(drawnAddress, _disputeID, currentRound, round.drawnJurors.length); + round.drawnJurors.push(drawnAddress); + if (round.drawnJurors.length == round.nbVotes) { + sortitionModule.postDrawHook(_disputeID, currentRound); } } + round.drawIterations += i; } /// @dev Appeals the ruling of a specified dispute. @@ -763,16 +765,14 @@ contract KlerosCore is IArbitratorV2 { // Unlock the PNKs affected by the penalty address account = round.drawnJurors[_params.repartition]; - jurors[account].lockedPnk[dispute.courtID] -= penalty; - - // Apply the penalty to the staked PNKs - if (jurors[account].stakedPnk[dispute.courtID] >= courts[dispute.courtID].minStake + penalty) { - // The juror still has enough staked PNKs after penalty for this court. - uint256 newStake = jurors[account].stakedPnk[dispute.courtID] - penalty; - _setStakeForAccount(account, dispute.courtID, newStake, penalty); - } else if (jurors[account].stakedPnk[dispute.courtID] != 0) { - // The juror does not have enough staked PNKs after penalty for this court, unstake them. - _setStakeForAccount(account, dispute.courtID, 0, penalty); + jurors[account].lockedPnk -= penalty; + + // Apply the penalty to the staked PNKs. + // Note that lockedPnk will always cover penalty while stakedPnk can become lower after manual unstaking. + if (jurors[account].stakedPnk >= penalty) { + jurors[account].stakedPnk -= penalty; + } else { + jurors[account].stakedPnk = 0; } emit TokenAndETHShift( account, @@ -832,10 +832,10 @@ contract KlerosCore is IArbitratorV2 { uint256 pnkLocked = (round.pnkAtStakePerJuror * degreeOfCoherence) / ALPHA_DIVISOR; // Release the rest of the PNKs of the juror for this round. - jurors[account].lockedPnk[dispute.courtID] -= pnkLocked; + jurors[account].lockedPnk -= pnkLocked; // Give back the locked PNKs in case the juror fully unstaked earlier. - if (jurors[account].stakedPnk[dispute.courtID] == 0) { + if (jurors[account].stakedPnk == 0) { pinakion.safeTransfer(account, pnkLocked); } @@ -973,38 +973,8 @@ contract KlerosCore is IArbitratorV2 { (ruling, tied, overridden) = disputeKit.currentRuling(_disputeID); } - function getRoundInfo( - uint256 _disputeID, - uint256 _round - ) - external - view - returns ( - uint256 disputeKitID, - uint256 pnkAtStakePerJuror, - uint256 totalFeesForJurors, - uint256 nbVotes, - uint256 repartitions, - uint256 pnkPenalties, - address[] memory drawnJurors, - uint256 sumFeeRewardPaid, - uint256 sumPnkRewardPaid, - IERC20 feeToken - ) - { - Round storage round = disputes[_disputeID].rounds[_round]; - return ( - round.disputeKitID, - round.pnkAtStakePerJuror, - round.totalFeesForJurors, - round.nbVotes, - round.repartitions, - round.pnkPenalties, - round.drawnJurors, - round.sumFeeRewardPaid, - round.sumPnkRewardPaid, - round.feeToken - ); + function getRoundInfo(uint256 _disputeID, uint256 _round) external view returns (Round memory) { + return disputes[_disputeID].rounds[_round]; } function getNumberOfRounds(uint256 _disputeID) external view returns (uint256) { @@ -1014,10 +984,11 @@ contract KlerosCore is IArbitratorV2 { function getJurorBalance( address _juror, uint96 _courtID - ) external view returns (uint256 staked, uint256 locked, uint256 nbCourts) { + ) external view returns (uint256 totalStaked, uint256 totalLocked, uint256 stakedInCourt, uint256 nbCourts) { Juror storage juror = jurors[_juror]; - staked = juror.stakedPnk[_courtID]; - locked = juror.lockedPnk[_courtID]; + totalStaked = juror.stakedPnk; + totalLocked = juror.lockedPnk; + stakedInCourt = juror.stakedPnkByCourt[_courtID]; nbCourts = juror.courtIDs.length; } @@ -1109,77 +1080,80 @@ contract KlerosCore is IArbitratorV2 { /// and `j` is the maximum number of jurors that ever staked in one of these courts simultaneously. /// @param _account The address of the juror. /// @param _courtID The ID of the court. - /// @param _stake The new stake. - /// @param _penalty Penalized amount won't be transferred back to juror when the stake is lowered. + /// @param _newStake The new stake. /// @return succeeded True if the call succeeded, false otherwise. function _setStakeForAccount( address _account, uint96 _courtID, - uint256 _stake, - uint256 _penalty + uint256 _newStake ) internal returns (bool succeeded) { if (_courtID == FORKING_COURT || _courtID > courts.length) return false; Juror storage juror = jurors[_account]; - uint256 currentStake = juror.stakedPnk[_courtID]; + uint256 currentStake = juror.stakedPnkByCourt[_courtID]; - if (_stake != 0) { - // Check against locked PNKs in case the min stake was lowered. - if (_stake < courts[_courtID].minStake || _stake < juror.lockedPnk[_courtID]) return false; + if (_newStake != 0) { + if (_newStake < courts[_courtID].minStake) return false; + } else if (currentStake == 0) { + return false; } - ISortitionModule.preStakeHookResult result = sortitionModule.preStakeHook(_account, _courtID, _stake, _penalty); + ISortitionModule.preStakeHookResult result = sortitionModule.preStakeHook(_account, _courtID, _newStake); if (result == ISortitionModule.preStakeHookResult.failed) { return false; } else if (result == ISortitionModule.preStakeHookResult.delayed) { - emit StakeDelayed(_account, _courtID, _stake, _penalty); + emit StakeDelayed(_account, _courtID, _newStake); return true; } uint256 transferredAmount; - if (_stake >= currentStake) { - transferredAmount = _stake - currentStake; + if (_newStake >= currentStake) { + // Stake increase + // When stakedPnk becomes lower than lockedPnk count the locked tokens in when transferring tokens from juror. + // (E.g. stakedPnk = 0, lockedPnk = 150) which can happen if the juror unstaked fully while having some tokens locked. + uint256 previouslyLocked = (juror.lockedPnk >= juror.stakedPnk) ? juror.lockedPnk - juror.stakedPnk : 0; // underflow guard + transferredAmount = (_newStake >= currentStake + previouslyLocked) // underflow guard + ? _newStake - currentStake - previouslyLocked + : 0; if (transferredAmount > 0) { - if (pinakion.safeTransferFrom(_account, address(this), transferredAmount)) { - if (currentStake == 0) { - juror.courtIDs.push(_courtID); - } - } else { + if (!pinakion.safeTransferFrom(_account, address(this), transferredAmount)) { return false; } } + if (currentStake == 0) { + juror.courtIDs.push(_courtID); + } } else { - if (_stake == 0) { - // Keep locked PNKs in the contract and release them after dispute is executed. - transferredAmount = currentStake - juror.lockedPnk[_courtID] - _penalty; - if (transferredAmount > 0) { - if (pinakion.safeTransfer(_account, transferredAmount)) { - for (uint256 i = juror.courtIDs.length; i > 0; i--) { - if (juror.courtIDs[i - 1] == _courtID) { - juror.courtIDs[i - 1] = juror.courtIDs[juror.courtIDs.length - 1]; - juror.courtIDs.pop(); - break; - } - } - } else { - return false; - } + // Stake decrease: make sure locked tokens always stay in the contract. They can only be released during Execution. + if (juror.stakedPnk >= currentStake - _newStake + juror.lockedPnk) { + // We have enough pnk staked to afford withdrawal while keeping locked tokens. + transferredAmount = currentStake - _newStake; + } else if (juror.stakedPnk >= juror.lockedPnk) { + // Can't afford withdrawing the current stake fully. Take whatever is available while keeping locked tokens. + transferredAmount = juror.stakedPnk - juror.lockedPnk; + } + if (transferredAmount > 0) { + if (!pinakion.safeTransfer(_account, transferredAmount)) { + return false; } - } else { - transferredAmount = currentStake - _stake - _penalty; - if (transferredAmount > 0) { - if (!pinakion.safeTransfer(_account, transferredAmount)) { - return false; + } + if (_newStake == 0) { + for (uint256 i = juror.courtIDs.length; i > 0; i--) { + if (juror.courtIDs[i - 1] == _courtID) { + juror.courtIDs[i - 1] = juror.courtIDs[juror.courtIDs.length - 1]; + juror.courtIDs.pop(); + break; } } } } - // Update juror's records. - juror.stakedPnk[_courtID] = _stake; + // Note that stakedPnk can become async with currentStake (e.g. after penalty). + juror.stakedPnk = (juror.stakedPnk >= currentStake) ? juror.stakedPnk - currentStake + _newStake : _newStake; + juror.stakedPnkByCourt[_courtID] = _newStake; - sortitionModule.setStake(_account, _courtID, _stake); - emit StakeSet(_account, _courtID, _stake); + sortitionModule.setStake(_account, _courtID, _newStake); + emit StakeSet(_account, _courtID, _newStake); return true; } diff --git a/contracts/src/arbitration/SortitionModule.sol b/contracts/src/arbitration/SortitionModule.sol index f870ed52a..1cd4e3bf6 100644 --- a/contracts/src/arbitration/SortitionModule.sol +++ b/contracts/src/arbitration/SortitionModule.sol @@ -36,7 +36,6 @@ contract SortitionModule is ISortitionModule { address account; // The address of the juror. uint96 courtID; // The ID of the court. uint256 stake; // The new stake. - uint256 penalty; // Penalty value, in case the stake was set during execution. } // ************************************* // @@ -185,12 +184,7 @@ contract SortitionModule is ISortitionModule { for (uint256 i = delayedStakeReadIndex; i < newDelayedStakeReadIndex; i++) { DelayedStake storage delayedStake = delayedStakes[i]; - core.setStakeBySortitionModule( - delayedStake.account, - delayedStake.courtID, - delayedStake.stake, - delayedStake.penalty - ); + core.setStakeBySortitionModule(delayedStake.account, delayedStake.courtID, delayedStake.stake); delete delayedStakes[i]; } delayedStakeReadIndex = newDelayedStakeReadIndex; @@ -199,10 +193,9 @@ contract SortitionModule is ISortitionModule { function preStakeHook( address _account, uint96 _courtID, - uint256 _stake, - uint256 _penalty + uint256 _stake ) external override onlyByCore returns (preStakeHookResult) { - (uint256 currentStake, , uint256 nbCourts) = core.getJurorBalance(_account, _courtID); + (, , uint256 currentStake, uint256 nbCourts) = core.getJurorBalance(_account, _courtID); if (currentStake == 0 && nbCourts >= MAX_STAKE_PATHS) { // Prevent staking beyond MAX_STAKE_PATHS but unstaking is always allowed. return preStakeHookResult.failed; @@ -211,8 +204,7 @@ contract SortitionModule is ISortitionModule { delayedStakes[++delayedStakeWriteIndex] = DelayedStake({ account: _account, courtID: _courtID, - stake: _stake, - penalty: _penalty + stake: _stake }); return preStakeHookResult.delayed; } @@ -264,7 +256,7 @@ contract SortitionModule is ISortitionModule { function setJurorInactive(address _account) external override onlyByCore { uint96[] memory courtIDs = core.getJurorCourtIDs(_account); for (uint256 j = courtIDs.length; j > 0; j--) { - core.setStakeBySortitionModule(_account, courtIDs[j - 1], 0, 0); + core.setStakeBySortitionModule(_account, courtIDs[j - 1], 0); } } @@ -276,7 +268,7 @@ contract SortitionModule is ISortitionModule { /// Note that this function reverts if the sum of all values in the tree is 0. /// @param _key The key of the tree. /// @param _coreDisputeID Index of the dispute in Kleros Core. - /// @param _voteID ID of the voter. + /// @param _nonce Nonce to hash with random number. /// @return drawnAddress The drawn address. /// `O(k * log_k(n))` where /// `k` is the maximum number of children per node in the tree, @@ -284,7 +276,7 @@ contract SortitionModule is ISortitionModule { function draw( bytes32 _key, uint256 _coreDisputeID, - uint256 _voteID + uint256 _nonce ) public view override returns (address drawnAddress) { require(phase == Phase.drawing, "Wrong phase."); SortitionSumTree storage tree = sortitionSumTrees[_key]; @@ -293,7 +285,7 @@ contract SortitionModule is ISortitionModule { return address(0); // No jurors staked. } - uint256 currentDrawnNumber = uint256(keccak256(abi.encodePacked(randomNumber, _coreDisputeID, _voteID))) % + uint256 currentDrawnNumber = uint256(keccak256(abi.encodePacked(randomNumber, _coreDisputeID, _nonce))) % tree.nodes[0]; // While it still has children diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol index 4eb8fb523..cf4e927b3 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol @@ -181,9 +181,11 @@ contract DisputeKitClassic is BaseDisputeKit, IEvidence { /// @dev Draws the juror from the sortition tree. The drawn address is picked up by Kleros Core. /// Note: Access restricted to Kleros Core only. /// @param _coreDisputeID The ID of the dispute in Kleros Core. + /// @param _nonce Nonce of the drawing iteration. /// @return drawnAddress The drawn address. function draw( - uint256 _coreDisputeID + uint256 _coreDisputeID, + uint256 _nonce ) external override onlyByCore notJumped(_coreDisputeID) returns (address drawnAddress) { Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]]; Round storage round = dispute.rounds[dispute.rounds.length - 1]; @@ -193,7 +195,7 @@ contract DisputeKitClassic is BaseDisputeKit, IEvidence { bytes32 key = bytes32(uint256(courtID)); // Get the ID of the tree. // TODO: Handle the situation when no one has staked yet. - drawnAddress = sortitionModule.draw(key, _coreDisputeID, round.votes.length); + drawnAddress = sortitionModule.draw(key, _coreDisputeID, _nonce); if (_postDrawCheck(_coreDisputeID, drawnAddress)) { round.votes.push(Vote({account: drawnAddress, commit: bytes32(0), choice: 0, voted: false})); @@ -558,7 +560,12 @@ contract DisputeKitClassic is BaseDisputeKit, IEvidence { // ************************************* // /// @inheritdoc BaseDisputeKit - function _postDrawCheck(uint256 /*_coreDisputeID*/, address /*_juror*/) internal pure override returns (bool) { - return true; + function _postDrawCheck(uint256 _coreDisputeID, address _juror) internal view override returns (bool) { + (uint96 courtID, , , , ) = core.disputes(_coreDisputeID); + uint256 lockedAmountPerJuror = core + .getRoundInfo(_coreDisputeID, core.getNumberOfRounds(_coreDisputeID) - 1) + .pnkAtStakePerJuror; + (uint256 totalStaked, uint256 totalLocked, , ) = core.getJurorBalance(_juror, courtID); + return totalStaked >= totalLocked + lockedAmountPerJuror; } } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol b/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol index 87528b8b9..eb907c87e 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitSybilResistant.sol @@ -200,9 +200,11 @@ contract DisputeKitSybilResistant is BaseDisputeKit, IEvidence { /// @dev Draws the juror from the sortition tree. The drawn address is picked up by Kleros Core. /// Note: Access restricted to Kleros Core only. /// @param _coreDisputeID The ID of the dispute in Kleros Core. + /// @param _nonce Nonce of the drawing iteration. /// @return drawnAddress The drawn address. function draw( - uint256 _coreDisputeID + uint256 _coreDisputeID, + uint256 _nonce ) external override onlyByCore notJumped(_coreDisputeID) returns (address drawnAddress) { Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]]; Round storage round = dispute.rounds[dispute.rounds.length - 1]; @@ -212,7 +214,7 @@ contract DisputeKitSybilResistant is BaseDisputeKit, IEvidence { bytes32 key = bytes32(uint256(courtID)); // Get the ID of the tree. // TODO: Handle the situation when no one has staked yet. - drawnAddress = sortitionModule.draw(key, _coreDisputeID, round.votes.length); + drawnAddress = sortitionModule.draw(key, _coreDisputeID, _nonce); if (_postDrawCheck(_coreDisputeID, drawnAddress) && !round.alreadyDrawn[drawnAddress]) { round.votes.push(Vote({account: drawnAddress, commit: bytes32(0), choice: 0, voted: false})); @@ -583,13 +585,11 @@ contract DisputeKitSybilResistant is BaseDisputeKit, IEvidence { /// @return Whether the address can be drawn or not. function _postDrawCheck(uint256 _coreDisputeID, address _juror) internal view override returns (bool) { (uint96 courtID, , , , ) = core.disputes(_coreDisputeID); - (, uint256 lockedAmountPerJuror, , , , , , , , ) = core.getRoundInfo( - _coreDisputeID, - core.getNumberOfRounds(_coreDisputeID) - 1 - ); - (uint256 staked, uint256 locked, ) = core.getJurorBalance(_juror, courtID); - (, , uint256 minStake, , , , ) = core.courts(courtID); - if (staked < locked + lockedAmountPerJuror || staked < minStake) { + uint256 lockedAmountPerJuror = core + .getRoundInfo(_coreDisputeID, core.getNumberOfRounds(_coreDisputeID) - 1) + .pnkAtStakePerJuror; + (uint256 totalStaked, uint256 totalLocked, , ) = core.getJurorBalance(_juror, courtID); + if (totalStaked < totalLocked + lockedAmountPerJuror) { return false; } else { return _proofOfHumanity(_juror); diff --git a/contracts/src/arbitration/interfaces/IDisputeKit.sol b/contracts/src/arbitration/interfaces/IDisputeKit.sol index bd8f804c0..2905c9cf5 100644 --- a/contracts/src/arbitration/interfaces/IDisputeKit.sol +++ b/contracts/src/arbitration/interfaces/IDisputeKit.sol @@ -51,8 +51,9 @@ interface IDisputeKit { /// @dev Draws the juror from the sortition tree. The drawn address is picked up by Kleros Core. /// Note: Access restricted to Kleros Core only. /// @param _coreDisputeID The ID of the dispute in Kleros Core, not in the Dispute Kit. + /// @param _nonce Nonce. /// @return drawnAddress The drawn address. - function draw(uint256 _coreDisputeID) external returns (address drawnAddress); + function draw(uint256 _coreDisputeID, uint256 _nonce) external returns (address drawnAddress); // ************************************* // // * Public Views * // diff --git a/contracts/src/arbitration/interfaces/ISortitionModule.sol b/contracts/src/arbitration/interfaces/ISortitionModule.sol index 0c5f15c6f..3bee0e270 100644 --- a/contracts/src/arbitration/interfaces/ISortitionModule.sol +++ b/contracts/src/arbitration/interfaces/ISortitionModule.sol @@ -24,14 +24,9 @@ interface ISortitionModule { function notifyRandomNumber(uint256 _drawnNumber) external; - function draw(bytes32 _court, uint256 _coreDisputeID, uint256 _voteID) external view returns (address); - - function preStakeHook( - address _account, - uint96 _courtID, - uint256 _stake, - uint256 _penalty - ) external returns (preStakeHookResult); + function draw(bytes32 _court, uint256 _coreDisputeID, uint256 _nonce) external view returns (address); + + function preStakeHook(address _account, uint96 _courtID, uint256 _stake) external returns (preStakeHookResult); function createDisputeHook(uint256 _disputeID, uint256 _roundID) external; diff --git a/contracts/test/arbitration/draw.ts b/contracts/test/arbitration/draw.ts index 40840832b..cb3bc2619 100644 --- a/contracts/test/arbitration/draw.ts +++ b/contracts/test/arbitration/draw.ts @@ -1,22 +1,20 @@ import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs"; import { deployments, ethers, getNamedAccounts, network } from "hardhat"; -import { BigNumber, ContractTransaction, Wallet } from "ethers"; +import { BigNumber, ContractReceipt, ContractTransaction, Wallet } from "ethers"; import { PNK, KlerosCore, ArbitrableExample, HomeGateway, DisputeKitClassic, - RandomizerRNG, - RandomizerMock, SortitionModule, } from "../../typechain-types"; import { expect } from "chai"; +import { DrawEvent } from "../../typechain-types/src/kleros-v1/kleros-liquid-xdai/XKlerosLiquidV2"; /* eslint-disable no-unused-vars */ /* eslint-disable no-unused-expressions */ // https://github.com/standard/standard/issues/690#issuecomment-278533482 -// FIXME: This test fails on Github actions, cannot figure why, skipping for now. describe("Draw Benchmark", async () => { const ONE_TENTH_ETH = BigNumber.from(10).pow(17); const ONE_THOUSAND_PNK = BigNumber.from(10).pow(21); @@ -49,7 +47,11 @@ describe("Draw Benchmark", async () => { let homeGateway; let sortitionModule; let rng; - let randomizer; + let parentCourtMinStake: BigNumber; + let childCourtMinStake: BigNumber; + const RANDOM = BigNumber.from("61688911660239508166491237672720926005752254046266901728404745669596507231249"); + const PARENT_COURT = 1; + const CHILD_COURT = 2; beforeEach("Setup", async () => { ({ deployer, relayer } = await getNamedAccounts()); @@ -63,11 +65,25 @@ describe("Draw Benchmark", async () => { core = (await ethers.getContract("KlerosCore")) as KlerosCore; homeGateway = (await ethers.getContract("HomeGatewayToEthereum")) as HomeGateway; arbitrable = (await ethers.getContract("ArbitrableExample")) as ArbitrableExample; - rng = (await ethers.getContract("RandomizerRNG")) as RandomizerRNG; - randomizer = (await ethers.getContract("RandomizerMock")) as RandomizerMock; sortitionModule = (await ethers.getContract("SortitionModule")) as SortitionModule; - // CourtId 2 + parentCourtMinStake = await core + .GENERAL_COURT() + .then((courtId) => core.courts(courtId)) + .then((court) => court.minStake); + + childCourtMinStake = BigNumber.from(10).pow(20).mul(3); // 300 PNK + + // Make the tests more deterministic with this dummy RNG + rng = await deployments.deploy("IncrementalNG", { + from: deployer, + args: [RANDOM], + log: true, + }); + + await sortitionModule.changeRandomNumberGenerator(rng.address, 20); + + // CourtId 2 = CHILD_COURT const minStake = BigNumber.from(10).pow(20).mul(3); // 300 PNK const alpha = 10000; const feeForJuror = BigNumber.from(10).pow(17); @@ -84,16 +100,24 @@ describe("Draw Benchmark", async () => { ); }); + type CountedDraws = { [address: string]: number }; type SetStake = (wallet: Wallet) => Promise; type ExpectFromDraw = (drawTx: Promise) => Promise; - const draw = async (setStake: SetStake, createDisputeCourtId: string, expectFromDraw: ExpectFromDraw) => { + const draw = async ( + stake: SetStake, + createDisputeCourtId: number, + expectFromDraw: ExpectFromDraw, + unstake: SetStake + ) => { const arbitrationCost = ONE_TENTH_ETH.mul(3); const [bridger] = await ethers.getSigners(); + const wallets: Wallet[] = []; // Stake some jurors for (let i = 0; i < 16; i++) { const wallet = ethers.Wallet.createRandom().connect(ethers.provider); + wallets.push(wallet); await bridger.sendTransaction({ to: wallet.address, @@ -106,7 +130,7 @@ describe("Draw Benchmark", async () => { await pnk.connect(wallet).approve(core.address, ONE_THOUSAND_PNK.mul(10), { gasLimit: 300000 }); - await setStake(wallet); + await stake(wallet); } // Create a dispute @@ -144,75 +168,247 @@ describe("Draw Benchmark", async () => { await network.provider.send("evm_mine"); } - await randomizer.relay(rng.address, 0, ethers.utils.randomBytes(32)); await sortitionModule.passPhase(); // Generating -> Drawing + await expectFromDraw(core.draw(0, 20, { gasLimit: 1000000 })); + + await network.provider.send("evm_increaseTime", [2000]); // Wait for maxDrawingTime + await sortitionModule.passPhase(); // Drawing -> Staking + expect(await sortitionModule.phase()).to.equal(Phase.staking); - await expectFromDraw(core.draw(0, 1000, { gasLimit: 1000000 })); + // Unstake jurors + for (const wallet of wallets) { + await unstake(wallet); + } + }; + + const countDraws = async (blockNumber: number) => { + const draws: Array = await core.queryFilter(core.filters.Draw(), blockNumber, blockNumber); + return draws.reduce((acc: { [address: string]: number }, draw) => { + const address = draw.args._address; + acc[address] = acc[address] ? acc[address] + 1 : 1; + return acc; + }, {}); }; it("Stakes in parent court and should draw jurors in parent court", async () => { - const setStake = async (wallet: Wallet) => { - await core.connect(wallet).setStake(1, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); + const stake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(PARENT_COURT, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); + + expect(await core.getJurorBalance(wallet.address, 1)).to.deep.equal([ + ONE_THOUSAND_PNK.mul(5), // totalStaked + 0, // totalLocked + ONE_THOUSAND_PNK.mul(5), // stakedInCourt + PARENT_COURT, // nbOfCourts + ]); }; - + let countedDraws: CountedDraws; const expectFromDraw = async (drawTx: Promise) => { - await expect(drawTx) + expect(await core.getRoundInfo(0, 0).then((round) => round.drawIterations)).to.equal(3); + + const tx = await (await drawTx).wait(); + expect(tx) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 0) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 1) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 2); + + countedDraws = await countDraws(tx.blockNumber); + for (const [address, draws] of Object.entries(countedDraws)) { + expect(await core.getJurorBalance(address, PARENT_COURT)).to.deep.equal([ + ONE_THOUSAND_PNK.mul(5), // totalStaked + parentCourtMinStake.mul(draws), // totalLocked + ONE_THOUSAND_PNK.mul(5), // stakedInCourt + 1, // nbOfCourts + ]); + expect(await core.getJurorBalance(address, CHILD_COURT)).to.deep.equal([ + ONE_THOUSAND_PNK.mul(5), // totalStaked + parentCourtMinStake.mul(draws), // totalLocked + 0, // stakedInCourt + 1, // nbOfCourts + ]); + } + }; + + const unstake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(PARENT_COURT, 0, { gasLimit: 5000000 }); + const locked = parentCourtMinStake.mul(countedDraws[wallet.address] ?? 0); + expect( + await core.getJurorBalance(wallet.address, PARENT_COURT), + "Drawn jurors have a locked stake in the parent court" + ).to.deep.equal([ + 0, // totalStaked + locked, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); + expect( + await core.getJurorBalance(wallet.address, CHILD_COURT), + "No locked stake in the child court" + ).to.deep.equal([ + 0, // totalStaked + locked, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); }; - await draw(setStake, "1", expectFromDraw); + await draw(stake, PARENT_COURT, expectFromDraw, unstake); }); it("Stakes in parent court and should draw nobody in subcourt", async () => { - const setStake = async (wallet: Wallet) => { - await core.connect(wallet).setStake(1, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); + const stake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(PARENT_COURT, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); }; const expectFromDraw = async (drawTx: Promise) => { - await expect(drawTx).to.not.emit(core, "Draw"); + expect(await core.getRoundInfo(0, 0).then((round) => round.drawIterations)).to.equal(20); + expect(await drawTx).to.not.emit(core, "Draw"); + }; + + const unstake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(PARENT_COURT, 0, { gasLimit: 5000000 }); + expect( + await core.getJurorBalance(wallet.address, PARENT_COURT), + "No locked stake in the parent court" + ).to.deep.equal([ + 0, // totalStaked + 0, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); + expect( + await core.getJurorBalance(wallet.address, CHILD_COURT), + "No locked stake in the child court" + ).to.deep.equal([ + 0, // totalStaked + 0, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); }; - await draw(setStake, "2", expectFromDraw); + await draw(stake, CHILD_COURT, expectFromDraw, unstake); }); it("Stakes in subcourt and should draw jurors in parent court", async () => { - const setStake = async (wallet: Wallet) => { - await core.connect(wallet).setStake(2, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); + const stake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(CHILD_COURT, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); }; - + let countedDraws: CountedDraws; const expectFromDraw = async (drawTx: Promise) => { - await expect(drawTx) + expect(await core.getRoundInfo(0, 0).then((round) => round.drawIterations)).to.equal(3); + + const tx = await (await drawTx).wait(); + expect(tx) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 0) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 1) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 2); + + countedDraws = await countDraws(tx.blockNumber); + for (const [address, draws] of Object.entries(countedDraws)) { + expect(await core.getJurorBalance(address, PARENT_COURT)).to.deep.equal([ + ONE_THOUSAND_PNK.mul(5), // totalStaked + parentCourtMinStake.mul(draws), // totalLocked + 0, // stakedInCourt + 1, // nbOfCourts + ]); + expect(await core.getJurorBalance(address, CHILD_COURT)).to.deep.equal([ + ONE_THOUSAND_PNK.mul(5), // totalStaked + parentCourtMinStake.mul(draws), // totalLocked + ONE_THOUSAND_PNK.mul(5), // stakedInCourt + 1, // nbOfCourts + ]); + } }; - await draw(setStake, "1", expectFromDraw); + const unstake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(CHILD_COURT, 0, { gasLimit: 5000000 }); + const locked = parentCourtMinStake.mul(countedDraws[wallet.address] ?? 0); + expect( + await core.getJurorBalance(wallet.address, PARENT_COURT), + "No locked stake in the parent court" + ).to.deep.equal([ + 0, // totalStaked + locked, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); + expect( + await core.getJurorBalance(wallet.address, CHILD_COURT), + "Drawn jurors have a locked stake in the child court" + ).to.deep.equal([ + 0, // totalStaked + locked, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); + }; + + await draw(stake, PARENT_COURT, expectFromDraw, unstake); }); it("Stakes in subcourt and should draw jurors in subcourt", async () => { - const setStake = async (wallet: Wallet) => { - await core.connect(wallet).setStake(2, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); + const stake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(CHILD_COURT, ONE_THOUSAND_PNK.mul(5), { gasLimit: 5000000 }); }; - + let countedDraws: CountedDraws; const expectFromDraw = async (drawTx: Promise) => { - await expect(drawTx) + expect(await core.getRoundInfo(0, 0).then((round) => round.drawIterations)).to.equal(3); + + const tx = await (await drawTx).wait(); + expect(tx) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 0) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 1) .to.emit(core, "Draw") .withArgs(anyValue, 0, 0, 2); + + countedDraws = await countDraws(tx.blockNumber); + for (const [address, draws] of Object.entries(countedDraws)) { + expect(await core.getJurorBalance(address, PARENT_COURT)).to.deep.equal([ + ONE_THOUSAND_PNK.mul(5), // totalStaked + childCourtMinStake.mul(draws), // totalLocked + 0, // stakedInCourt + 1, // nbOfCourts + ]); + expect(await core.getJurorBalance(address, CHILD_COURT)).to.deep.equal([ + ONE_THOUSAND_PNK.mul(5), // totalStaked + childCourtMinStake.mul(draws), // totalLocked + ONE_THOUSAND_PNK.mul(5), // stakedInCourt + 1, // nbOfCourts + ]); + } + }; + + const unstake = async (wallet: Wallet) => { + await core.connect(wallet).setStake(CHILD_COURT, 0, { gasLimit: 5000000 }); + const locked = childCourtMinStake.mul(countedDraws[wallet.address] ?? 0); + expect( + await core.getJurorBalance(wallet.address, PARENT_COURT), + "No locked stake in the parent court" + ).to.deep.equal([ + 0, // totalStaked + locked, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); + expect( + await core.getJurorBalance(wallet.address, CHILD_COURT), + "Drawn jurors have a locked stake in the child court" + ).to.deep.equal([ + 0, // totalStaked + locked, // totalLocked + 0, // stakedInCourt + 0, // nbOfCourts + ]); }; - await draw(setStake, "2", expectFromDraw); + await draw(stake, CHILD_COURT, expectFromDraw, unstake); }); }); diff --git a/contracts/test/integration/index.ts b/contracts/test/integration/index.ts index 86162b3ce..e04d39fa5 100644 --- a/contracts/test/integration/index.ts +++ b/contracts/test/integration/index.ts @@ -67,29 +67,29 @@ describe("Integration tests", async () => { await core.setStake(1, ONE_THOUSAND_PNK); await core.getJurorBalance(deployer, 1).then((result) => { - expect(result.staked).to.equal(ONE_THOUSAND_PNK); - expect(result.locked).to.equal(0); + expect(result.totalStaked).to.equal(ONE_THOUSAND_PNK); + expect(result.totalLocked).to.equal(0); logJurorBalance(result); }); await core.setStake(1, ONE_HUNDRED_PNK.mul(5)); await core.getJurorBalance(deployer, 1).then((result) => { - expect(result.staked).to.equal(ONE_HUNDRED_PNK.mul(5)); - expect(result.locked).to.equal(0); + expect(result.totalStaked).to.equal(ONE_HUNDRED_PNK.mul(5)); + expect(result.totalLocked).to.equal(0); logJurorBalance(result); }); await core.setStake(1, 0); await core.getJurorBalance(deployer, 1).then((result) => { - expect(result.staked).to.equal(0); - expect(result.locked).to.equal(0); + expect(result.totalStaked).to.equal(0); + expect(result.totalLocked).to.equal(0); logJurorBalance(result); }); await core.setStake(1, ONE_THOUSAND_PNK.mul(4)); await core.getJurorBalance(deployer, 1).then((result) => { - expect(result.staked).to.equal(ONE_THOUSAND_PNK.mul(4)); - expect(result.locked).to.equal(0); + expect(result.totalStaked).to.equal(ONE_THOUSAND_PNK.mul(4)); + expect(result.totalLocked).to.equal(0); logJurorBalance(result); }); const tx = await arbitrable.functions["createDispute(string)"]("future of france", { @@ -183,5 +183,9 @@ describe("Integration tests", async () => { }); const logJurorBalance = async (result) => { - console.log("staked=%s, locked=%s", ethers.utils.formatUnits(result.staked), ethers.utils.formatUnits(result.locked)); + console.log( + "staked=%s, locked=%s", + ethers.utils.formatUnits(result.totalStaked), + ethers.utils.formatUnits(result.totalLocked) + ); }; diff --git a/cspell.json b/cspell.json index 7049b6d51..1aa319e95 100644 --- a/cspell.json +++ b/cspell.json @@ -12,11 +12,14 @@ "arbitrum", "autorestart", "codegen", + "commify", "commitlint", + "consts", "COOLDOWN", "datetime", "devnet", "Devnet", + "DISPUTOR", "dockerhost", "Ethfinex", "gluegun", @@ -31,7 +34,10 @@ "repartitions", "solhint", "typechain", + "uncommify", "Unslashed", + "unstake", + "viem", "wagmi" ], "ignoreWords": [], diff --git a/scripts/cancel-all-netlify-builds.sh b/scripts/cancel-all-netlify-builds.sh new file mode 100755 index 000000000..f9f923069 --- /dev/null +++ b/scripts/cancel-all-netlify-builds.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +function cancelSiteDeploy() #sideId +{ + local sideId=$1 + readarray -t builds < <(netlify api listSiteDeploys -d '{ "site_id": "'$sideId'", "state": "new"}' | jq --compact-output '.[]') + for build in "${builds[@]}" + do + local name=$(jq -r .name <<< $build) + local branch=$(jq -r .branch <<< $build) + if [[ "$branch" == "dev" || "$branch" == "master" ]]; then + continue; + fi + echo "Cancelling build for $name $branch" + netlify api cancelSiteDeploy -d '{ "deploy_id": "'$(jq -r .id <<< $build)'"}' > /dev/null + done +} + +# netlify api listSites | jq '. | map([.name, .id])' +v2Site="86d94ae8-f655-46a4-a859-d68696173f3a" +v2ContractsSite="dd8bc215-e054-407f-92ef-d61511720928" + +cancelSiteDeploy $v2Site +cancelSiteDeploy $v2ContractsSite diff --git a/services/bots/testnet/bots.env.testnet.example b/services/bots/testnet/bots.env.testnet.example index 7ac85da62..5343b7eda 100644 --- a/services/bots/testnet/bots.env.testnet.example +++ b/services/bots/testnet/bots.env.testnet.example @@ -8,9 +8,11 @@ SUBGRAPH_URL=https://api.thegraph.com/subgraphs/name/alcercu/kleroscoretest LOG_LEVEL=debug LOGTAIL_TOKEN_KEEPER_BOT= LOGTAIL_TOKEN_RELAYER_BOT= +LOGTAIL_TOKEN_DISPUTOR_BOT= # Heartbeat HEARTBEAT_URL_KEEPER_BOT= HEARTBEAT_URL_RELAYER_BOT= +HEARTBEAT_URL_DISPUTOR_BOT= DISPUTES_TO_SKIP= \ No newline at end of file diff --git a/services/bots/testnet/compose.yml b/services/bots/testnet/compose.yml index af311a0ca..c7241a899 100644 --- a/services/bots/testnet/compose.yml +++ b/services/bots/testnet/compose.yml @@ -9,6 +9,16 @@ services: - type: bind source: ./pm2.config.keeper-bot.${DEPLOYMENT}.js target: /usr/src/app/contracts/ecosystem.config.js + + disputor-bot: + container_name: disputor-bot-${DEPLOYMENT:?error} + extends: + file: ../base/bot-pm2.yml + service: bot-pm2 + volumes: + - type: bind + source: ./pm2.config.disputor-bot.${DEPLOYMENT}.js + target: /usr/src/app/contracts/ecosystem.config.js relayer-bot-from-chiado: container_name: relayer-bot-from-chiado-${DEPLOYMENT:?error} diff --git a/services/bots/testnet/pm2.config.disputor-bot.testnet.js b/services/bots/testnet/pm2.config.disputor-bot.testnet.js new file mode 100644 index 000000000..185db5777 --- /dev/null +++ b/services/bots/testnet/pm2.config.disputor-bot.testnet.js @@ -0,0 +1,12 @@ +module.exports = { + apps: [ + { + name: "disputor-bot-testnet", + interpreter: "sh", + script: "yarn", + args: "bot:disputor --network arbitrumGoerli", + restart_delay: 43200000, // 12 hours + autorestart: true, + }, + ], +}; diff --git a/subgraph/package.json b/subgraph/package.json index cc9ca7499..7d70d40d7 100644 --- a/subgraph/package.json +++ b/subgraph/package.json @@ -5,6 +5,7 @@ "update:arbitrum-goerli": "./scripts/update.sh arbitrumGoerli arbitrum-goerli", "update:arbitrum-goerli-devnet": "./scripts/update.sh arbitrumGoerliDevnet arbitrum-goerli", "update:arbitrum": "./scripts/update.sh arbitrum arbitrum", + "update:local": "./scripts/update.sh localhost mainnet", "codegen": "graph codegen", "build": "graph build", "clean": "graph clean && rm subgraph.yaml.bak.*", @@ -14,7 +15,7 @@ "create-local": "graph create --node http://localhost:8020/ kleros/kleros-v2-core-local", "remove-local": "graph remove --node http://localhost:8020/ kleros/kleros-v2-core-local", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 kleros/kleros-v2-core-local --version-label v$(date +%s)", - "rebuild-deploy-local": "./scripts/update.sh localhost mainnet && yarn codegen && yarn create-local && yarn deploy-local", + "rebuild-deploy-local": "yarn update:local && yarn codegen && yarn create-local && yarn deploy-local", "start-local-indexer": "docker compose -f ../services/graph-node/docker-compose.yml up -d && docker compose -f ../services/graph-node/docker-compose.yml logs -f", "stop-local-indexer": "docker compose -f ../services/graph-node/docker-compose.yml down && rm -rf ../services/graph-node/data" }, diff --git a/subgraph/scripts/update.sh b/subgraph/scripts/update.sh index 980b70a3c..b36e7ed0b 100755 --- a/subgraph/scripts/update.sh +++ b/subgraph/scripts/update.sh @@ -2,18 +2,33 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -function update() #file #dataSourceIndex #graphNetwork +function update() #hardhatNetwork #graphNetwork #dataSourceIndex #contract { - local f="$1" - local dataSourceIndex="$2" - - graphNetwork=$3 yq -i ".dataSources[$dataSourceIndex].network=env(graphNetwork)" "$SCRIPT_DIR"/../subgraph.yaml + local hardhatNetwork="$1" + local graphNetwork="$2" + local dataSourceIndex="$3" + local contract="$4" + local artifact="$SCRIPT_DIR/../../contracts/deployments/$hardhatNetwork/$contract.json" + + # Set the address + address=$(cat "$artifact" | jq '.address') + yq -i ".dataSources[$dataSourceIndex].source.address=$address" "$SCRIPT_DIR"/../subgraph.yaml + + # Set the start block + blockNumber="$(cat "$artifact" | jq '.receipt.blockNumber')" + yq -i ".dataSources[$dataSourceIndex].source.startBlock=$blockNumber" "$SCRIPT_DIR"/../subgraph.yaml - address=$(cat "$f" | jq '.address') - yq -i ".dataSources[$dataSourceIndex].source.address=$address" "$SCRIPT_DIR"/../subgraph.yaml + # Set the Graph network + graphNetwork=$graphNetwork yq -i ".dataSources[$dataSourceIndex].network=env(graphNetwork)" "$SCRIPT_DIR"/../subgraph.yaml - blockNumber="$(cat "$f" | jq '.receipt.blockNumber')" - yq -i ".dataSources[$dataSourceIndex].source.startBlock=$blockNumber" "$SCRIPT_DIR"/../subgraph.yaml + # Set the ABIs path for this Hardhat network + abiIndex=0 + for f in $(yq e .dataSources[$dataSourceIndex].mapping.abis[].file subgraph.yaml -o json -I 0 | jq -sr '.[]') + do + f2=$(echo $f | sed "s|\(.*\/deployments\/\).*\/|\1$hardhatNetwork\/|") + yq -i ".dataSources[$dataSourceIndex].mapping.abis[$abiIndex].file=\"$f2\"" "$SCRIPT_DIR"/../subgraph.yaml + (( ++abiIndex )) + done } # as per ../contracts/hardhat.config.js @@ -28,6 +43,6 @@ cp "$SCRIPT_DIR"/../subgraph.yaml "$SCRIPT_DIR"/../subgraph.yaml.bak.$(date +%s) for contract in $(yq .dataSources[].name "$SCRIPT_DIR"/../subgraph.yaml) do - update "$SCRIPT_DIR/../../contracts/deployments/$hardhatNetwork/$contract.json" $i $graphNetwork + update $hardhatNetwork $graphNetwork $i $contract (( ++i )) done diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml index fd19eb740..04487470e 100644 --- a/subgraph/subgraph.yaml +++ b/subgraph/subgraph.yaml @@ -48,7 +48,7 @@ dataSources: handler: handleDisputeKitEnabled - event: StakeSet(indexed address,uint256,uint256) handler: handleStakeSet - - event: StakeDelayed(indexed address,uint256,uint256,uint256) + - event: StakeDelayed(indexed address,uint256,uint256) handler: handleStakeDelayed - event: TokenAndETHShift(indexed address,indexed uint256,indexed uint256,uint256,int256,int256,address) handler: handleTokenAndETHShift diff --git a/web/src/assets/svgs/header/header-darkmode-desktop.svg b/web/src/assets/svgs/header/header-darkmode-desktop.svg new file mode 100644 index 000000000..53ac43cab --- /dev/null +++ b/web/src/assets/svgs/header/header-darkmode-desktop.svg @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/svgs/header/header-darkmode-mobile.svg b/web/src/assets/svgs/header/header-darkmode-mobile.svg new file mode 100644 index 000000000..5e2f9c3df --- /dev/null +++ b/web/src/assets/svgs/header/header-darkmode-mobile.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/svgs/header/header-lightmode-desktop.svg b/web/src/assets/svgs/header/header-lightmode-desktop.svg new file mode 100644 index 000000000..c654a4710 --- /dev/null +++ b/web/src/assets/svgs/header/header-lightmode-desktop.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/web/src/assets/svgs/header/header-lightmode-mobile.svg b/web/src/assets/svgs/header/header-lightmode-mobile.svg new file mode 100644 index 000000000..a50f09da1 --- /dev/null +++ b/web/src/assets/svgs/header/header-lightmode-mobile.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/svgs/icons/round.svg b/web/src/assets/svgs/icons/round.svg new file mode 100644 index 000000000..3e36edfdd --- /dev/null +++ b/web/src/assets/svgs/icons/round.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/src/assets/svgs/icons/user.svg b/web/src/assets/svgs/icons/user.svg index e4444701e..23dc8988b 100644 --- a/web/src/assets/svgs/icons/user.svg +++ b/web/src/assets/svgs/icons/user.svg @@ -1,3 +1,3 @@ - + diff --git a/web/src/components/CasesDisplay/CasesGrid.tsx b/web/src/components/CasesDisplay/CasesGrid.tsx index 87113c915..4e2d22980 100644 --- a/web/src/components/CasesDisplay/CasesGrid.tsx +++ b/web/src/components/CasesDisplay/CasesGrid.tsx @@ -82,7 +82,6 @@ const CasesGrid: React.FC = ({ disputes, currentPage, setCurrentPage const totalPages = isDashboard ? calculatePages(statusFilter, userData, casesPerPage, numberDisputes ?? 0, userAppealCasesNumber) : calculatePages(statusFilter, counterData, casesPerPage, numberDisputes ?? 0); - return ( <> {!isUndefined(numberDisputes) && ( diff --git a/web/src/components/CasesDisplay/Stats.tsx b/web/src/components/CasesDisplay/Stats.tsx index 6b49ca70a..a3b04193e 100644 --- a/web/src/components/CasesDisplay/Stats.tsx +++ b/web/src/components/CasesDisplay/Stats.tsx @@ -6,6 +6,11 @@ const FieldWrapper = styled.div` gap: 8px; `; +const SeparatorLabel = styled.label` + margin-left: 8px; + margin-right: 8px; +`; + const Field: React.FC<{ label: string; value: string }> = ({ label, value }) => ( @@ -13,11 +18,6 @@ const Field: React.FC<{ label: string; value: string }> = ({ label, value }) => ); -const SeparatorLabel = styled.label` - margin-left: 8px; - margin-right: 8px; -`; - const Separator: React.FC = () => |; export interface IStats { diff --git a/web/src/components/ConnectWallet/AccountDisplay.tsx b/web/src/components/ConnectWallet/AccountDisplay.tsx index 0fb33516d..d64cdca5d 100644 --- a/web/src/components/ConnectWallet/AccountDisplay.tsx +++ b/web/src/components/ConnectWallet/AccountDisplay.tsx @@ -6,10 +6,9 @@ import { shortenAddress } from "utils/shortenAddress"; const Container = styled.div` display: flex; - flex-direction: row; - justify-content: space-between; - align-content: center; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: 8px; `; const AccountContainer = styled.div` diff --git a/web/src/components/ConnectWallet/index.tsx b/web/src/components/ConnectWallet/index.tsx index 89e44bd03..59c070d32 100644 --- a/web/src/components/ConnectWallet/index.tsx +++ b/web/src/components/ConnectWallet/index.tsx @@ -1,9 +1,19 @@ import React from "react"; +import styled from "styled-components"; import { useAccount, useNetwork, useSwitchNetwork } from "wagmi"; import { useWeb3Modal } from "@web3modal/react"; import { Button } from "@kleros/ui-components-library"; import { SUPPORTED_CHAINS, DEFAULT_CHAIN } from "consts/chains"; import AccountDisplay from "./AccountDisplay"; +import { DisconnectWalletButton } from "layout/Header/navbar/Menu/Settings/General"; + +const Container = styled.div` + display: flex; + gap: 16px; + justify-content: space-between; + flex-wrap: wrap; + align-items: center; +`; export const SwitchChainButton: React.FC = () => { const { switchNetwork, isLoading } = useSwitchNetwork(); @@ -22,7 +32,7 @@ export const SwitchChainButton: React.FC = () => { ); @@ -39,7 +49,13 @@ const ConnectWallet: React.FC = () => { if (isConnected) { if (chain && chain.id !== DEFAULT_CHAIN) { return ; - } else return ; + } else + return ( + + + + + ); } else return ; }; diff --git a/web/src/components/DisputeCard/DisputeInfo.tsx b/web/src/components/DisputeCard/DisputeInfo.tsx index 0b3568097..dc36b524f 100644 --- a/web/src/components/DisputeCard/DisputeInfo.tsx +++ b/web/src/components/DisputeCard/DisputeInfo.tsx @@ -5,6 +5,7 @@ import BookmarkIcon from "svgs/icons/bookmark.svg"; import CalendarIcon from "svgs/icons/calendar.svg"; import LawBalanceIcon from "svgs/icons/law-balance.svg"; import PileCoinsIcon from "svgs/icons/pile-coins.svg"; +import RoundIcon from "svgs/icons/round.svg"; import Field from "../Field"; const Container = styled.div` @@ -33,13 +34,15 @@ export interface IDisputeInfo { rewards?: string; period?: Periods; date?: number; + round?: number; } -const DisputeInfo: React.FC = ({ courtId, court, category, rewards, period, date }) => { +const DisputeInfo: React.FC = ({ courtId, court, category, rewards, period, date, round }) => { return ( - {category && } {court && courtId && } + {category && } + {round && } {rewards && } {typeof period !== "undefined" && date && ( diff --git a/web/src/components/DisputeCard/PeriodBanner.tsx b/web/src/components/DisputeCard/PeriodBanner.tsx index 0990cf47c..fb8dd3e0a 100644 --- a/web/src/components/DisputeCard/PeriodBanner.tsx +++ b/web/src/components/DisputeCard/PeriodBanner.tsx @@ -2,22 +2,6 @@ import React from "react"; import styled, { Theme } from "styled-components"; import { Periods } from "consts/periods"; -export interface IPeriodBanner { - id: number; - period: Periods; -} - -const getPeriodColors = (period: Periods, theme: Theme): [string, string] => { - switch (period) { - case Periods.appeal: - return [theme.tint, theme.tintMedium]; - case Periods.execution: - return [theme.secondaryPurple, theme.mediumPurple]; - default: - return [theme.primaryBlue, theme.mediumBlue]; - } -}; - const Container = styled.div>` height: 45px; width: auto; @@ -54,6 +38,22 @@ const Container = styled.div>` }}; `; +export interface IPeriodBanner { + id: number; + period: Periods; +} + +const getPeriodColors = (period: Periods, theme: Theme): [string, string] => { + switch (period) { + case Periods.appeal: + return [theme.tint, theme.tintMedium]; + case Periods.execution: + return [theme.secondaryPurple, theme.mediumPurple]; + default: + return [theme.primaryBlue, theme.mediumBlue]; + } +}; + const getPeriodLabel = (period: Periods): string => { switch (period) { case Periods.appeal: diff --git a/web/src/components/DisputeCard/index.tsx b/web/src/components/DisputeCard/index.tsx index 474c0feec..bf6392447 100644 --- a/web/src/components/DisputeCard/index.tsx +++ b/web/src/components/DisputeCard/index.tsx @@ -2,7 +2,7 @@ import React from "react"; import styled from "styled-components"; import { useNavigate } from "react-router-dom"; import { formatEther } from "viem"; -import Skeleton from "react-loading-skeleton"; +import { StyledSkeleton } from "components/StyledSkeleton"; import { Card } from "@kleros/ui-components-library"; import { Periods } from "consts/periods"; import { DisputeDetailsFragment } from "queries/useCasesQuery"; @@ -11,6 +11,7 @@ import { useDisputeTemplate } from "queries/useDisputeTemplate"; import DisputeInfo from "./DisputeInfo"; import PeriodBanner from "./PeriodBanner"; import { isUndefined } from "utils/index"; +import { useVotingHistory } from "hooks/queries/useVotingHistory"; const StyledCard = styled(Card)` max-width: 380px; @@ -48,13 +49,15 @@ const DisputeCard: React.FC = ({ id, arbitrated, period, : getPeriodEndTimestamp(lastPeriodChange, currentPeriodIndex, court.timesPerPeriod); const { data: disputeTemplate } = useDisputeTemplate(id, arbitrated.id as `0x${string}`); const title = isUndefined(disputeTemplate) ? ( - + ) : ( disputeTemplate?.title ?? "The dispute's template is not correct please vote refuse to arbitrate" ); const { data: courtPolicy } = useCourtPolicy(court.id); const courtName = courtPolicy?.name; const category = disputeTemplate ? disputeTemplate.category : undefined; + const { data: votingHistory } = useVotingHistory(id); + const localRounds = votingHistory?.dispute?.disputeKitDispute?.localRounds; const navigate = useNavigate(); return ( navigate(`/cases/${id.toString()}`)}> @@ -65,6 +68,7 @@ const DisputeCard: React.FC = ({ id, arbitrated, period, courtId={court?.id} court={courtName} period={currentPeriodIndex} + round={localRounds?.length} {...{ category, rewards, date }} /> diff --git a/web/src/components/EvidenceCard.tsx b/web/src/components/EvidenceCard.tsx index 4fffba6da..44a1cabeb 100644 --- a/web/src/components/EvidenceCard.tsx +++ b/web/src/components/EvidenceCard.tsx @@ -7,40 +7,6 @@ import { useIPFSQuery } from "hooks/useIPFSQuery"; import { shortenAddress } from "utils/shortenAddress"; import { IPFS_GATEWAY } from "consts/index"; -interface IEvidenceCard { - evidence: string; - sender: string; - index: number; -} - -const EvidenceCard: React.FC = ({ evidence, sender, index }) => { - const { data } = useIPFSQuery(evidence.at(0) === "/" ? evidence : undefined); - return ( - - - #{index}: - {data ? ( - <> -

{data.name}

-

{data.description}

- - ) : ( -

{evidence}

- )} - - - -

{shortenAddress(sender)}

- {data && typeof data.fileURI !== "undefined" && ( - - - - )} -
- - ); -}; - const StyledCard = styled(Card)` width: 100%; height: auto; @@ -85,4 +51,38 @@ const StyledA = styled.a` } `; +interface IEvidenceCard { + evidence: string; + sender: string; + index: number; +} + +const EvidenceCard: React.FC = ({ evidence, sender, index }) => { + const { data } = useIPFSQuery(evidence.at(0) === "/" ? evidence : undefined); + return ( + + + #{index}: + {data ? ( + <> +

{data.name}

+

{data.description}

+ + ) : ( +

{evidence}

+ )} +
+ + +

{shortenAddress(sender)}

+ {data && typeof data.fileURI !== "undefined" && ( + + + + )} +
+
+ ); +}; + export default EvidenceCard; diff --git a/web/src/components/Field.tsx b/web/src/components/Field.tsx index 7bdecf0d4..af7b09df8 100644 --- a/web/src/components/Field.tsx +++ b/web/src/components/Field.tsx @@ -2,6 +2,33 @@ import React from "react"; import { Link } from "react-router-dom"; import styled from "styled-components"; +const FieldContainer = styled.div` + width: ${({ width = "100%" }) => width}; + display: flex; + align-items: center; + justify-content: flex-start; + .value { + flex-grow: 1; + text-align: end; + color: ${({ theme }) => theme.primaryText}; + } + svg { + fill: ${({ theme }) => theme.secondaryPurple}; + margin-right: 8px; + width: 15px; + } + .link { + color: ${({ theme }) => theme.primaryBlue}; + :hover { + cursor: pointer; + } + } +`; + +type FieldContainerProps = { + width?: string; +}; + interface IField { icon: React.FunctionComponent>; name: string; @@ -10,10 +37,6 @@ interface IField { width?: string; } -type FieldContainerProps = { - width?: string; -}; - const Field: React.FC = ({ icon: Icon, name, value, link, width }) => ( {} @@ -29,26 +52,3 @@ const Field: React.FC = ({ icon: Icon, name, value, link, width }) => ( ); export default Field; - -const FieldContainer = styled.div` - width: ${({ width = "100%" }) => width}; - display: flex; - align-items: center; - justify-content: flex-start; - .value { - flex-grow: 1; - text-align: end; - color: ${({ theme }) => theme.primaryText}; - } - svg { - fill: ${({ theme }) => theme.secondaryPurple}; - margin-right: 8px; - width: 15px; - } - .link { - color: ${({ theme }) => theme.primaryBlue}; - :hover { - cursor: pointer; - } - } -`; diff --git a/web/src/components/NumberInputField.tsx b/web/src/components/NumberInputField.tsx new file mode 100644 index 000000000..8cd4b627f --- /dev/null +++ b/web/src/components/NumberInputField.tsx @@ -0,0 +1,57 @@ +import React, { useState } from "react"; +import styled from "styled-components"; +import { Field } from "@kleros/ui-components-library"; + +const Container = styled.div` + width: 100%; + height: fit-content; +`; + +const StyledField = styled(Field)` + width: 100%; + height: fit-content; +`; + +interface INumberInputField { + placeholder?: string; + message?: string; + value?: string; + onChange?: (value: string) => void; + formatter?: (value: string) => string; +} + +export const NumberInputField: React.FC = ({ placeholder, message, value, onChange, formatter }) => { + const [isEditing, setIsEditing] = useState(false); + + const toggleEditing = () => { + setIsEditing(!isEditing); + }; + + return ( + + {isEditing ? ( + ) => { + onChange?.(event.target.value); + }} + placeholder={placeholder} + message={message} + variant="info" + onBlur={toggleEditing} + /> + ) : ( + + )} + + ); +}; diff --git a/web/src/components/Popup/index.tsx b/web/src/components/Popup/index.tsx index 313f5db1b..a289afe1c 100644 --- a/web/src/components/Popup/index.tsx +++ b/web/src/components/Popup/index.tsx @@ -9,47 +9,6 @@ import Appeal from "./Description/Appeal"; import VoteWithCommitExtraInfo from "./ExtraInfo/VoteWithCommitExtraInfo"; import StakeWithdrawExtraInfo from "./ExtraInfo/StakeWithdrawExtraInfo"; -export enum PopupType { - STAKE_WITHDRAW = "STAKE_WITHDRAW", - APPEAL = "APPEAL", - VOTE_WITHOUT_COMMIT = "VOTE_WITHOUT_COMMIT", - VOTE_WITH_COMMIT = "VOTE_WITH_COMMIT", -} - -interface IStakeWithdraw { - popupType: PopupType.STAKE_WITHDRAW; - pnkStaked: string; - courtName: string; - isStake: boolean; - courtId: string; -} - -interface IVoteWithoutCommit { - popupType: PopupType.VOTE_WITHOUT_COMMIT; - date: string; -} - -interface IVoteWithCommit { - popupType: PopupType.VOTE_WITH_COMMIT; - date: string; -} - -interface IAppeal { - popupType: PopupType.APPEAL; - amount: string; - option: string; -} -interface IPopup { - title: string; - icon: React.FC>; - popupType: PopupType; - setIsOpen: (val: boolean) => void; - setAmount?: (val: string) => void; - isCommit?: boolean; -} - -type PopupProps = IStakeWithdraw | IVoteWithoutCommit | IVoteWithCommit | IAppeal; - const Header = styled.h1` display: flex; margin-top: calc(12px + (32 - 12) * ((100vw - 375px) / (1250 - 375))); @@ -120,6 +79,47 @@ export const VoteDescriptionEmphasizedDate = styled.span` color: ${({ theme }) => theme.primaryText}; `; +export enum PopupType { + STAKE_WITHDRAW = "STAKE_WITHDRAW", + APPEAL = "APPEAL", + VOTE_WITHOUT_COMMIT = "VOTE_WITHOUT_COMMIT", + VOTE_WITH_COMMIT = "VOTE_WITH_COMMIT", +} + +interface IStakeWithdraw { + popupType: PopupType.STAKE_WITHDRAW; + pnkStaked: string; + courtName: string; + isStake: boolean; + courtId: string; +} + +interface IVoteWithoutCommit { + popupType: PopupType.VOTE_WITHOUT_COMMIT; + date: string; +} + +interface IVoteWithCommit { + popupType: PopupType.VOTE_WITH_COMMIT; + date: string; +} + +interface IAppeal { + popupType: PopupType.APPEAL; + amount: string; + option: string; +} +interface IPopup { + title: string; + icon: React.FC>; + popupType: PopupType; + setIsOpen: (val: boolean) => void; + setAmount?: (val: string) => void; + isCommit?: boolean; +} + +type PopupProps = IStakeWithdraw | IVoteWithoutCommit | IVoteWithCommit | IAppeal; + const Popup: React.FC = ({ title, icon: Icon, diff --git a/web/src/components/StatDisplay.tsx b/web/src/components/StatDisplay.tsx index 767d3a8b1..43c929663 100644 --- a/web/src/components/StatDisplay.tsx +++ b/web/src/components/StatDisplay.tsx @@ -1,11 +1,6 @@ import React from "react"; import styled, { useTheme } from "styled-components"; -const createPair = (iconColor: string, backgroundColor: string) => ({ - iconColor, - backgroundColor, -}); - const Container = styled.div` display: flex; align-items: center; @@ -20,11 +15,10 @@ const SVGContainer = styled.div<{ iconColor: string; backgroundColor: string }>` display: flex; align-items: center; justify-content: center; - svg { fill: ${({ iconColor }) => iconColor}; - height: 32px; - width: 32px; + height: ${({ iconColor, theme }) => (iconColor === theme.success ? "24px" : "32px")}; + width: ${({ iconColor, theme }) => (iconColor === theme.success ? "24px" : "32px")}; } `; @@ -34,10 +28,15 @@ const TextContainer = styled.div` } `; +const createPair = (iconColor: string, backgroundColor: string) => ({ + iconColor, + backgroundColor, +}); + export interface IStatDisplay { title: string; - text: string; - subtext: string; + text: string | React.ReactNode; + subtext: string | React.ReactNode; icon: React.FunctionComponent>; color: "red" | "orange" | "green" | "blue" | "purple"; } @@ -51,6 +50,7 @@ const StatDisplay: React.FC = ({ title, text, subtext, icon: Icon, blue: createPair(theme.primaryBlue, theme.mediumBlue), purple: createPair(theme.secondaryPurple, theme.mediumPurple), }; + return ( {} diff --git a/web/src/components/StyledSkeleton.tsx b/web/src/components/StyledSkeleton.tsx new file mode 100644 index 000000000..f49bf8c14 --- /dev/null +++ b/web/src/components/StyledSkeleton.tsx @@ -0,0 +1,6 @@ +import styled from "styled-components"; +import Skeleton from "react-loading-skeleton"; + +export const StyledSkeleton = styled(Skeleton)` + z-index: 0; +`; diff --git a/web/src/components/Verdict/DisputeTimeline.tsx b/web/src/components/Verdict/DisputeTimeline.tsx index a6c3b1812..c2efa61e1 100644 --- a/web/src/components/Verdict/DisputeTimeline.tsx +++ b/web/src/components/Verdict/DisputeTimeline.tsx @@ -80,7 +80,7 @@ const useItems = (disputeDetails?: DisputeDetailsQuery, arbitrable?: `0x${string return localRounds?.reduce( (acc, { winningChoice }, index) => { const parsedRoundChoice = parseInt(winningChoice); - const isOngoing = index === localRounds.length - 1 && currentPeriodIndex < 4; + const isOngoing = index === localRounds.length - 1 && currentPeriodIndex < 3; const eventDate = getCaseEventTimes(lastPeriodChange, currentPeriodIndex, courtTimePeriods, false); const icon = dispute.ruled && !rulingOverride && index === localRounds.length - 1 ? ClosedCaseIcon : ""; const answers = disputeTemplate?.answers; diff --git a/web/src/consts/chains.ts b/web/src/consts/chains.ts index b9e4e4437..63c7ab82c 100644 --- a/web/src/consts/chains.ts +++ b/web/src/consts/chains.ts @@ -1,23 +1,13 @@ -export const SUPPORTED_CHAINS = { - 421613: { - chainName: "Arbitrum Goerli", - nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 }, - rpcUrls: ["https://goerli-rollup.arbitrum.io/rpc"], - blockExplorerUrls: ["https://goerli.arbiscan.io/"], - }, -}; - -export const DEFAULT_CHAIN = 421613; +import { Chain, arbitrumGoerli, gnosisChiado } from "@wagmi/chains"; -export const SUPPORTED_CHAINIDS = Object.keys(SUPPORTED_CHAINS).map((x) => parseInt(x)); +export const DEFAULT_CHAIN = arbitrumGoerli.id; -export const QUERY_CHAINS = { - 10200: { - chainName: "Chiado Testnet", - nativeCurrency: { name: "xDAI", symbol: "xDAI", decimals: 18 }, - rpcUrls: ["https://rpc.eu-central-2.gateway.fm/v3/gnosis/archival/chiado"], - blockExplorerUrls: ["https://blockscout.chiadochain.net"], - }, +export const SUPPORTED_CHAINS: Record = { + [arbitrumGoerli.id]: arbitrumGoerli, }; +export const SUPPORTED_CHAIN_IDS = Object.keys(SUPPORTED_CHAINS); -export const QUERY_CHAINIDS = Object.keys(QUERY_CHAINS).map((x) => parseInt(x)); +export const QUERY_CHAINS: Record = { + [gnosisChiado.id]: gnosisChiado, +}; +export const QUERY_CHAIN_IDS = Object.keys(QUERY_CHAINS); diff --git a/web/src/consts/coingecko.ts b/web/src/consts/coingecko.ts new file mode 100644 index 000000000..af17fbc59 --- /dev/null +++ b/web/src/consts/coingecko.ts @@ -0,0 +1,5 @@ +// https://apiguide.coingecko.com/getting-started/10-min-tutorial-guide/1-get-data-by-id-or-address +export const CoinIds = { + ETH: "coingecko:ethereum", + PNK: "coingecko:kleros", +}; diff --git a/web/src/consts/index.ts b/web/src/consts/index.ts index 551c10b0f..a6e7f4a90 100644 --- a/web/src/consts/index.ts +++ b/web/src/consts/index.ts @@ -2,10 +2,6 @@ import { version, gitCommitHash, gitCommitShortHash, gitBranch, gitTags, clean } export const ONE_BASIS_POINT = 10000n; -export const KLEROS_CONTRACT_ADDRESS = "ethereum:0x93ed3fbe21207ec2e8f2d3c3de6e058cb73bc04d"; -export const WETH_CONTRACT_ADDRESS = "ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; -export const PNK_FAUCET_CONTRACT_ADDRESS = "0x05648Ee14941630a649082e0dA5cb80D29cC9002"; - export const IPFS_GATEWAY = process.env.REACT_APP_IPFS_GATEWAY || "https://cdn.kleros.link"; export const GIT_BRANCH = gitBranch; diff --git a/web/src/hooks/useContractAddress.tsx b/web/src/hooks/useContractAddress.tsx new file mode 100644 index 000000000..474ce3e78 --- /dev/null +++ b/web/src/hooks/useContractAddress.tsx @@ -0,0 +1,27 @@ +import { Abi, PublicClient } from "viem"; +import { usePublicClient } from "wagmi"; +import { GetContractArgs, GetContractResult } from "wagmi/actions"; +import { getPinakionV2, pinakionV2ABI, getWeth, getPnkFaucet, wethABI, pnkFaucetABI } from "./contracts/generated"; + +type Config = Omit, "abi" | "address"> & { + chainId?: any; +}; + +export const useContractAddress = ( + getter: (c: Config) => GetContractResult +): GetContractResult => { + const publicClient = usePublicClient(); + return getter({ walletClient: publicClient }); +}; + +export const usePNKAddress = () => { + return useContractAddress(getPinakionV2)?.address; +}; + +export const useWETHAddress = () => { + return useContractAddress(getWeth)?.address; +}; + +export const usePNKFaucetAddress = () => { + return useContractAddress(getPnkFaucet)?.address; +}; diff --git a/web/src/layout/Header/navbar/DappList.tsx b/web/src/layout/Header/navbar/DappList.tsx index b0474da1e..81c69a38b 100644 --- a/web/src/layout/Header/navbar/DappList.tsx +++ b/web/src/layout/Header/navbar/DappList.tsx @@ -12,6 +12,52 @@ import Tokens from "svgs/icons/tokens.svg"; import Product from "./Product"; import { Overlay } from "components/Overlay"; +const Header = styled.h1` + display: flex; + padding-top: 32px; + padding-bottom: 20px; + font-size: 24px; + font-weight: 600; + line-height: 32.68px; +`; + +const Container = styled.div` + display: flex; + position: absolute; + max-height: 60vh; + top: 5%; + left: 50%; + transform: translate(-50%); + z-index: 10; + flex-direction: column; + align-items: center; + + width: calc(300px + (480 - 300) * (100vw - 375px) / (1250 - 375)); + max-width: 480px; + min-width: 300px; + border-radius: 3px; + border: 1px solid ${({ theme }) => theme.stroke}; + background-color: ${({ theme }) => theme.whiteBackground}; + box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.06); + + svg { + visibility: visible; + } +`; + +const ItemsDiv = styled.div` + display: grid; + overflow-y: auto; + padding: 16px calc(8px + (24 - 8) * ((100vw - 480px) / (1250 - 480))); + row-gap: 8px; + column-gap: 2px; + justify-items: center; + max-width: 480px; + min-width: 300px; + width: calc(300px + (480 - 300) * (100vw - 375px) / (1250 - 375)); + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); +`; + interface IDappList { toggleSolution: () => void; } @@ -59,52 +105,6 @@ const ITEMS = [ }, ]; -const Header = styled.h1` - display: flex; - padding-top: 32px; - padding-bottom: 20px; - font-size: 24px; - font-weight: 600; - line-height: 32.68px; -`; - -const Container = styled.div` - display: flex; - position: absolute; - max-height: 60vh; - top: 5%; - left: 50%; - transform: translate(-50%); - z-index: 10; - flex-direction: column; - align-items: center; - - width: calc(300px + (480 - 300) * (100vw - 375px) / (1250 - 375)); - max-width: 480px; - min-width: 300px; - border-radius: 3px; - border: 1px solid ${({ theme }) => theme.stroke}; - background-color: ${({ theme }) => theme.whiteBackground}; - box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.06); - - svg { - visibility: visible; - } -`; - -const ItemsDiv = styled.div` - display: grid; - overflow-y: auto; - padding: 16px calc(8px + (24 - 8) * ((100vw - 480px) / (1250 - 480))); - row-gap: 8px; - column-gap: 2px; - justify-items: center; - max-width: 480px; - min-width: 300px; - width: calc(300px + (480 - 300) * (100vw - 375px) / (1250 - 375)); - grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); -`; - const DappList: React.FC = ({ toggleSolution }) => { const containerRef = useRef(null); useFocusOutside(containerRef, () => { diff --git a/web/src/layout/Header/navbar/Menu/Help.tsx b/web/src/layout/Header/navbar/Menu/Help.tsx index f8d01eab0..1559d6b68 100644 --- a/web/src/layout/Header/navbar/Menu/Help.tsx +++ b/web/src/layout/Header/navbar/Menu/Help.tsx @@ -9,43 +9,6 @@ import Faq from "svgs/menu-icons/help.svg"; import Telegram from "svgs/socialmedia/telegram.svg"; import { Overlay } from "components/Overlay"; -const ITEMS = [ - { - text: "Onboarding", - Icon: Book, - url: "", - }, - { - text: "Get Help", - Icon: Telegram, - url: "https://t.me/kleros", - }, - { - text: "Report a Bug", - Icon: Bug, - url: "https://github.com/kleros/kleros-v2/issues", - }, - { - text: "DApp Guide", - Icon: Guide, - url: "https://docs.kleros.io/products/court-v2", - }, - { - text: "Crypto Beginner's Guide", - Icon: ETH, - url: "https://ethereum.org/en/wallets/", - }, - { - text: "FAQ", - Icon: Faq, - url: "https://docs.kleros.io/kleros-faq", - }, -]; - -interface IHelp { - toggle: () => void; -} - const Container = styled.div` display: flex; flex-direction: column; @@ -86,6 +49,43 @@ const Icon = styled.svg` fill: ${({ theme }) => theme.secondaryPurple}; `; +const ITEMS = [ + { + text: "Onboarding", + Icon: Book, + url: "", + }, + { + text: "Get Help", + Icon: Telegram, + url: "https://t.me/kleros", + }, + { + text: "Report a Bug", + Icon: Bug, + url: "https://github.com/kleros/kleros-v2/issues", + }, + { + text: "DApp Guide", + Icon: Guide, + url: "https://docs.kleros.io/products/court-v2", + }, + { + text: "Crypto Beginner's Guide", + Icon: ETH, + url: "https://ethereum.org/en/wallets/", + }, + { + text: "FAQ", + Icon: Faq, + url: "https://docs.kleros.io/kleros-faq", + }, +]; + +interface IHelp { + toggle: () => void; +} + const Help: React.FC = ({ toggle }) => { const containerRef = useRef(null); useFocusOutside(containerRef, () => { diff --git a/web/src/layout/Header/navbar/Product.tsx b/web/src/layout/Header/navbar/Product.tsx index 95f05deab..1fa47844c 100644 --- a/web/src/layout/Header/navbar/Product.tsx +++ b/web/src/layout/Header/navbar/Product.tsx @@ -1,12 +1,6 @@ import React from "react"; import styled from "styled-components"; -interface IProduct { - text: string; - url: string; - Icon: React.FC> | string; -} - const Container = styled.a` cursor: pointer; display: flex; @@ -54,6 +48,12 @@ const StyledImg = styled.img` max-height: 48px; `; +interface IProduct { + text: string; + url: string; + Icon: React.FC> | string; +} + const Product: React.FC = ({ text, url, Icon }) => { return ( diff --git a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Fund.tsx b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Fund.tsx index 9c522b952..2645336ea 100644 --- a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Fund.tsx +++ b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Fund.tsx @@ -15,6 +15,26 @@ import { useFundingContext, } from "hooks/useClassicAppealContext"; +const StyledField = styled(Field)` + width: 100%; + & > input { + text-align: center; + } + &:before { + position: absolute; + content: "ETH"; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: ${({ theme }) => theme.primaryText}; + } +`; + +const StyledButton = styled(Button)` + margin: auto; + margin-top: 12px; +`; + const useNeedFund = () => { const loserSideCountdown = useLoserSideCountdownContext(); const { fundedChoices, winningChoice } = useFundingContext(); @@ -102,24 +122,4 @@ const Fund: React.FC = ({ amount, setAmount, setIsOpen }) => { ); }; -const StyledField = styled(Field)` - width: 100%; - & > input { - text-align: center; - } - &:before { - position: absolute; - content: "ETH"; - right: 12px; - top: 50%; - transform: translateY(-50%); - color: ${({ theme }) => theme.primaryText}; - } -`; - -const StyledButton = styled(Button)` - margin: auto; - margin-top: 12px; -`; - export default Fund; diff --git a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageOne.tsx b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageOne.tsx index 746137ddc..805ec3244 100644 --- a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageOne.tsx +++ b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageOne.tsx @@ -9,6 +9,17 @@ import { useSelectedOptionContext, } from "hooks/useClassicAppealContext"; +const Container = styled.div` + margin: 24px 0; +`; + +const OptionsContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 24px; + margin-top: 12px; +`; + const StageOne: React.FC = () => { const { paidFees, winningChoice, loserRequiredFunding, winnerRequiredFunding } = useFundingContext(); const options = useOptionsContext(); @@ -38,15 +49,4 @@ const StageOne: React.FC = () => { ); }; -const Container = styled.div` - margin: 24px 0; -`; - -const OptionsContainer = styled.div` - display: flex; - flex-wrap: wrap; - gap: 24px; - margin-top: 12px; -`; - export default StageOne; diff --git a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageTwo.tsx b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageTwo.tsx index 4fb7f1ca9..6be1c1485 100644 --- a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageTwo.tsx +++ b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/StageTwo.tsx @@ -4,6 +4,17 @@ import OptionCard from "../../OptionCard"; import { useFundingContext, useOptionsContext, useSelectedOptionContext } from "hooks/useClassicAppealContext"; import { isUndefined } from "utils/index"; +const Container = styled.div` + margin: 24px 0; +`; + +const OptionsContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 24px; + margin-top: 12px; +`; + const StageOne: React.FC = () => { const { paidFees, winningChoice, winnerRequiredFunding, fundedChoices } = useFundingContext(); const options = useOptionsContext(); @@ -39,15 +50,4 @@ const StageOne: React.FC = () => { ); }; -const Container = styled.div` - margin: 24px 0; -`; - -const OptionsContainer = styled.div` - display: flex; - flex-wrap: wrap; - gap: 24px; - margin-top: 12px; -`; - export default StageOne; diff --git a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/index.tsx b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/index.tsx index 1d99325e2..83219b2c1 100644 --- a/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/index.tsx +++ b/web/src/pages/Cases/CaseDetails/Appeal/Classic/Options/index.tsx @@ -1,21 +1,22 @@ import React from "react"; import styled from "styled-components"; import { useLoserSideCountdownContext } from "hooks/useClassicAppealContext"; +import { StyledSkeleton } from "components/StyledSkeleton"; import StageOne from "./StageOne"; import StageTwo from "./StageTwo"; import { isUndefined } from "utils/index"; +const Container = styled.div` + margin: 24px 0; +`; + const Options: React.FC = () => { const loserSideCountdown = useLoserSideCountdownContext(); return !isUndefined(loserSideCountdown) ? ( {loserSideCountdown > 0 ? : } ) : ( -

Loading...

+ ); }; -const Container = styled.div` - margin: 24px 0; -`; - export default Options; diff --git a/web/src/pages/Cases/CaseDetails/Appeal/Classic/StageExplainer.tsx b/web/src/pages/Cases/CaseDetails/Appeal/Classic/StageExplainer.tsx index d3d2651b8..123838747 100644 --- a/web/src/pages/Cases/CaseDetails/Appeal/Classic/StageExplainer.tsx +++ b/web/src/pages/Cases/CaseDetails/Appeal/Classic/StageExplainer.tsx @@ -4,29 +4,6 @@ import { Box } from "@kleros/ui-components-library"; import { secondsToDayHourMinute } from "utils/date"; import HourglassIcon from "svgs/icons/hourglass.svg"; -interface IStageExplainer { - loserSideCountdown: number | undefined; -} - -const StageExplainer: React.FC = ({ loserSideCountdown }) => ( - - - - {typeof loserSideCountdown !== "undefined" && - secondsToDayHourMinute(loserSideCountdown)} - -
- - -
-
-); - const StyledBox = styled(Box)` border-radius: 3px; margin: 24px 0; @@ -52,4 +29,25 @@ const CountdownLabel = styled.label` } `; +interface IStageExplainer { + loserSideCountdown: number | undefined; +} + +const StageExplainer: React.FC = ({ loserSideCountdown }) => ( + + + + {typeof loserSideCountdown !== "undefined" && secondsToDayHourMinute(loserSideCountdown)} + +
+ + +
+
+); + export default StageExplainer; diff --git a/web/src/pages/Cases/CaseDetails/Appeal/OptionCard.tsx b/web/src/pages/Cases/CaseDetails/Appeal/OptionCard.tsx index 10f6a8ee7..df23ed64c 100644 --- a/web/src/pages/Cases/CaseDetails/Appeal/OptionCard.tsx +++ b/web/src/pages/Cases/CaseDetails/Appeal/OptionCard.tsx @@ -6,6 +6,45 @@ import Gavel from "svgs/icons/gavel.svg"; import { formatEther } from "viem"; import { isUndefined } from "utils/index"; +const StyledCard = styled(Card)` + width: 100%; + padding: 24px; + &:hover { + cursor: pointer; + } +`; + +const WinnerLabel = styled.label<{ winner: boolean }>` + color: ${({ theme, winner }) => (winner ? theme.success : theme.warning)}; + svg { + width: 12px; + margin-right: 4px; + fill: ${({ theme, winner }) => (winner ? theme.success : theme.warning)}; + } +`; + +const StyledRadio = styled(Radio)` + padding-left: 24px; + > input { + display: none; + } +`; + +const TopContainer = styled.div` + display: flex; + justify-content: space-between; + height: 50%; + .block { + display: block; + } +`; + +const LabelContainer = styled.div` + width: 100%; + display: flex; + justify-content: center; +`; + interface IOptionCard extends React.HTMLAttributes { text: string; funding: bigint; @@ -56,43 +95,4 @@ const OptionCard: React.FC = ({ ); }; -const StyledCard = styled(Card)` - width: 100%; - padding: 24px; - &:hover { - cursor: pointer; - } -`; - -const WinnerLabel = styled.label<{ winner: boolean }>` - color: ${({ theme, winner }) => (winner ? theme.success : theme.warning)}; - svg { - width: 12px; - margin-right: 4px; - fill: ${({ theme, winner }) => (winner ? theme.success : theme.warning)}; - } -`; - -const StyledRadio = styled(Radio)` - padding-left: 24px; - > input { - display: none; - } -`; - -const TopContainer = styled.div` - display: flex; - justify-content: space-between; - height: 50%; - .block { - display: block; - } -`; - -const LabelContainer = styled.div` - width: 100%; - display: flex; - justify-content: center; -`; - export default OptionCard; diff --git a/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx b/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx index d932a5de8..c844d036c 100644 --- a/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx +++ b/web/src/pages/Cases/CaseDetails/Evidence/SubmitEvidenceModal.tsx @@ -9,6 +9,42 @@ import { useWalletClient, usePublicClient } from "wagmi"; import { EnsureChain } from "components/EnsureChain"; import { prepareWriteDisputeKitClassic } from "hooks/contracts/generated"; +const StyledModal = styled(Modal)` + position: absolute; + top: 50%; + left: 50%; + right: auto; + bottom: auto; + margin-right: -50%; + transform: translate(-50%, -50%); + height: auto; + width: 80%; + border: 1px solid ${({ theme }) => theme.stroke}; + border-radius: 3px; + background-color: ${({ theme }) => theme.whiteBackground}; + + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; + gap: 16px; +`; + +const StyledTextArea = styled(Textarea)` + width: 100%; + height: 200px; +`; + +const StyledFileUploader = styled(FileUploader)` + width: 100%; +`; + +const ButtonArea = styled.div` + width: 100%; + display: flex; + justify-content: space-between; +`; + const SubmitEvidenceModal: React.FC<{ isOpen: boolean; evidenceGroup: bigint; @@ -80,40 +116,4 @@ const constructEvidence = async (msg: string, file?: File): Promise => return formData; }; -const StyledModal = styled(Modal)` - position: absolute; - top: 50%; - left: 50%; - right: auto; - bottom: auto; - margin-right: -50%; - transform: translate(-50%, -50%); - height: auto; - width: 80%; - border: 1px solid ${({ theme }) => theme.stroke}; - border-radius: 3px; - background-color: ${({ theme }) => theme.whiteBackground}; - - display: flex; - flex-direction: column; - align-items: center; - padding: 16px; - gap: 16px; -`; - -const StyledTextArea = styled(Textarea)` - width: 100%; - height: 200px; -`; - -const StyledFileUploader = styled(FileUploader)` - width: 100%; -`; - -const ButtonArea = styled.div` - width: 100%; - display: flex; - justify-content: space-between; -`; - export default SubmitEvidenceModal; diff --git a/web/src/pages/Cases/CaseDetails/Overview.tsx b/web/src/pages/Cases/CaseDetails/Overview.tsx index 569f9d4a4..c7e4b0f7f 100644 --- a/web/src/pages/Cases/CaseDetails/Overview.tsx +++ b/web/src/pages/Cases/CaseDetails/Overview.tsx @@ -3,7 +3,6 @@ import styled from "styled-components"; import { useParams } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import { formatEther } from "viem"; -import Skeleton from "react-loading-skeleton"; import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import { useDisputeTemplate } from "queries/useDisputeTemplate"; import { useCourtPolicy } from "queries/useCourtPolicy"; @@ -12,8 +11,10 @@ import { isUndefined } from "utils/index"; import { Periods } from "consts/periods"; import { IPFS_GATEWAY } from "consts/index"; import PolicyIcon from "svgs/icons/policy.svg"; +import { StyledSkeleton } from "components/StyledSkeleton"; import DisputeInfo from "components/DisputeCard/DisputeInfo"; import Verdict from "components/Verdict/index"; +import { useVotingHistory } from "hooks/queries/useVotingHistory"; const Container = styled.div` width: 100%; @@ -86,16 +87,19 @@ const Overview: React.FC = ({ arbitrable, courtID, currentPeriodIndex const { data: disputeDetails } = useDisputeDetailsQuery(id); const { data: courtPolicyURI } = useCourtPolicyURI(courtID); const { data: courtPolicy } = useCourtPolicy(courtID); + const { data: votingHistory } = useVotingHistory(id); const courtName = courtPolicy?.name; const court = disputeDetails?.dispute?.court; const rewards = court ? `â‰Ĩ ${formatEther(court.feeForJuror)} ETH` : undefined; const category = disputeTemplate ? disputeTemplate.category : undefined; + const localRounds = votingHistory?.dispute?.disputeKitDispute?.localRounds; + return ( <>

{isUndefined(disputeTemplate) ? ( - + ) : ( disputeTemplate?.title ?? "The dispute's template is not correct please vote refuse to arbitrate" )} @@ -126,7 +130,7 @@ const Overview: React.FC = ({ arbitrable, courtID, currentPeriodIndex )} - +

Make sure you understand the Policies

@@ -138,7 +142,7 @@ const Overview: React.FC = ({ arbitrable, courtID, currentPeriodIndex )} {courtPolicy && ( - + Court Policy diff --git a/web/src/pages/Cases/CaseDetails/Tabs.tsx b/web/src/pages/Cases/CaseDetails/Tabs.tsx index d7ce0d44b..f7c976c0c 100644 --- a/web/src/pages/Cases/CaseDetails/Tabs.tsx +++ b/web/src/pages/Cases/CaseDetails/Tabs.tsx @@ -10,6 +10,17 @@ import DocIcon from "assets/svgs/icons/doc.svg"; import BalanceIcon from "assets/svgs/icons/law-balance.svg"; import BullhornIcon from "assets/svgs/icons/bullhorn.svg"; +const StyledTabs = styled(TabsComponent)` + width: 100%; + > * { + display: flex; + flex-wrap: wrap; + > svg { + margin-right: 0px !important; + } + } +`; + const TABS = [ { text: "Overview", @@ -68,15 +79,4 @@ const Tabs: React.FC = () => { ); }; -const StyledTabs = styled(TabsComponent)` - width: 100%; - > * { - display: flex; - flex-wrap: wrap; - > svg { - margin-right: 0px !important; - } - } -`; - export default Tabs; diff --git a/web/src/pages/Cases/CaseDetails/Timeline.tsx b/web/src/pages/Cases/CaseDetails/Timeline.tsx index cd66ccee2..9fb621da0 100644 --- a/web/src/pages/Cases/CaseDetails/Timeline.tsx +++ b/web/src/pages/Cases/CaseDetails/Timeline.tsx @@ -3,9 +3,23 @@ import styled from "styled-components"; import { Periods } from "consts/periods"; import { DisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import { Box, Steps } from "@kleros/ui-components-library"; +import { StyledSkeleton } from "components/StyledSkeleton"; import { useCountdown } from "hooks/useCountdown"; import { secondsToDayHourMinute } from "utils/date"; +const TimeLineContainer = styled(Box)` + width: 100%; + height: 100px; + border-radius: 3px; + margin: 16px 0px; + padding: 8px; +`; + +const StyledSteps = styled(Steps)` + width: 85%; + margin: auto; +`; + const Timeline: React.FC<{ dispute: DisputeDetailsQuery["dispute"]; currentPeriodIndex: number; @@ -33,7 +47,7 @@ const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: dispute?.court.timesPerPeriod ); const countdown = useCountdown(deadlineCurrentPeriod); - const getSubitems = (index: number): string[] => { + const getSubitems = (index: number): string[] | React.ReactNode[] => { if (typeof countdown !== "undefined" && dispute) { if (index === currentItemIndex && countdown === 0) { return ["Time's up!"]; @@ -47,7 +61,7 @@ const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: return [secondsToDayHourMinute(dispute?.court.timesPerPeriod[index])]; } } - return ["Loading..."]; + return []; }; return titles.map((title, i) => ({ title, @@ -68,17 +82,4 @@ const getDeadline = ( return 0; }; -const TimeLineContainer = styled(Box)` - width: 100%; - height: 100px; - border-radius: 3px; - margin: 16px 0px; - padding: 8px; -`; - -const StyledSteps = styled(Steps)` - width: 85%; - margin: auto; -`; - export default Timeline; diff --git a/web/src/pages/Cases/CaseDetails/Voting/VotingHistory.tsx b/web/src/pages/Cases/CaseDetails/Voting/VotingHistory.tsx index 9061a02fd..f53602a23 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/VotingHistory.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/VotingHistory.tsx @@ -55,6 +55,20 @@ const StyledAccordion = styled(Accordion)` } `; +const VotedContainer = styled.div` + width: 100%; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 4px; +`; + +const JustificationContainer = styled.div` + > p { + margin: 0px; + } +`; + const AccordionContent: React.FC<{ choice: string; justification: string; @@ -73,20 +87,6 @@ const AccordionContent: React.FC<{ ); }; -const VotedContainer = styled.div` - width: 100%; - margin-bottom: 8px; - display: flex; - align-items: center; - gap: 4px; -`; - -const JustificationContainer = styled.div` - > p { - margin: 0px; - } -`; - export const getVoteChoice = (vote, answers) => { const selectedAnswer = answers?.[vote - 1]?.title; if (vote === 0) { diff --git a/web/src/pages/Cases/CaseDetails/index.tsx b/web/src/pages/Cases/CaseDetails/index.tsx index 2a858a9ea..46dc66017 100644 --- a/web/src/pages/Cases/CaseDetails/index.tsx +++ b/web/src/pages/Cases/CaseDetails/index.tsx @@ -11,6 +11,16 @@ import Tabs from "./Tabs"; import Timeline from "./Timeline"; import Voting from "./Voting"; +const Container = styled.div``; + +const StyledCard = styled(Card)` + margin-top: 16px; + width: 100%; + height: auto; + min-height: 100px; + padding: 16px; +`; + const CaseDetails: React.FC = () => { const { id } = useParams(); const { data } = useDisputeDetailsQuery(id); @@ -41,14 +51,4 @@ const CaseDetails: React.FC = () => { ); }; -const Container = styled.div``; - -const StyledCard = styled(Card)` - margin-top: 16px; - width: 100%; - height: auto; - min-height: 100px; - padding: 16px; -`; - export default CaseDetails; diff --git a/web/src/pages/Courts/CourtDetails/Description.tsx b/web/src/pages/Courts/CourtDetails/Description.tsx index 57caad908..c48ce3593 100644 --- a/web/src/pages/Courts/CourtDetails/Description.tsx +++ b/web/src/pages/Courts/CourtDetails/Description.tsx @@ -3,8 +3,29 @@ import styled from "styled-components"; import ReactMarkdown from "react-markdown"; import { Routes, Route, Navigate, useParams, useNavigate, useLocation } from "react-router-dom"; import { Tabs } from "@kleros/ui-components-library"; +import { StyledSkeleton } from "components/StyledSkeleton"; import { useCourtPolicy } from "queries/useCourtPolicy"; +const Container = styled.div` + width: 100%; +`; + +const TextContainer = styled.div` + width: 100%; + padding: 12px 0; +`; + +const StyledTabs = styled(Tabs)` + width: 100%; + > * { + display: flex; + flex-wrap: wrap; + > svg { + margin-right: 0px !important; + } + } +`; + interface IPolicy { description?: string; requiredSkills?: string; @@ -42,8 +63,21 @@ const Description: React.FC = () => { const filteredTabs = TABS.filter(({ isVisible }) => isVisible(policy)); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const scrollToSection = queryParams.get("section"); + + useEffect(() => { + if (scrollToSection === "description") { + const element = document.getElementById(scrollToSection); + if (element) { + element.scrollIntoView({ behavior: "smooth" }); + } + } + }, [scrollToSection]); + return ( - + markdown ? ( {typeof markdown === "string" ? markdown.replace(/\n/g, " \n") : markdown} ) : ( -

Loading...

+ ); -const Container = styled.div` - width: 100%; -`; - -const TextContainer = styled.div` - width: 100%; - padding: 12px 0; -`; - -const StyledTabs = styled(Tabs)` - width: 100%; - > * { - display: flex; - flex-wrap: wrap; - > svg { - margin-right: 0px !important; - } - } -`; - export default Description; diff --git a/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx b/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx index a299b04a8..1da445fcb 100644 --- a/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx +++ b/web/src/pages/Courts/CourtDetails/StakePanel/InputDisplay.tsx @@ -1,18 +1,19 @@ import React, { useState } from "react"; import styled from "styled-components"; import { useParams } from "react-router-dom"; -import { formatEther } from "viem"; import { useDebounce } from "react-use"; import { useAccount } from "wagmi"; -import { Field } from "@kleros/ui-components-library"; +import { NumberInputField } from "components/NumberInputField"; import { useParsedAmount } from "hooks/useParsedAmount"; import { useCourtDetails } from "hooks/queries/useCourtDetails"; import { useKlerosCoreGetJurorBalance, usePnkBalanceOf } from "hooks/contracts/generated"; import StakeWithdrawButton, { ActionType } from "./StakeWithdrawButton"; +import { formatPNK, roundNumberDown } from "utils/format"; import { isUndefined } from "utils/index"; +import { commify, uncommify } from "utils/commify"; import { EnsureChain } from "components/EnsureChain"; -const StyledField = styled(Field)` +const StyledField = styled(NumberInputField)` width: 100%; height: fit-content; `; @@ -53,7 +54,7 @@ const InputDisplay: React.FC = ({ }) => { const [debouncedAmount, setDebouncedAmount] = useState(""); useDebounce(() => setDebouncedAmount(amount), 500, [amount]); - const parsedAmount = useParsedAmount(debouncedAmount); + const parsedAmount = useParsedAmount(uncommify(debouncedAmount)); const { id } = useParams(); const { data: courtDetails } = useCourtDetails(id); @@ -63,13 +64,13 @@ const InputDisplay: React.FC = ({ args: [address ?? "0x"], watch: true, }); - const parsedBalance = formatEther(balance ?? 0n); + const parsedBalance = formatPNK(balance ?? 0n, 0, true); const { data: jurorBalance } = useKlerosCoreGetJurorBalance({ enabled: !isUndefined(address), args: [address, id], watch: true, }); - const parsedStake = formatEther(jurorBalance?.[0] || 0n); + const parsedStake = formatPNK(jurorBalance?.[0] || 0n, 0, true); const isStaking = action === ActionType.stake; return ( @@ -87,21 +88,21 @@ const InputDisplay: React.FC = ({ { - setAmount(e.target.value); + setAmount(e); }} placeholder={isStaking ? "Amount to stake" : "Amount to withdraw"} message={ isStaking - ? `You need to stake at least ${formatEther(courtDetails?.court.minStake ?? 0n)} PNK. ` + + ? `You need to stake at least ${formatPNK(courtDetails?.court.minStake ?? 0n, 3)} PNK. ` + "You may need two transactions, one to increase allowance, the other to stake." - : `You need to either withdraw all or keep at least ${formatEther( - courtDetails?.court.minStake ?? 0n + : `You need to either withdraw all or keep at least ${formatPNK( + courtDetails?.court.minStake ?? 0n, + 3 )} PNK.` } - variant="info" + formatter={(number: string) => commify(roundNumberDown(Number(number)))} /> (value !== undefined ? formatEther(value) : "0"); const bigIntRatioToPercentage = (numerator: bigint, denominator: bigint): string => { @@ -103,11 +111,3 @@ const JurorBalanceDisplay = () => { }; export default JurorBalanceDisplay; - -const Container = styled.div` - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 8px; - margin-top: 12px; -`; diff --git a/web/src/pages/Courts/CourtDetails/StakePanel/index.tsx b/web/src/pages/Courts/CourtDetails/StakePanel/index.tsx index ab0db4266..49590e6fe 100644 --- a/web/src/pages/Courts/CourtDetails/StakePanel/index.tsx +++ b/web/src/pages/Courts/CourtDetails/StakePanel/index.tsx @@ -8,6 +8,28 @@ import { ActionType } from "./StakeWithdrawButton"; import Popup, { PopupType } from "components/Popup/index"; import BalanceIcon from "assets/svgs/icons/balance.svg"; +const Container = styled.div` + position: relative; + width: 100%; + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 28px; +`; + +const TagArea = styled.div` + display: flex; + gap: 10px; +`; + +const StakeArea = styled(TagArea)` + flex-direction: column; +`; + +const TextArea = styled.div` + color: ${({ theme }) => theme.primaryText}; +`; + const StakePanel: React.FC<{ courtName: string; id: string }> = ({ courtName = "General Court", id }) => { const [amount, setAmount] = useState(""); const [isSending, setIsSending] = useState(false); @@ -55,25 +77,3 @@ const StakePanel: React.FC<{ courtName: string; id: string }> = ({ courtName = " }; export default StakePanel; - -const Container = styled.div` - position: relative; - width: 100%; - margin-top: 32px; - display: flex; - flex-direction: column; - gap: 28px; -`; - -const TagArea = styled.div` - display: flex; - gap: 10px; -`; - -const StakeArea = styled(TagArea)` - flex-direction: column; -`; - -const TextArea = styled.div` - color: ${({ theme }) => theme.primaryText}; -`; diff --git a/web/src/pages/Courts/CourtDetails/Stats.tsx b/web/src/pages/Courts/CourtDetails/Stats.tsx index 07f44a203..bfc8bfa49 100644 --- a/web/src/pages/Courts/CourtDetails/Stats.tsx +++ b/web/src/pages/Courts/CourtDetails/Stats.tsx @@ -1,19 +1,20 @@ import React from "react"; import styled from "styled-components"; -import { formatUnits, formatEther } from "viem"; import { useParams } from "react-router-dom"; import { useCourtDetails, CourtDetailsQuery } from "queries/useCourtDetails"; -import { KLEROS_CONTRACT_ADDRESS, WETH_CONTRACT_ADDRESS } from "src/consts/index"; +import { useCoinPrice } from "hooks/useCoinPrice"; +import { formatETH, formatPNK, formatUnitsWei, formatUSD } from "utils/format"; +import { isUndefined } from "utils/index"; +import { calculateSubtextRender } from "utils/calculateSubtextRender"; +import { CoinIds } from "consts/coingecko"; import StatDisplay, { IStatDisplay } from "components/StatDisplay"; +import { StyledSkeleton } from "components/StyledSkeleton"; import BalanceIcon from "svgs/icons/law-balance.svg"; import MinStake from "svgs/icons/min-stake.svg"; -import { commify } from "utils/commify"; import VoteStake from "svgs/icons/vote-stake.svg"; import PNKIcon from "svgs/icons/pnk.svg"; import PNKRedistributedIcon from "svgs/icons/redistributed-pnk.svg"; import EthereumIcon from "svgs/icons/ethereum.svg"; -import { useCoinPrice } from "hooks/useCoinPrice"; -import { isUndefined } from "utils/index"; const StyledCard = styled.div` width: auto; @@ -27,7 +28,7 @@ interface IStat { title: string; coinId?: number; getText: (data: CourtDetailsQuery["court"]) => string; - getSubtext: (data: CourtDetailsQuery["court"], coinPrice?: number) => string; + getSubtext?: (data: CourtDetailsQuery["court"], coinPrice?: number) => string; color: IStatDisplay["color"]; icon: React.FC>; } @@ -36,91 +37,79 @@ const stats: IStat[] = [ { title: "Min Stake", coinId: 0, - getText: (data) => commify(formatUnits(data?.minStake, 18)), - getSubtext: (data, coinPrice) => - (parseInt(formatUnits(data?.minStake, 18)) * (coinPrice ?? 0)).toFixed(2).toString() + "$", + getText: (data) => formatPNK(data?.minStake), + getSubtext: (data, coinPrice) => formatUSD(Number(formatUnitsWei(data?.minStake)) * (coinPrice ?? 0)), color: "purple", icon: MinStake, }, { title: "Vote Stake", coinId: 0, - getText: (data) => commify(formatUnits(data?.minStake, 18)), - getSubtext: (data, coinPrice) => - (parseInt(formatUnits(data?.minStake, 18)) * (coinPrice ?? 0)).toFixed(2).toString() + "$", + getText: (data) => formatPNK(data?.minStake), + getSubtext: (data, coinPrice) => formatUSD(Number(formatUnitsWei(data?.minStake)) * (coinPrice ?? 0)), color: "purple", icon: VoteStake, }, { title: "Active Jurors", getText: (data) => data?.numberStakedJurors, - getSubtext: () => "", color: "purple", icon: PNKRedistributedIcon, }, { title: "PNK Staked", coinId: 0, - getText: (data) => commify(formatUnits(data?.stake, 18)), - getSubtext: (data, coinPrice) => - (parseInt(formatUnits(data?.stake, 18)) * (coinPrice ?? 0)).toFixed(2).toString() + "$", + getText: (data) => formatPNK(data?.stake), + getSubtext: (data, coinPrice) => formatUSD(Number(formatUnitsWei(data?.stake)) * (coinPrice ?? 0)), color: "purple", icon: PNKIcon, }, { title: "Cases", getText: (data) => data?.numberDisputes, - getSubtext: () => "", color: "orange", icon: BalanceIcon, }, { title: "In Progress", getText: (data) => data?.numberDisputes, - getSubtext: () => "", color: "orange", icon: BalanceIcon, }, { title: "ETH paid to Jurors", coinId: 1, - getText: (data) => commify(formatEther(BigInt(data?.paidETH))), - getSubtext: (data, coinPrice) => - (Number(formatUnits(data?.paidETH, 18)) * (coinPrice ?? 0)).toFixed(2).toString() + "$", + getText: (data) => formatETH(data?.paidETH), + getSubtext: (data, coinPrice) => formatUSD(Number(formatUnitsWei(data?.paidETH)) * (coinPrice ?? 0)), color: "blue", icon: EthereumIcon, }, { title: "PNK redistributed", coinId: 0, - getText: (data) => commify(formatUnits(data?.paidPNK, 18)), - getSubtext: (data, coinPrice) => - (parseInt(formatUnits(data?.paidPNK, 18)) * (coinPrice ?? 0)).toFixed(2).toString() + "$", + getText: (data) => formatPNK(data?.paidPNK), + getSubtext: (data, coinPrice) => formatUSD(Number(formatUnitsWei(data?.paidPNK)) * (coinPrice ?? 0)), color: "purple", icon: PNKRedistributedIcon, }, ]; -const coinIdToAddress = { - 0: KLEROS_CONTRACT_ADDRESS, - 1: WETH_CONTRACT_ADDRESS, -}; - const Stats = () => { const { id } = useParams(); const { data } = useCourtDetails(id); - const { prices: pricesData } = useCoinPrice([KLEROS_CONTRACT_ADDRESS, WETH_CONTRACT_ADDRESS]); + const coinIds = [CoinIds.PNK, CoinIds.ETH]; + const { prices: pricesData } = useCoinPrice(coinIds); return ( {stats.map(({ title, coinId, getText, getSubtext, color, icon }, i) => { - const coinPrice = !isUndefined(pricesData) ? pricesData[coinIdToAddress[coinId!]]?.price : undefined; + const coinPrice = !isUndefined(pricesData) ? pricesData[coinIds[coinId!]]?.price : undefined; return ( } + subtext={calculateSubtextRender(data ? data.court : undefined, getSubtext, coinPrice)} /> ); })} diff --git a/web/src/pages/Courts/CourtDetails/index.tsx b/web/src/pages/Courts/CourtDetails/index.tsx index aea305377..e8a8db7db 100644 --- a/web/src/pages/Courts/CourtDetails/index.tsx +++ b/web/src/pages/Courts/CourtDetails/index.tsx @@ -7,14 +7,41 @@ import { formatEther } from "viem"; import { useCourtPolicy } from "queries/useCourtPolicy"; import { useCourtTree, CourtTreeQuery } from "queries/useCourtTree"; import { DEFAULT_CHAIN } from "consts/chains"; -import { PNK_FAUCET_CONTRACT_ADDRESS } from "consts/index"; +import { usePNKFaucetAddress } from "hooks/useContractAddress"; import { wrapWithToast } from "utils/wrapWithToast"; import { isUndefined } from "utils/index"; +import { StyledSkeleton } from "components/StyledSkeleton"; import Stats from "./Stats"; import Description from "./Description"; import StakePanel from "./StakePanel"; import { usePnkFaucetWithdrewAlready, prepareWritePnkFaucet, usePnkBalanceOf } from "hooks/contracts/generated"; +const Container = styled.div``; + +const ButtonContainer = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: space-between; +`; + +const StyledCard = styled(Card)` + margin-top: 16px; + width: 100%; + height: auto; + padding: 16px; + min-height: 100px; +`; + +const StyledBreadcrumb = styled(Breadcrumb)` + margin-bottom: 12px; + display: flex; + align-items: flex-start; +`; + +const StyledBreadcrumbSkeleton = styled.div` + margin-bottom: 12px; +`; + const CourtDetails: React.FC = () => { const { id } = useParams(); const [isSending, setIsSending] = useState(false); @@ -24,12 +51,13 @@ const CourtDetails: React.FC = () => { const { address } = useAccount(); const { data: claimed } = usePnkFaucetWithdrewAlready({ enabled: !isUndefined(address), - args: [address], + args: [address ?? "0x00"], watch: true, }); + const faucetAddress = usePNKFaucetAddress(); const { data: balance } = usePnkBalanceOf({ - args: [PNK_FAUCET_CONTRACT_ADDRESS], + args: [faucetAddress], watch: true, }); const { data: walletClient } = useWalletClient(); @@ -49,17 +77,26 @@ const CourtDetails: React.FC = () => { const faucetCheck = !isUndefined(balance) && parseInt(formatEther(balance)) > 200; const courtPath = getCourtsPath(data?.court, id); - const items = courtPath?.map((node) => ({ - text: node.name, - value: node.id, - })); + const items = [{ text: "🏛ī¸", value: "0" }]; + items.push( + ...(courtPath?.map((node) => ({ + text: node.name, + value: node.id, + })) ?? []) + ); return ( -

{policy ? policy.name : "Loading..."}

+

{policy ? policy.name : }

- {items && } + {items.length > 1 ? ( + + ) : ( + + + + )} {chain?.id === DEFAULT_CHAIN && !claimed && (