From 2bffa61931f99b8d772f180511638d40f885f278 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Sat, 15 Mar 2025 00:49:17 +0000 Subject: [PATCH 01/13] chore: shutter script experiment --- contracts/package.json | 2 + contracts/scripts/shutter.ts | 127 +++++++++++++++++++++++++++++++++++ yarn.lock | 74 ++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 contracts/scripts/shutter.ts diff --git a/contracts/package.json b/contracts/package.json index 0b4d3dd75..101f5ec34 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -157,6 +157,8 @@ "@chainlink/contracts": "^1.3.0", "@kleros/vea-contracts": "^0.6.0", "@openzeppelin/contracts": "^5.2.0", + "@shutter-network/shutter-sdk": "^0.0.1", + "isomorphic-fetch": "^3.0.0", "viem": "^2.24.1" } } diff --git a/contracts/scripts/shutter.ts b/contracts/scripts/shutter.ts new file mode 100644 index 000000000..e5ad5bd77 --- /dev/null +++ b/contracts/scripts/shutter.ts @@ -0,0 +1,127 @@ +import { encryptData } from "@shutter-network/shutter-sdk"; +import { Hex, stringToHex } from "viem"; +import crypto from "crypto"; +import "isomorphic-fetch"; + +interface ShutterApiMessageData { + eon: number; + identity: string; + identity_prefix: string; + eon_key: string; + tx_hash: string; +} + +interface ShutterApiResponse { + message: ShutterApiMessageData; + error?: string; +} + +/** + * Fetches encryption data from the Shutter API + * @param decryptionTimestamp Unix timestamp when decryption should be possible + * @returns Promise with the eon key and identity + */ +async function fetchShutterData(decryptionTimestamp: number): Promise { + try { + console.log(`Sending request to Shutter API with decryption timestamp: ${decryptionTimestamp}`); + + const response = await fetch("https://shutter-api.shutter.network/api/register_identity", { + method: "POST", + headers: { + accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ decryptionTimestamp }), + }); + + // Log the response status + console.log(`API response status: ${response.status}`); + + // Get the response text + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}: ${responseText}`); + } + + // Parse the JSON response + let jsonResponse: ShutterApiResponse; + try { + jsonResponse = JSON.parse(responseText); + } catch (error) { + throw new Error(`Failed to parse API response as JSON: ${responseText}`); + } + + // Check if we have the message data + if (!jsonResponse.message) { + throw new Error(`API response missing message data: ${JSON.stringify(jsonResponse)}`); + } + + return jsonResponse.message; + } catch (error) { + console.error("Error fetching data from Shutter API:", error); + throw error; + } +} + +/** + * Ensures a string is a valid hex string with 0x prefix + * @param hexString The hex string to validate + * @returns The validated hex string with 0x prefix + */ +function ensureHexString(hexString: string | undefined): `0x${string}` { + if (!hexString) { + throw new Error("Hex string is undefined or null"); + } + + // Add 0x prefix if it doesn't exist + const prefixedHex = hexString.startsWith("0x") ? hexString : `0x${hexString}`; + return prefixedHex as `0x${string}`; +} + +/** + * Generates a random sigma value (32 bytes) + * @returns Random sigma as a hex string with 0x prefix + */ +function generateRandomSigma(): `0x${string}` { + return ("0x" + + crypto + .getRandomValues(new Uint8Array(32)) + .reduce((acc, byte) => acc + byte.toString(16).padStart(2, "0"), "")) as Hex; +} + +async function main() { + try { + // Set decryption timestamp (e.g., 24 hours from now) + const decryptionTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now + + // Fetch encryption data from Shutter API + console.log(`Fetching encryption data for decryption at timestamp ${decryptionTimestamp}...`); + const shutterData = await fetchShutterData(decryptionTimestamp); + + // Extract the eon key and identity from the response and ensure they have the correct format + const eonKeyHex = ensureHexString(shutterData.eon_key); + const identityHex = ensureHexString(shutterData.identity); + + // Message to encrypt + const msgHex = stringToHex("my very secret vote"); + + // Generate a random sigma + const sigmaHex = generateRandomSigma(); + + console.log("Eon Key:", eonKeyHex); + console.log("Identity:", identityHex); + console.log("Sigma:", sigmaHex); + + // Encrypt the message + const encryptedCommitment = await encryptData(msgHex, identityHex, eonKeyHex, sigmaHex); + + // Print the encrypted commitment + console.log("Encrypted Commitment:", encryptedCommitment); + } catch (error) { + console.error("Error:", error); + } +} + +// Execute the main function +main(); diff --git a/yarn.lock b/yarn.lock index 6aa34039e..f46f859f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5560,6 +5560,7 @@ __metadata: "@nomiclabs/hardhat-solhint": "npm:^4.0.1" "@openzeppelin/contracts": "npm:^5.2.0" "@openzeppelin/upgrades-core": "npm:^1.42.2" + "@shutter-network/shutter-sdk": "npm:^0.0.1" "@typechain/ethers-v6": "npm:^0.5.1" "@typechain/hardhat": "npm:^9.1.0" "@types/chai": "npm:^4.3.20" @@ -5585,6 +5586,7 @@ __metadata: hardhat-gas-reporter: "npm:^2.2.2" hardhat-tracer: "npm:^3.1.0" hardhat-watcher: "npm:^2.5.0" + isomorphic-fetch: "npm:^3.0.0" node-fetch: "npm:^3.3.2" pino: "npm:^8.21.0" pino-pretty: "npm:^10.3.1" @@ -6888,6 +6890,15 @@ __metadata: languageName: node linkType: hard +"@noble/curves@npm:1.8.2": + version: 1.8.2 + resolution: "@noble/curves@npm:1.8.2" + dependencies: + "@noble/hashes": "npm:1.7.2" + checksum: 10/540e7b7a8fe92ecd5cef846f84d07180662eb7fd7d8e9172b8960c31827e74f148fe4630da962138a6be093ae9f8992d14ab23d3682a2cc32be839aa57c03a46 + languageName: node + linkType: hard + "@noble/curves@npm:^1.7.0": version: 1.7.0 resolution: "@noble/curves@npm:1.7.0" @@ -6946,6 +6957,13 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:1.7.2": + version: 1.7.2 + resolution: "@noble/hashes@npm:1.7.2" + checksum: 10/b5af9e4b91543dcc46a811b5b2c57bfdeb41728361979a19d6110a743e2cb0459872553f68d3a46326d21959964db2776b8c8b4db85ac1d9f63ebcaddf7d59b6 + languageName: node + linkType: hard + "@noble/hashes@npm:^1.6.1": version: 1.6.1 resolution: "@noble/hashes@npm:1.6.1" @@ -8561,6 +8579,17 @@ __metadata: languageName: node linkType: hard +"@shutter-network/shutter-sdk@npm:^0.0.1": + version: 0.0.1 + resolution: "@shutter-network/shutter-sdk@npm:0.0.1" + dependencies: + browser-or-node: "npm:^3.0.0" + lodash: "npm:^4.17.21" + viem: "npm:^2.23.2" + checksum: 10/5e021c9dc27cb88dd487e1a289501394302d88fe349a873525610405eb2b351563556b1d0ef2ac789cff99b03ba6019c0a9761a093a0aef5a82941986ca58517 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.24.1": version: 0.24.51 resolution: "@sinclair/typebox@npm:0.24.51" @@ -12986,6 +13015,13 @@ __metadata: languageName: node linkType: hard +"browser-or-node@npm:^3.0.0": + version: 3.0.0 + resolution: "browser-or-node@npm:3.0.0" + checksum: 10/51d74cc5d0139da3d37e83ff3906fcca20d02c42aa8b81a48d9ea01806f36df1a4b55006670071b1d7423967777275920054ec8b723410534b580b0232c5093d + languageName: node + linkType: hard + "browser-process-hrtime@npm:^1.0.0": version: 1.0.0 resolution: "browser-process-hrtime@npm:1.0.0" @@ -21179,6 +21215,16 @@ __metadata: languageName: node linkType: hard +"isomorphic-fetch@npm:^3.0.0": + version: 3.0.0 + resolution: "isomorphic-fetch@npm:3.0.0" + dependencies: + node-fetch: "npm:^2.6.1" + whatwg-fetch: "npm:^3.4.1" + checksum: 10/568fe0307528c63405c44dd3873b7b6c96c0d19ff795cb15846e728b6823bdbc68cc8c97ac23324509661316f12f551e43dac2929bc7030b8bc4d6aa1158b857 + languageName: node + linkType: hard + "isomorphic-timers-promises@npm:^1.0.1": version: 1.0.1 resolution: "isomorphic-timers-promises@npm:1.0.1" @@ -33420,6 +33466,27 @@ __metadata: languageName: node linkType: hard +"viem@npm:^2.23.2": + version: 2.28.0 + resolution: "viem@npm:2.28.0" + dependencies: + "@noble/curves": "npm:1.8.2" + "@noble/hashes": "npm:1.7.2" + "@scure/bip32": "npm:1.6.2" + "@scure/bip39": "npm:1.5.4" + abitype: "npm:1.0.8" + isows: "npm:1.0.6" + ox: "npm:0.6.9" + ws: "npm:8.18.1" + peerDependencies: + typescript: ">=5.0.4" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/fbdf2ce3020b0ee37457726375cb2479cf716e5ef021470a79f2713c5def1ae53c881458cc3853d1cab8ba655385280aeebbdc155e81b8cb54c6387758e59435 + languageName: node + linkType: hard + "vite-node@npm:1.6.0": version: 1.6.0 resolution: "vite-node@npm:1.6.0" @@ -34149,6 +34216,13 @@ __metadata: languageName: node linkType: hard +"whatwg-fetch@npm:^3.4.1": + version: 3.6.20 + resolution: "whatwg-fetch@npm:3.6.20" + checksum: 10/2b4ed92acd6a7ad4f626a6cb18b14ec982bbcaf1093e6fe903b131a9c6decd14d7f9c9ca3532663c2759d1bdf01d004c77a0adfb2716a5105465c20755a8c57c + languageName: node + linkType: hard + "whatwg-fetch@npm:^3.6.2": version: 3.6.2 resolution: "whatwg-fetch@npm:3.6.2" From 3570b46675771cae2a3c601d3220793cd383e60c Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 19 Mar 2025 19:19:24 +0000 Subject: [PATCH 02/13] feat(shutter): added decryption logic --- contracts/scripts/shutter.ts | 206 ++++++++++++++++++++++++++++++----- 1 file changed, 179 insertions(+), 27 deletions(-) diff --git a/contracts/scripts/shutter.ts b/contracts/scripts/shutter.ts index e5ad5bd77..6a7c73f90 100644 --- a/contracts/scripts/shutter.ts +++ b/contracts/scripts/shutter.ts @@ -1,5 +1,5 @@ -import { encryptData } from "@shutter-network/shutter-sdk"; -import { Hex, stringToHex } from "viem"; +import { encryptData, decrypt as shutterDecrypt } from "@shutter-network/shutter-sdk"; +import { Hex, stringToHex, hexToString } from "viem"; import crypto from "crypto"; import "isomorphic-fetch"; @@ -16,6 +16,17 @@ interface ShutterApiResponse { error?: string; } +interface ShutterDecryptionKeyData { + decryption_key: string; + identity: string; + decryption_timestamp: number; +} + +interface ShutterDecryptionKeyResponse { + message: ShutterDecryptionKeyData; + error?: string; +} + /** * Fetches encryption data from the Shutter API * @param decryptionTimestamp Unix timestamp when decryption should be possible @@ -25,13 +36,20 @@ async function fetchShutterData(decryptionTimestamp: number): Promise { + try { + console.log(`Fetching decryption key for identity: ${identity}`); + + const response = await fetch(`https://shutter-api.shutter.network/api/get_decryption_key?identity=${identity}`, { + method: "GET", + headers: { + accept: "application/json", + }, + }); + + // Log the response status + console.log(`API response status: ${response.status}`); + + // Get the response text + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}: ${responseText}`); + } + + // Parse the JSON response + let jsonResponse: ShutterDecryptionKeyResponse; + try { + jsonResponse = JSON.parse(responseText); + } catch (error) { + throw new Error(`Failed to parse API response as JSON: ${responseText}`); + } + + // Check if we have the message data + if (!jsonResponse.message) { + throw new Error(`API response missing message data: ${JSON.stringify(jsonResponse)}`); + } + + return jsonResponse.message; + } catch (error) { + console.error("Error fetching decryption key:", error); + throw error; + } +} + /** * Ensures a string is a valid hex string with 0x prefix * @param hexString The hex string to validate @@ -80,46 +144,134 @@ function ensureHexString(hexString: string | undefined): `0x${string}` { } /** - * Generates a random sigma value (32 bytes) - * @returns Random sigma as a hex string with 0x prefix + * Generates a random 32 bytes + * @returns Random 32 bytes as a hex string with 0x prefix */ -function generateRandomSigma(): `0x${string}` { +function generateRandomBytes32(): `0x${string}` { return ("0x" + crypto .getRandomValues(new Uint8Array(32)) .reduce((acc, byte) => acc + byte.toString(16).padStart(2, "0"), "")) as Hex; } -async function main() { +/** + * Encrypts a message using the Shutter API + * @param message The message to encrypt + * @returns Promise with the encrypted commitment + */ +async function encrypt(message: string): Promise { + // Set decryption timestamp + const decryptionTimestamp = Math.floor(Date.now() / 1000) + 120; + + // Fetch encryption data from Shutter API + console.log(`Fetching encryption data for decryption at timestamp ${decryptionTimestamp}...`); + const shutterData = await fetchShutterData(decryptionTimestamp); + + // Extract the eon key and identity from the response and ensure they have the correct format + const eonKeyHex = ensureHexString(shutterData.eon_key); + const identityHex = ensureHexString(shutterData.identity); + + // Message to encrypt + const msgHex = stringToHex(message); + + // Generate a random sigma + const sigmaHex = generateRandomBytes32(); + + console.log("Eon Key:", eonKeyHex); + console.log("Identity:", identityHex); + console.log("Sigma:", sigmaHex); + + // Encrypt the message + const encryptedCommitment = await encryptData(msgHex, identityHex, eonKeyHex, sigmaHex); + + return encryptedCommitment; +} + +/** + * Decrypts a message using the Shutter API + * @param encryptedMessage The encrypted message to decrypt + * @returns Promise with the decrypted message + */ +async function decrypt(encryptedMessage: string): Promise { try { - // Set decryption timestamp (e.g., 24 hours from now) - const decryptionTimestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hours from now + // Extract the identity from the encrypted message + // TODO: In a real implementation, you would need to store and retrieve the identity + // used for encryption. For now, we'll need to pass it as an additional parameter + // or store it alongside the encrypted message. + const identity = process.argv[4]; // Temporary solution: pass identity as an additional argument + if (!identity) { + throw new Error("Please provide the identity used for encryption as the third argument"); + } - // Fetch encryption data from Shutter API - console.log(`Fetching encryption data for decryption at timestamp ${decryptionTimestamp}...`); - const shutterData = await fetchShutterData(decryptionTimestamp); + // Fetch the decryption key + const decryptionKeyData = await fetchDecryptionKey(identity); + console.log("Decryption key:", decryptionKeyData.decryption_key); - // Extract the eon key and identity from the response and ensure they have the correct format - const eonKeyHex = ensureHexString(shutterData.eon_key); - const identityHex = ensureHexString(shutterData.identity); + // Ensure the decryption key is properly formatted + const decryptionKey = ensureHexString(decryptionKeyData.decryption_key); - // Message to encrypt - const msgHex = stringToHex("my very secret vote"); + // Decrypt the message + const decryptedHexMessage = await shutterDecrypt(encryptedMessage, decryptionKey); - // Generate a random sigma - const sigmaHex = generateRandomSigma(); + // Convert the decrypted hex message back to a string + return hexToString(decryptedHexMessage as `0x${string}`); + } catch (error) { + console.error("Error during decryption:", error); + throw error; + } +} + +async function main() { + try { + const command = process.argv[2]?.toLowerCase(); + + if (!command) { + console.error(` +Usage: yarn ts-node shutter.ts [arguments] - console.log("Eon Key:", eonKeyHex); - console.log("Identity:", identityHex); - console.log("Sigma:", sigmaHex); +Commands: + encrypt Encrypt a message + decrypt Decrypt a message (requires the identity used during encryption) - // Encrypt the message - const encryptedCommitment = await encryptData(msgHex, identityHex, eonKeyHex, sigmaHex); +Examples: + yarn ts-node shutter.ts encrypt "my secret message" + yarn ts-node shutter.ts decrypt "encrypted-data" "0x1234..."`); + process.exit(1); + } - // Print the encrypted commitment - console.log("Encrypted Commitment:", encryptedCommitment); + switch (command) { + case "encrypt": { + const message = process.argv[3]; + if (!message) { + console.error("Error: Missing message to encrypt"); + console.error("Usage: yarn ts-node shutter.ts encrypt "); + process.exit(1); + } + const encryptedCommitment = await encrypt(message); + console.log("\nEncrypted Commitment:", encryptedCommitment); + break; + } + case "decrypt": { + const [encryptedMessage, identity] = [process.argv[3], process.argv[4]]; + if (!encryptedMessage || !identity) { + console.error("Error: Missing required arguments for decrypt"); + console.error("Usage: yarn ts-node shutter.ts decrypt "); + console.error("Note: The identity is the one returned during encryption"); + process.exit(1); + } + const decryptedMessage = await decrypt(encryptedMessage); + console.log("\nDecrypted Message:", decryptedMessage); + break; + } + default: { + console.error(`Error: Unknown command '${command}'`); + console.error("Valid commands are: encrypt, decrypt"); + process.exit(1); + } + } } catch (error) { - console.error("Error:", error); + console.error("\nError:", error); + process.exit(1); } } From ddae29a78d5640b1a100bd808706bedf49e464e5 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 19 Mar 2025 19:30:52 +0000 Subject: [PATCH 03/13] fix: better error handling --- contracts/scripts/shutter.ts | 106 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 54 deletions(-) diff --git a/contracts/scripts/shutter.ts b/contracts/scripts/shutter.ts index 6a7c73f90..aab9eff4a 100644 --- a/contracts/scripts/shutter.ts +++ b/contracts/scripts/shutter.ts @@ -3,6 +3,9 @@ import { Hex, stringToHex, hexToString } from "viem"; import crypto from "crypto"; import "isomorphic-fetch"; +/** Time in seconds to wait before the message can be decrypted */ +const DECRYPTION_DELAY = 120; // 2 minutes + interface ShutterApiMessageData { eon: number; identity: string; @@ -88,44 +91,44 @@ async function fetchShutterData(decryptionTimestamp: number): Promise { - try { - console.log(`Fetching decryption key for identity: ${identity}`); + console.log(`Fetching decryption key for identity: ${identity}`); - const response = await fetch(`https://shutter-api.shutter.network/api/get_decryption_key?identity=${identity}`, { - method: "GET", - headers: { - accept: "application/json", - }, - }); + const response = await fetch(`https://shutter-api.shutter.network/api/get_decryption_key?identity=${identity}`, { + method: "GET", + headers: { + accept: "application/json", + }, + }); - // Log the response status - console.log(`API response status: ${response.status}`); + // Get the response text + const responseText = await response.text(); - // Get the response text - const responseText = await response.text(); - - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}: ${responseText}`); - } - - // Parse the JSON response - let jsonResponse: ShutterDecryptionKeyResponse; - try { - jsonResponse = JSON.parse(responseText); - } catch (error) { - throw new Error(`Failed to parse API response as JSON: ${responseText}`); - } + // Try to parse the error response even if the request failed + let jsonResponse; + try { + jsonResponse = JSON.parse(responseText); + } catch (error) { + throw new Error(`Failed to parse API response as JSON: ${responseText}`); + } - // Check if we have the message data - if (!jsonResponse.message) { - throw new Error(`API response missing message data: ${JSON.stringify(jsonResponse)}`); + // Handle the "too early" error case specifically + if (!response.ok) { + if (jsonResponse?.description?.includes("timestamp not reached yet")) { + throw new Error( + `Cannot decrypt yet: The decryption timestamp has not been reached.\n` + + `Please wait at least ${DECRYPTION_DELAY} seconds after encryption before attempting to decrypt.\n` + + `Error details: ${jsonResponse.description}` + ); } + throw new Error(`API request failed with status ${response.status}: ${responseText}`); + } - return jsonResponse.message; - } catch (error) { - console.error("Error fetching decryption key:", error); - throw error; + // Check if we have the message data + if (!jsonResponse.message) { + throw new Error(`API response missing message data: ${JSON.stringify(jsonResponse)}`); } + + return jsonResponse.message; } /** @@ -161,7 +164,7 @@ function generateRandomBytes32(): `0x${string}` { */ async function encrypt(message: string): Promise { // Set decryption timestamp - const decryptionTimestamp = Math.floor(Date.now() / 1000) + 120; + const decryptionTimestamp = Math.floor(Date.now() / 1000) + DECRYPTION_DELAY; // Fetch encryption data from Shutter API console.log(`Fetching encryption data for decryption at timestamp ${decryptionTimestamp}...`); @@ -193,32 +196,27 @@ async function encrypt(message: string): Promise { * @returns Promise with the decrypted message */ async function decrypt(encryptedMessage: string): Promise { - try { - // Extract the identity from the encrypted message - // TODO: In a real implementation, you would need to store and retrieve the identity - // used for encryption. For now, we'll need to pass it as an additional parameter - // or store it alongside the encrypted message. - const identity = process.argv[4]; // Temporary solution: pass identity as an additional argument - if (!identity) { - throw new Error("Please provide the identity used for encryption as the third argument"); - } + // Extract the identity from the encrypted message + // TODO: In a real implementation, you would need to store and retrieve the identity + // used for encryption. For now, we'll need to pass it as an additional parameter + // or store it alongside the encrypted message. + const identity = process.argv[4]; // Temporary solution: pass identity as an additional argument + if (!identity) { + throw new Error("Please provide the identity used for encryption as the third argument"); + } - // Fetch the decryption key - const decryptionKeyData = await fetchDecryptionKey(identity); - console.log("Decryption key:", decryptionKeyData.decryption_key); + // Fetch the decryption key + const decryptionKeyData = await fetchDecryptionKey(identity); + console.log("Decryption key:", decryptionKeyData.decryption_key); - // Ensure the decryption key is properly formatted - const decryptionKey = ensureHexString(decryptionKeyData.decryption_key); + // Ensure the decryption key is properly formatted + const decryptionKey = ensureHexString(decryptionKeyData.decryption_key); - // Decrypt the message - const decryptedHexMessage = await shutterDecrypt(encryptedMessage, decryptionKey); + // Decrypt the message + const decryptedHexMessage = await shutterDecrypt(encryptedMessage, decryptionKey); - // Convert the decrypted hex message back to a string - return hexToString(decryptedHexMessage as `0x${string}`); - } catch (error) { - console.error("Error during decryption:", error); - throw error; - } + // Convert the decrypted hex message back to a string + return hexToString(decryptedHexMessage as `0x${string}`); } async function main() { From 04e3f5c42a06d961652aca9eb14acd1d60dd40bd Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Thu, 24 Apr 2025 20:58:06 +0100 Subject: [PATCH 04/13] feat: naive shutterized dispute kit and auto-voting bot --- contracts/deploy/09-shutter.ts | 26 +++ contracts/scripts/shutter.ts | 35 ++- contracts/scripts/shutterAutoVote.ts | 210 ++++++++++++++++++ .../dispute-kits/DisputeKitShutterPoC.sol | 105 +++++++++ 4 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 contracts/deploy/09-shutter.ts create mode 100644 contracts/scripts/shutterAutoVote.ts create mode 100644 contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol diff --git a/contracts/deploy/09-shutter.ts b/contracts/deploy/09-shutter.ts new file mode 100644 index 000000000..b8d53fb1a --- /dev/null +++ b/contracts/deploy/09-shutter.ts @@ -0,0 +1,26 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; +import { HomeChains, isSkipped } from "./utils"; + +const deployArbitration: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { + const { deployments, getNamedAccounts, getChainId } = hre; + const { deploy } = deployments; + + // fallback to hardhat node signers on local network + const deployer = (await getNamedAccounts()).deployer ?? (await hre.ethers.getSigners())[0].address; + const chainId = Number(await getChainId()); + console.log("deploying to %s with deployer %s", HomeChains[chainId], deployer); + + await deploy("DisputeKitShutterPoC", { + from: deployer, + args: [], + log: true, + }); +}; + +deployArbitration.tags = ["Shutter"]; +deployArbitration.skip = async ({ network }) => { + return isSkipped(network, !HomeChains[network.config.chainId ?? 0]); +}; + +export default deployArbitration; diff --git a/contracts/scripts/shutter.ts b/contracts/scripts/shutter.ts index aab9eff4a..f4d917d6d 100644 --- a/contracts/scripts/shutter.ts +++ b/contracts/scripts/shutter.ts @@ -4,7 +4,7 @@ import crypto from "crypto"; import "isomorphic-fetch"; /** Time in seconds to wait before the message can be decrypted */ -const DECRYPTION_DELAY = 120; // 2 minutes +export const DECRYPTION_DELAY = 20; interface ShutterApiMessageData { eon: number; @@ -160,9 +160,9 @@ function generateRandomBytes32(): `0x${string}` { /** * Encrypts a message using the Shutter API * @param message The message to encrypt - * @returns Promise with the encrypted commitment + * @returns Promise with the encrypted commitment and identity */ -async function encrypt(message: string): Promise { +export async function encrypt(message: string): Promise<{ encryptedCommitment: string; identity: string }> { // Set decryption timestamp const decryptionTimestamp = Math.floor(Date.now() / 1000) + DECRYPTION_DELAY; @@ -187,24 +187,16 @@ async function encrypt(message: string): Promise { // Encrypt the message const encryptedCommitment = await encryptData(msgHex, identityHex, eonKeyHex, sigmaHex); - return encryptedCommitment; + return { encryptedCommitment, identity: identityHex }; } /** * Decrypts a message using the Shutter API * @param encryptedMessage The encrypted message to decrypt + * @param identity The identity used for encryption * @returns Promise with the decrypted message */ -async function decrypt(encryptedMessage: string): Promise { - // Extract the identity from the encrypted message - // TODO: In a real implementation, you would need to store and retrieve the identity - // used for encryption. For now, we'll need to pass it as an additional parameter - // or store it alongside the encrypted message. - const identity = process.argv[4]; // Temporary solution: pass identity as an additional argument - if (!identity) { - throw new Error("Please provide the identity used for encryption as the third argument"); - } - +export async function decrypt(encryptedMessage: string, identity: string): Promise { // Fetch the decryption key const decryptionKeyData = await fetchDecryptionKey(identity); console.log("Decryption key:", decryptionKeyData.decryption_key); @@ -228,8 +220,8 @@ async function main() { Usage: yarn ts-node shutter.ts [arguments] Commands: - encrypt Encrypt a message - decrypt Decrypt a message (requires the identity used during encryption) + encrypt Encrypt a message + decrypt Decrypt a message (requires the identity used during encryption) Examples: yarn ts-node shutter.ts encrypt "my secret message" @@ -245,8 +237,9 @@ Examples: console.error("Usage: yarn ts-node shutter.ts encrypt "); process.exit(1); } - const encryptedCommitment = await encrypt(message); + const { encryptedCommitment, identity } = await encrypt(message); console.log("\nEncrypted Commitment:", encryptedCommitment); + console.log("Identity:", identity); break; } case "decrypt": { @@ -257,7 +250,7 @@ Examples: console.error("Note: The identity is the one returned during encryption"); process.exit(1); } - const decryptedMessage = await decrypt(encryptedMessage); + const decryptedMessage = await decrypt(encryptedMessage, identity); console.log("\nDecrypted Message:", decryptedMessage); break; } @@ -273,5 +266,7 @@ Examples: } } -// Execute the main function -main(); +// Execute if run directly +if (require.main === module) { + main(); +} diff --git a/contracts/scripts/shutterAutoVote.ts b/contracts/scripts/shutterAutoVote.ts new file mode 100644 index 000000000..f1990256d --- /dev/null +++ b/contracts/scripts/shutterAutoVote.ts @@ -0,0 +1,210 @@ +/** + * TODO: + * The goal is to automatically decrypt each encrypted voteID previously cast as encrypted commitments, and cast them as votes. + * - modify shutter.ts encrypt() to return {encryptedCommitment, identity} + * - implement shutterAutoVote.ts that: + * - provides a castCommit() function which + * - calls shutter.ts encrypt() with the message "disputeId␟voteId␟choice␟justification" with `U+241F` as separator + * - calls the DisputeKitShutterPoC.castCommit() function with the encryptedCommitment, voteId, choice and justification + * - retrieve the DisputeKitShutterPoC.CommitCast event and log its parameters + * - provides an autoVote() function which + * - runs continuously as a loop, waking up every 30 seconds + * - upon wake up, retrieve the details of the previously encrypted messages (and corresponding identities) which have not yet been decrypted and have been encrypted for more than shutter.ts `DECRYPTION_DELAY`. + * - for each of these messages, call shutter.ts decrypt() with the identity and the encryptedCommitment + * - if the decryption is successful, call the DisputeKitShutterPoC.castVote() function with the voteId, choice and justification + * - if the decryption is not successful, log an error + * - if the castVote() function was called, retrieve the DisputeKitShutterPoC.VoteCast event and log its parameters + * - shutterAutoVote.ts needs to know: + * - the _voteIDs: they start from 0 and go up to DisputeKitShutterPoC.maxVoteIDs() + * - the _coreDisputeID: just use 0 for now. + **/ + +import { createPublicClient, createWalletClient, http, Hex, decodeEventLog, Log, getContract } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { hardhat } from "viem/chains"; +import { encrypt, decrypt, DECRYPTION_DELAY } from "./shutter"; +import dotenv from "dotenv"; +import { abi as DisputeKitShutterPoCAbi } from "../deployments/localhost/DisputeKitShutterPoC.json"; + +// Load environment variables +dotenv.config(); + +// Constants +const SEPARATOR = "␟"; // U+241F + +// Validate environment variables +if (!process.env.PRIVATE_KEY) throw new Error("PRIVATE_KEY environment variable is required"); + +/** + * Split a hex string into bytes32 chunks + */ +function splitToBytes32(hex: string): Hex[] { + // Remove 0x prefix if present + const cleanHex = hex.startsWith("0x") ? hex.slice(2) : hex; + + // Split into chunks of 64 characters (32 bytes) + const chunks: Hex[] = []; + for (let i = 0; i < cleanHex.length; i += 64) { + const chunk = `0x${cleanHex.slice(i, i + 64)}` as Hex; + chunks.push(chunk); + } + + return chunks; +} + +// Store encrypted votes for later decryption +interface EncryptedVote { + encryptedCommitment: string; + identity: Hex; + timestamp: number; + voteId: bigint; +} + +const encryptedVotes: EncryptedVote[] = []; + +// Initialize Viem clients +const publicClient = createPublicClient({ + chain: hardhat, + transport: http(), +}); + +const PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as const; + +const account = privateKeyToAccount(PRIVATE_KEY); +const walletClient = createWalletClient({ + account, + chain: hardhat, + transport: http(), +}); + +const disputeKit = getContract({ + address: CONTRACT_ADDRESS, + abi: DisputeKitShutterPoCAbi, + client: { public: publicClient, wallet: walletClient }, +}); + +/** + * Cast an encrypted commit for a vote + */ +export async function castCommit({ + disputeId, + voteId, + choice, + justification, +}: { + disputeId: bigint; + voteId: bigint; + choice: bigint; + justification: string; +}) { + try { + // Create message with U+241F separator + const message = `${disputeId}${SEPARATOR}${voteId}${SEPARATOR}${choice}${SEPARATOR}${justification}`; + + // Encrypt the message + const { encryptedCommitment, identity } = await encrypt(message); + + // Split encrypted commitment into bytes32 chunks + const commitmentChunks = splitToBytes32(encryptedCommitment); + console.log("Commitment chunks:", commitmentChunks); + + // Cast the commit on-chain + const hash = await disputeKit.write.castCommit([disputeId, [voteId], encryptedCommitment as Hex, identity as Hex]); + + // Store encrypted vote for later decryption + encryptedVotes.push({ + encryptedCommitment, + identity: identity as Hex, + timestamp: Math.floor(Date.now() / 1000), + voteId, + }); + + // Watch for CommitCast event + const events = await disputeKit.getEvents.CommitCast(); + console.log("CommitCast event:", (events[0] as any).args); + + return { encryptedCommitment, identity }; + } catch (error) { + console.error("Error in castCommit:", error); + throw error; + } +} + +/** + * Continuously monitor for votes ready to be decrypted and cast + */ +export async function autoVote() { + while (true) { + try { + const currentTime = Math.floor(Date.now() / 1000); + + // Find votes ready for decryption + const readyVotes = encryptedVotes.filter((vote) => currentTime - vote.timestamp >= DECRYPTION_DELAY); + console.log("Ready votes:", readyVotes); + + for (const vote of readyVotes) { + try { + console.log("Decrypting vote:", vote); + + // Attempt to decrypt the vote + const decryptedMessage = await decrypt(vote.encryptedCommitment, vote.identity); + console.log("Decrypted message:", decryptedMessage); + + // Parse the decrypted message + const [, , choice, justification] = decryptedMessage.split(SEPARATOR); + + // Cast the vote on-chain + const hash = await disputeKit.write.castVote([ + BigInt(0), + [vote.voteId], + BigInt(choice), + justification, + vote.identity, + ]); + + // Watch for VoteCast event + const events = await disputeKit.getEvents.VoteCast(); + console.log("VoteCast event:", (events[0] as any).args); + + // Remove the processed vote + const index = encryptedVotes.indexOf(vote); + if (index > -1) encryptedVotes.splice(index, 1); + } catch (error) { + console.error(`Error processing vote ${vote.voteId}:`, error); + } + } + + // Sleep for 30 seconds + console.log("Sleeping for 30 seconds"); + await new Promise((resolve) => setTimeout(resolve, 30000)); + } catch (error) { + console.error("Error in autoVote loop:", error); + // Continue the loop even if there's an error + } + } +} + +// Main function to start the auto voting process +async function main() { + try { + // Cast an encrypted commit + await castCommit({ + disputeId: BigInt(0), + voteId: BigInt(0), + choice: BigInt(2), + justification: "This is my vote justification", + }); + + // Start the auto voting process + await autoVote(); + } catch (error) { + console.error("Error in main:", error); + process.exit(1); + } +} + +// Execute if run directly +if (require.main === module) { + main(); +} diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol new file mode 100644 index 000000000..6d8e5b991 --- /dev/null +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "hardhat/console.sol"; + +contract DisputeKitShutterPoC { + struct Vote { + address account; // The address of the juror. + bytes commit; // The commit of the juror. For courts with hidden votes. + bytes32 identity; // The Shutter identity. + uint256 choice; // The choice of the juror. + bool voted; // True if the vote has been cast. + } + + address public revealer; + Vote[] public votes; + uint256 public winningChoice; // The choice with the most votes. Note that in the case of a tie, it is the choice that reached the tied number of votes first. + mapping(uint256 => uint256) public counts; // The sum of votes for each choice in the form `counts[choice]`. + bool public tied; // True if there is a tie, false otherwise. + uint256 public totalCommitted; + uint256 public totalVoted; + + event CommitCast( + uint256 indexed _coreDisputeID, + address indexed _juror, + uint256[] _voteIDs, + bytes _commit, + bytes32 _identity + ); + + event VoteCast( + uint256 indexed _coreDisputeID, + address indexed _juror, + uint256[] _voteIDs, + uint256 indexed _choice, + string _justification + ); + + constructor() { + revealer = msg.sender; + address juror = msg.sender; + votes.push(Vote({account: juror, commit: bytes(""), identity: bytes32(0), choice: 0, voted: false})); + } + + function castCommit( + uint256 _coreDisputeID, + uint256[] calldata _voteIDs, + bytes calldata _commit, + bytes32 _identity + ) external { + // Store the commitment and identity for each voteID + for (uint256 i = 0; i < _voteIDs.length; i++) { + console.log("votes[_voteIDs[i]].account:", votes[_voteIDs[i]].account); + console.log("msg.sender:", msg.sender); + require(votes[_voteIDs[i]].account == msg.sender, "The caller has to own the vote."); + votes[_voteIDs[i]].commit = _commit; + votes[_voteIDs[i]].identity = _identity; + } + + totalCommitted += _voteIDs.length; + emit CommitCast(_coreDisputeID, msg.sender, _voteIDs, _commit, _identity); + } + + function castVote( + uint256 _coreDisputeID, + uint256[] calldata _voteIDs, + uint256 _choice, + string memory _justification, + bytes32 _identity + ) external { + require(_voteIDs.length > 0, "No voteID provided"); + require(revealer == msg.sender, "The caller has to own the vote."); + // TODO: what happens if hiddenVotes are not enabled? + for (uint256 i = 0; i < _voteIDs.length; i++) { + // Not useful to check the identity here? + require(votes[_voteIDs[i]].identity == _identity, "The identity has to match the commitment."); + + require(!votes[_voteIDs[i]].voted, "Vote already cast."); + votes[_voteIDs[i]].choice = _choice; + votes[_voteIDs[i]].voted = true; + } + + totalVoted += _voteIDs.length; + + counts[_choice] += _voteIDs.length; + if (_choice == winningChoice) { + if (tied) tied = false; + } else { + // Voted for another choice. + if (counts[_choice] == counts[winningChoice]) { + // Tie. + if (!tied) tied = true; + } else if (counts[_choice] > counts[winningChoice]) { + // New winner. + winningChoice = _choice; + tied = false; + } + } + emit VoteCast(_coreDisputeID, msg.sender, _voteIDs, _choice, _justification); + } + + function maxVoteIDs() public view returns (uint256) { + return votes.length - 1; + } +} From be6e1ce1d63d5cf344165d0ce925949342dc918f Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Tue, 29 Apr 2025 21:30:54 +0100 Subject: [PATCH 05/13] feat: commitment hashing and verification onchain --- contracts/scripts/shutterAutoVote.ts | 109 ++++++++---------- .../dispute-kits/DisputeKitShutterPoC.sol | 51 +++++--- 2 files changed, 82 insertions(+), 78 deletions(-) diff --git a/contracts/scripts/shutterAutoVote.ts b/contracts/scripts/shutterAutoVote.ts index f1990256d..6c1be9f75 100644 --- a/contracts/scripts/shutterAutoVote.ts +++ b/contracts/scripts/shutterAutoVote.ts @@ -19,63 +19,42 @@ * - the _coreDisputeID: just use 0 for now. **/ -import { createPublicClient, createWalletClient, http, Hex, decodeEventLog, Log, getContract } from "viem"; +import { createPublicClient, createWalletClient, http, Hex, decodeEventLog, getContract } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { hardhat } from "viem/chains"; import { encrypt, decrypt, DECRYPTION_DELAY } from "./shutter"; -import dotenv from "dotenv"; import { abi as DisputeKitShutterPoCAbi } from "../deployments/localhost/DisputeKitShutterPoC.json"; - -// Load environment variables -dotenv.config(); +import crypto from "crypto"; // Constants const SEPARATOR = "␟"; // U+241F -// Validate environment variables -if (!process.env.PRIVATE_KEY) throw new Error("PRIVATE_KEY environment variable is required"); - -/** - * Split a hex string into bytes32 chunks - */ -function splitToBytes32(hex: string): Hex[] { - // Remove 0x prefix if present - const cleanHex = hex.startsWith("0x") ? hex.slice(2) : hex; - - // Split into chunks of 64 characters (32 bytes) - const chunks: Hex[] = []; - for (let i = 0; i < cleanHex.length; i += 64) { - const chunk = `0x${cleanHex.slice(i, i + 64)}` as Hex; - chunks.push(chunk); - } - - return chunks; -} - // Store encrypted votes for later decryption interface EncryptedVote { encryptedCommitment: string; identity: Hex; timestamp: number; voteId: bigint; + salt: Hex; } const encryptedVotes: EncryptedVote[] = []; -// Initialize Viem clients +const PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; + +const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as const; + +const transport = http(); const publicClient = createPublicClient({ chain: hardhat, - transport: http(), + transport, }); -const PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; -const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as const; - const account = privateKeyToAccount(PRIVATE_KEY); const walletClient = createWalletClient({ account, chain: hardhat, - transport: http(), + transport, }); const disputeKit = getContract({ @@ -85,46 +64,57 @@ const disputeKit = getContract({ }); /** - * Cast an encrypted commit for a vote + * Generate a random salt */ -export async function castCommit({ - disputeId, - voteId, +function generateSalt(): Hex { + return ("0x" + crypto.randomBytes(32).toString("hex")) as Hex; +} + +/** + * Cast a commit on-chain + */ +async function castCommit({ + coreDisputeID, + voteIDs, choice, justification, }: { - disputeId: bigint; - voteId: bigint; + coreDisputeID: bigint; + voteIDs: bigint[]; choice: bigint; justification: string; }) { try { // Create message with U+241F separator - const message = `${disputeId}${SEPARATOR}${voteId}${SEPARATOR}${choice}${SEPARATOR}${justification}`; + const message = `${coreDisputeID}${SEPARATOR}${voteIDs[0]}${SEPARATOR}${choice}${SEPARATOR}${justification}`; - // Encrypt the message + // Encrypt the message using shutter.ts const { encryptedCommitment, identity } = await encrypt(message); - // Split encrypted commitment into bytes32 chunks - const commitmentChunks = splitToBytes32(encryptedCommitment); - console.log("Commitment chunks:", commitmentChunks); + // Generate salt and compute hash + const salt = generateSalt(); + const commitHash = await disputeKit.read.hashVote([coreDisputeID, voteIDs[0], choice, justification, salt]); // Cast the commit on-chain - const hash = await disputeKit.write.castCommit([disputeId, [voteId], encryptedCommitment as Hex, identity as Hex]); + const txHash = await disputeKit.write.castCommit([coreDisputeID, voteIDs, commitHash, identity as Hex]); + + // Wait for transaction to be mined + await publicClient.waitForTransactionReceipt({ hash: txHash }); + + // Watch for CommitCast event + const events = await disputeKit.getEvents.CommitCast(); + console.log("CommitCast event:", (events[0] as any).args); // Store encrypted vote for later decryption encryptedVotes.push({ encryptedCommitment, identity: identity as Hex, timestamp: Math.floor(Date.now() / 1000), - voteId, + voteId: voteIDs[0], + salt, }); - // Watch for CommitCast event - const events = await disputeKit.getEvents.CommitCast(); - console.log("CommitCast event:", (events[0] as any).args); - - return { encryptedCommitment, identity }; + return { commitHash, identity, salt }; } catch (error) { console.error("Error in castCommit:", error); throw error; @@ -140,29 +130,28 @@ export async function autoVote() { const currentTime = Math.floor(Date.now() / 1000); // Find votes ready for decryption - const readyVotes = encryptedVotes.filter((vote) => currentTime - vote.timestamp >= DECRYPTION_DELAY); - console.log("Ready votes:", readyVotes); + const readyVotes = encryptedVotes.filter((vote) => currentTime - vote.timestamp >= DECRYPTION_DELAY + 10); for (const vote of readyVotes) { try { - console.log("Decrypting vote:", vote); - // Attempt to decrypt the vote const decryptedMessage = await decrypt(vote.encryptedCommitment, vote.identity); - console.log("Decrypted message:", decryptedMessage); // Parse the decrypted message - const [, , choice, justification] = decryptedMessage.split(SEPARATOR); + const [coreDisputeID, , choice, justification] = decryptedMessage.split(SEPARATOR); // Cast the vote on-chain - const hash = await disputeKit.write.castVote([ - BigInt(0), + const txHash = await disputeKit.write.castVote([ + BigInt(coreDisputeID), [vote.voteId], BigInt(choice), justification, - vote.identity, + vote.salt, ]); + // Wait for transaction to be mined + await publicClient.waitForTransactionReceipt({ hash: txHash }); + // Watch for VoteCast event const events = await disputeKit.getEvents.VoteCast(); console.log("VoteCast event:", (events[0] as any).args); @@ -190,8 +179,8 @@ async function main() { try { // Cast an encrypted commit await castCommit({ - disputeId: BigInt(0), - voteId: BigInt(0), + coreDisputeID: BigInt(0), + voteIDs: [BigInt(0)], choice: BigInt(2), justification: "This is my vote justification", }); diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol index 6d8e5b991..a5e5576ef 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol @@ -6,13 +6,11 @@ import "hardhat/console.sol"; contract DisputeKitShutterPoC { struct Vote { address account; // The address of the juror. - bytes commit; // The commit of the juror. For courts with hidden votes. - bytes32 identity; // The Shutter identity. + bytes32 commitHash; // The hash of the encrypted message + salt uint256 choice; // The choice of the juror. bool voted; // True if the vote has been cast. } - address public revealer; Vote[] public votes; uint256 public winningChoice; // The choice with the most votes. Note that in the case of a tie, it is the choice that reached the tied number of votes first. mapping(uint256 => uint256) public counts; // The sum of votes for each choice in the form `counts[choice]`. @@ -24,7 +22,7 @@ contract DisputeKitShutterPoC { uint256 indexed _coreDisputeID, address indexed _juror, uint256[] _voteIDs, - bytes _commit, + bytes32 _commitHash, bytes32 _identity ); @@ -37,28 +35,44 @@ contract DisputeKitShutterPoC { ); constructor() { - revealer = msg.sender; address juror = msg.sender; - votes.push(Vote({account: juror, commit: bytes(""), identity: bytes32(0), choice: 0, voted: false})); + votes.push(Vote({account: juror, commitHash: bytes32(0), choice: 0, voted: false})); + } + + /** + * @dev Computes the hash of a vote using ABI encoding + * @param _coreDisputeID The ID of the core dispute + * @param _voteID The ID of the vote + * @param _choice The choice being voted for + * @param _justification The justification for the vote + * @param _salt A random salt for commitment + * @return bytes32 The hash of the encoded vote parameters + */ + function hashVote( + uint256 _coreDisputeID, + uint256 _voteID, + uint256 _choice, + string memory _justification, + bytes32 _salt + ) public pure returns (bytes32) { + bytes32 justificationHash = keccak256(bytes(_justification)); + return keccak256(abi.encode(_coreDisputeID, _voteID, _choice, justificationHash, _salt)); } function castCommit( uint256 _coreDisputeID, uint256[] calldata _voteIDs, - bytes calldata _commit, + bytes32 _commitHash, bytes32 _identity ) external { - // Store the commitment and identity for each voteID + // Store the commitment hash for each voteID for (uint256 i = 0; i < _voteIDs.length; i++) { - console.log("votes[_voteIDs[i]].account:", votes[_voteIDs[i]].account); - console.log("msg.sender:", msg.sender); require(votes[_voteIDs[i]].account == msg.sender, "The caller has to own the vote."); - votes[_voteIDs[i]].commit = _commit; - votes[_voteIDs[i]].identity = _identity; + votes[_voteIDs[i]].commitHash = _commitHash; } totalCommitted += _voteIDs.length; - emit CommitCast(_coreDisputeID, msg.sender, _voteIDs, _commit, _identity); + emit CommitCast(_coreDisputeID, msg.sender, _voteIDs, _commitHash, _identity); } function castVote( @@ -66,15 +80,16 @@ contract DisputeKitShutterPoC { uint256[] calldata _voteIDs, uint256 _choice, string memory _justification, - bytes32 _identity + bytes32 _salt ) external { require(_voteIDs.length > 0, "No voteID provided"); - require(revealer == msg.sender, "The caller has to own the vote."); + // TODO: what happens if hiddenVotes are not enabled? - for (uint256 i = 0; i < _voteIDs.length; i++) { - // Not useful to check the identity here? - require(votes[_voteIDs[i]].identity == _identity, "The identity has to match the commitment."); + for (uint256 i = 0; i < _voteIDs.length; i++) { + // Verify the commitment hash + bytes32 computedHash = hashVote(_coreDisputeID, _voteIDs[i], _choice, _justification, _salt); + require(votes[_voteIDs[i]].commitHash == computedHash, "The commitment hash does not match."); require(!votes[_voteIDs[i]].voted, "Vote already cast."); votes[_voteIDs[i]].choice = _choice; votes[_voteIDs[i]].voted = true; From 020dbabc2eec92a99886a8da002db092e1e666e4 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Tue, 29 Apr 2025 21:35:18 +0100 Subject: [PATCH 06/13] chore: cleanup --- contracts/scripts/shutterAutoVote.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/contracts/scripts/shutterAutoVote.ts b/contracts/scripts/shutterAutoVote.ts index 6c1be9f75..4393ff943 100644 --- a/contracts/scripts/shutterAutoVote.ts +++ b/contracts/scripts/shutterAutoVote.ts @@ -1,25 +1,4 @@ -/** - * TODO: - * The goal is to automatically decrypt each encrypted voteID previously cast as encrypted commitments, and cast them as votes. - * - modify shutter.ts encrypt() to return {encryptedCommitment, identity} - * - implement shutterAutoVote.ts that: - * - provides a castCommit() function which - * - calls shutter.ts encrypt() with the message "disputeId␟voteId␟choice␟justification" with `U+241F` as separator - * - calls the DisputeKitShutterPoC.castCommit() function with the encryptedCommitment, voteId, choice and justification - * - retrieve the DisputeKitShutterPoC.CommitCast event and log its parameters - * - provides an autoVote() function which - * - runs continuously as a loop, waking up every 30 seconds - * - upon wake up, retrieve the details of the previously encrypted messages (and corresponding identities) which have not yet been decrypted and have been encrypted for more than shutter.ts `DECRYPTION_DELAY`. - * - for each of these messages, call shutter.ts decrypt() with the identity and the encryptedCommitment - * - if the decryption is successful, call the DisputeKitShutterPoC.castVote() function with the voteId, choice and justification - * - if the decryption is not successful, log an error - * - if the castVote() function was called, retrieve the DisputeKitShutterPoC.VoteCast event and log its parameters - * - shutterAutoVote.ts needs to know: - * - the _voteIDs: they start from 0 and go up to DisputeKitShutterPoC.maxVoteIDs() - * - the _coreDisputeID: just use 0 for now. - **/ - -import { createPublicClient, createWalletClient, http, Hex, decodeEventLog, getContract } from "viem"; +import { createPublicClient, createWalletClient, http, Hex, getContract } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { hardhat } from "viem/chains"; import { encrypt, decrypt, DECRYPTION_DELAY } from "./shutter"; From 30804a858765c26882a4361cf8d8e5bc78e89990 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 30 Apr 2025 22:00:55 +0100 Subject: [PATCH 07/13] feat: support for multiple voteIDs at once, fixed salt handling by bot --- contracts/scripts/shutterAutoVote.ts | 87 ++++++++++++++----- .../dispute-kits/DisputeKitShutterPoC.sol | 14 +-- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/contracts/scripts/shutterAutoVote.ts b/contracts/scripts/shutterAutoVote.ts index 4393ff943..35920476d 100644 --- a/contracts/scripts/shutterAutoVote.ts +++ b/contracts/scripts/shutterAutoVote.ts @@ -1,5 +1,5 @@ import { createPublicClient, createWalletClient, http, Hex, getContract } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; +import { mnemonicToAccount } from "viem/accounts"; import { hardhat } from "viem/chains"; import { encrypt, decrypt, DECRYPTION_DELAY } from "./shutter"; import { abi as DisputeKitShutterPoCAbi } from "../deployments/localhost/DisputeKitShutterPoC.json"; @@ -9,19 +9,20 @@ import crypto from "crypto"; const SEPARATOR = "␟"; // U+241F // Store encrypted votes for later decryption -interface EncryptedVote { +type EncryptedVote = { encryptedCommitment: string; identity: Hex; timestamp: number; - voteId: bigint; + voteIDs: bigint[]; salt: Hex; -} +}; const encryptedVotes: EncryptedVote[] = []; -const PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +const disputeKitAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as const; -const CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as const; +const MNEMONIC = "test test test test test test test test test test test junk"; +const account = mnemonicToAccount(MNEMONIC); const transport = http(); const publicClient = createPublicClient({ @@ -29,7 +30,6 @@ const publicClient = createPublicClient({ transport, }); -const account = privateKeyToAccount(PRIVATE_KEY); const walletClient = createWalletClient({ account, chain: hardhat, @@ -37,7 +37,7 @@ const walletClient = createWalletClient({ }); const disputeKit = getContract({ - address: CONTRACT_ADDRESS, + address: disputeKitAddress, abi: DisputeKitShutterPoCAbi, client: { public: publicClient, wallet: walletClient }, }); @@ -49,6 +49,41 @@ function generateSalt(): Hex { return ("0x" + crypto.randomBytes(32).toString("hex")) as Hex; } +/** + * Encodes vote parameters into a message string with separators + */ +function encode({ + coreDisputeID, + voteIDs, + choice, + justification, + salt, +}: { + coreDisputeID: bigint; + voteIDs: bigint[]; + choice: bigint; + justification: string; + salt: Hex; +}): string { + return `${coreDisputeID}${SEPARATOR}${voteIDs.join(",")}${SEPARATOR}${choice}${SEPARATOR}${justification}${SEPARATOR}${salt}`; +} + +/** + * Decodes a message string into its component parts + * @param message The message to decode + * @returns Object containing the decoded components + */ +function decode(message: string) { + const [coreDisputeID, voteIDsStr, choice, justification, salt] = message.split(SEPARATOR); + return { + coreDisputeID, + voteIDs: voteIDsStr.split(",").map((id) => BigInt(id)), + choice, + justification, + salt, + }; +} + /** * Cast a commit on-chain */ @@ -64,15 +99,23 @@ async function castCommit({ justification: string; }) { try { - // Create message with U+241F separator - const message = `${coreDisputeID}${SEPARATOR}${voteIDs[0]}${SEPARATOR}${choice}${SEPARATOR}${justification}`; + // Generate salt first + const salt = generateSalt(); + + // Encode the vote parameters into a message + const message = encode({ + coreDisputeID, + voteIDs, + choice, + justification, + salt, + }); // Encrypt the message using shutter.ts const { encryptedCommitment, identity } = await encrypt(message); - // Generate salt and compute hash - const salt = generateSalt(); - const commitHash = await disputeKit.read.hashVote([coreDisputeID, voteIDs[0], choice, justification, salt]); + // Compute hash using all vote IDs + const commitHash = await disputeKit.read.hashVote([coreDisputeID, voteIDs, choice, justification, salt]); // Cast the commit on-chain const txHash = await disputeKit.write.castCommit([coreDisputeID, voteIDs, commitHash, identity as Hex]); @@ -89,7 +132,7 @@ async function castCommit({ encryptedCommitment, identity: identity as Hex, timestamp: Math.floor(Date.now() / 1000), - voteId: voteIDs[0], + voteIDs, salt, }); @@ -116,16 +159,16 @@ export async function autoVote() { // Attempt to decrypt the vote const decryptedMessage = await decrypt(vote.encryptedCommitment, vote.identity); - // Parse the decrypted message - const [coreDisputeID, , choice, justification] = decryptedMessage.split(SEPARATOR); + // Decode the decrypted message + const { coreDisputeID, voteIDs, choice, justification, salt } = decode(decryptedMessage); // Cast the vote on-chain const txHash = await disputeKit.write.castVote([ BigInt(coreDisputeID), - [vote.voteId], + voteIDs, BigInt(choice), justification, - vote.salt, + salt, ]); // Wait for transaction to be mined @@ -139,7 +182,7 @@ export async function autoVote() { const index = encryptedVotes.indexOf(vote); if (index > -1) encryptedVotes.splice(index, 1); } catch (error) { - console.error(`Error processing vote ${vote.voteId}:`, error); + console.error(`Error processing vote ${vote.voteIDs.join(",")}:`, error); } } @@ -158,9 +201,9 @@ async function main() { try { // Cast an encrypted commit await castCommit({ - coreDisputeID: BigInt(0), - voteIDs: [BigInt(0)], - choice: BigInt(2), + coreDisputeID: 0n, + voteIDs: [0n, 1n, 2n], + choice: 2n, justification: "This is my vote justification", }); diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol index a5e5576ef..20c6f6f88 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol @@ -37,12 +37,14 @@ contract DisputeKitShutterPoC { constructor() { address juror = msg.sender; votes.push(Vote({account: juror, commitHash: bytes32(0), choice: 0, voted: false})); + votes.push(Vote({account: juror, commitHash: bytes32(0), choice: 0, voted: false})); + votes.push(Vote({account: juror, commitHash: bytes32(0), choice: 0, voted: false})); } /** * @dev Computes the hash of a vote using ABI encoding * @param _coreDisputeID The ID of the core dispute - * @param _voteID The ID of the vote + * @param _voteIDs Array of vote IDs * @param _choice The choice being voted for * @param _justification The justification for the vote * @param _salt A random salt for commitment @@ -50,13 +52,14 @@ contract DisputeKitShutterPoC { */ function hashVote( uint256 _coreDisputeID, - uint256 _voteID, + uint256[] calldata _voteIDs, uint256 _choice, string memory _justification, bytes32 _salt ) public pure returns (bytes32) { bytes32 justificationHash = keccak256(bytes(_justification)); - return keccak256(abi.encode(_coreDisputeID, _voteID, _choice, justificationHash, _salt)); + bytes32 voteIDsHash = keccak256(abi.encodePacked(_voteIDs)); + return keccak256(abi.encode(_coreDisputeID, voteIDsHash, _choice, justificationHash, _salt)); } function castCommit( @@ -86,9 +89,10 @@ contract DisputeKitShutterPoC { // TODO: what happens if hiddenVotes are not enabled? + // Verify the commitment hash for all votes at once + bytes32 computedHash = hashVote(_coreDisputeID, _voteIDs, _choice, _justification, _salt); + for (uint256 i = 0; i < _voteIDs.length; i++) { - // Verify the commitment hash - bytes32 computedHash = hashVote(_coreDisputeID, _voteIDs[i], _choice, _justification, _salt); require(votes[_voteIDs[i]].commitHash == computedHash, "The commitment hash does not match."); require(!votes[_voteIDs[i]].voted, "Vote already cast."); votes[_voteIDs[i]].choice = _choice; From 1a9b72db339db27b46b06b8ce8ec028272d4ad93 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 30 Apr 2025 22:56:50 +0100 Subject: [PATCH 08/13] chore: cleanup before integration into a fully fletched dispute kit --- contracts/scripts/shutter.ts | 9 +--- contracts/scripts/shutterAutoVote.ts | 50 +++++++------------ .../dispute-kits/DisputeKitShutterPoC.sol | 37 ++++++-------- 3 files changed, 34 insertions(+), 62 deletions(-) diff --git a/contracts/scripts/shutter.ts b/contracts/scripts/shutter.ts index f4d917d6d..a1c05e1c3 100644 --- a/contracts/scripts/shutter.ts +++ b/contracts/scripts/shutter.ts @@ -3,8 +3,8 @@ import { Hex, stringToHex, hexToString } from "viem"; import crypto from "crypto"; import "isomorphic-fetch"; -/** Time in seconds to wait before the message can be decrypted */ -export const DECRYPTION_DELAY = 20; +// Time in seconds to wait before the message can be decrypted +export const DECRYPTION_DELAY = 5; interface ShutterApiMessageData { eon: number; @@ -25,11 +25,6 @@ interface ShutterDecryptionKeyData { decryption_timestamp: number; } -interface ShutterDecryptionKeyResponse { - message: ShutterDecryptionKeyData; - error?: string; -} - /** * Fetches encryption data from the Shutter API * @param decryptionTimestamp Unix timestamp when decryption should be possible diff --git a/contracts/scripts/shutterAutoVote.ts b/contracts/scripts/shutterAutoVote.ts index 35920476d..9a6c4fe47 100644 --- a/contracts/scripts/shutterAutoVote.ts +++ b/contracts/scripts/shutterAutoVote.ts @@ -10,6 +10,7 @@ const SEPARATOR = "␟"; // U+241F // Store encrypted votes for later decryption type EncryptedVote = { + coreDisputeID: bigint; encryptedCommitment: string; identity: Hex; timestamp: number; @@ -52,20 +53,8 @@ function generateSalt(): Hex { /** * Encodes vote parameters into a message string with separators */ -function encode({ - coreDisputeID, - voteIDs, - choice, - justification, - salt, -}: { - coreDisputeID: bigint; - voteIDs: bigint[]; - choice: bigint; - justification: string; - salt: Hex; -}): string { - return `${coreDisputeID}${SEPARATOR}${voteIDs.join(",")}${SEPARATOR}${choice}${SEPARATOR}${justification}${SEPARATOR}${salt}`; +function encode({ choice, salt, justification }: { choice: bigint; salt: Hex; justification: string }): string { + return `${choice}${SEPARATOR}${salt}${SEPARATOR}${justification}`; } /** @@ -74,13 +63,11 @@ function encode({ * @returns Object containing the decoded components */ function decode(message: string) { - const [coreDisputeID, voteIDsStr, choice, justification, salt] = message.split(SEPARATOR); + const [choice, salt, justification] = message.split(SEPARATOR); return { - coreDisputeID, - voteIDs: voteIDsStr.split(",").map((id) => BigInt(id)), - choice, - justification, + choice: BigInt(choice), salt, + justification, }; } @@ -104,18 +91,16 @@ async function castCommit({ // Encode the vote parameters into a message const message = encode({ - coreDisputeID, - voteIDs, choice, - justification, salt, + justification, }); // Encrypt the message using shutter.ts const { encryptedCommitment, identity } = await encrypt(message); // Compute hash using all vote IDs - const commitHash = await disputeKit.read.hashVote([coreDisputeID, voteIDs, choice, justification, salt]); + const commitHash = await disputeKit.read.hashVote([choice, salt, justification]); // Cast the commit on-chain const txHash = await disputeKit.write.castCommit([coreDisputeID, voteIDs, commitHash, identity as Hex]); @@ -129,6 +114,7 @@ async function castCommit({ // Store encrypted vote for later decryption encryptedVotes.push({ + coreDisputeID, encryptedCommitment, identity: identity as Hex, timestamp: Math.floor(Date.now() / 1000), @@ -150,9 +136,10 @@ export async function autoVote() { while (true) { try { const currentTime = Math.floor(Date.now() / 1000); + const sleep = DECRYPTION_DELAY + 10; // Find votes ready for decryption - const readyVotes = encryptedVotes.filter((vote) => currentTime - vote.timestamp >= DECRYPTION_DELAY + 10); + const readyVotes = encryptedVotes.filter((vote) => currentTime - vote.timestamp >= sleep); for (const vote of readyVotes) { try { @@ -160,15 +147,15 @@ export async function autoVote() { const decryptedMessage = await decrypt(vote.encryptedCommitment, vote.identity); // Decode the decrypted message - const { coreDisputeID, voteIDs, choice, justification, salt } = decode(decryptedMessage); + const { choice, salt, justification } = decode(decryptedMessage); // Cast the vote on-chain const txHash = await disputeKit.write.castVote([ - BigInt(coreDisputeID), - voteIDs, - BigInt(choice), - justification, + vote.coreDisputeID, + vote.voteIDs, + choice, salt, + justification, ]); // Wait for transaction to be mined @@ -186,9 +173,8 @@ export async function autoVote() { } } - // Sleep for 30 seconds - console.log("Sleeping for 30 seconds"); - await new Promise((resolve) => setTimeout(resolve, 30000)); + console.log(`Sleeping for ${sleep} seconds`); + await new Promise((resolve) => setTimeout(resolve, sleep * 1000)); } catch (error) { console.error("Error in autoVote loop:", error); // Continue the loop even if there's an error diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol index 20c6f6f88..4d2d76add 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol @@ -6,7 +6,7 @@ import "hardhat/console.sol"; contract DisputeKitShutterPoC { struct Vote { address account; // The address of the juror. - bytes32 commitHash; // The hash of the encrypted message + salt + bytes32 commit; // The hash of the encrypted message + salt uint256 choice; // The choice of the juror. bool voted; // True if the vote has been cast. } @@ -22,7 +22,7 @@ contract DisputeKitShutterPoC { uint256 indexed _coreDisputeID, address indexed _juror, uint256[] _voteIDs, - bytes32 _commitHash, + bytes32 _commit, bytes32 _identity ); @@ -36,64 +36,55 @@ contract DisputeKitShutterPoC { constructor() { address juror = msg.sender; - votes.push(Vote({account: juror, commitHash: bytes32(0), choice: 0, voted: false})); - votes.push(Vote({account: juror, commitHash: bytes32(0), choice: 0, voted: false})); - votes.push(Vote({account: juror, commitHash: bytes32(0), choice: 0, voted: false})); + votes.push(Vote({account: juror, commit: bytes32(0), choice: 0, voted: false})); + votes.push(Vote({account: juror, commit: bytes32(0), choice: 0, voted: false})); + votes.push(Vote({account: juror, commit: bytes32(0), choice: 0, voted: false})); } /** * @dev Computes the hash of a vote using ABI encoding - * @param _coreDisputeID The ID of the core dispute - * @param _voteIDs Array of vote IDs * @param _choice The choice being voted for * @param _justification The justification for the vote * @param _salt A random salt for commitment * @return bytes32 The hash of the encoded vote parameters */ - function hashVote( - uint256 _coreDisputeID, - uint256[] calldata _voteIDs, - uint256 _choice, - string memory _justification, - bytes32 _salt - ) public pure returns (bytes32) { + function hashVote(uint256 _choice, bytes32 _salt, string memory _justification) public pure returns (bytes32) { bytes32 justificationHash = keccak256(bytes(_justification)); - bytes32 voteIDsHash = keccak256(abi.encodePacked(_voteIDs)); - return keccak256(abi.encode(_coreDisputeID, voteIDsHash, _choice, justificationHash, _salt)); + return keccak256(abi.encode(_choice, _salt, justificationHash)); } function castCommit( uint256 _coreDisputeID, uint256[] calldata _voteIDs, - bytes32 _commitHash, + bytes32 _commit, bytes32 _identity ) external { // Store the commitment hash for each voteID for (uint256 i = 0; i < _voteIDs.length; i++) { require(votes[_voteIDs[i]].account == msg.sender, "The caller has to own the vote."); - votes[_voteIDs[i]].commitHash = _commitHash; + votes[_voteIDs[i]].commit = _commit; } totalCommitted += _voteIDs.length; - emit CommitCast(_coreDisputeID, msg.sender, _voteIDs, _commitHash, _identity); + emit CommitCast(_coreDisputeID, msg.sender, _voteIDs, _commit, _identity); } function castVote( uint256 _coreDisputeID, uint256[] calldata _voteIDs, uint256 _choice, - string memory _justification, - bytes32 _salt + bytes32 _salt, + string memory _justification ) external { require(_voteIDs.length > 0, "No voteID provided"); // TODO: what happens if hiddenVotes are not enabled? // Verify the commitment hash for all votes at once - bytes32 computedHash = hashVote(_coreDisputeID, _voteIDs, _choice, _justification, _salt); + bytes32 computedHash = hashVote(_choice, _salt, _justification); for (uint256 i = 0; i < _voteIDs.length; i++) { - require(votes[_voteIDs[i]].commitHash == computedHash, "The commitment hash does not match."); + require(votes[_voteIDs[i]].commit == computedHash, "The commitment hash does not match."); require(!votes[_voteIDs[i]].voted, "Vote already cast."); votes[_voteIDs[i]].choice = _choice; votes[_voteIDs[i]].voted = true; From b5805561e5f96bb59d0b7e1baefad5fa2d1bb07e Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 30 Apr 2025 23:09:19 +0100 Subject: [PATCH 09/13] feat: fully fletched DisputeKitShutter --- .../dispute-kits/DisputeKitClassic.sol | 2 +- .../dispute-kits/DisputeKitClassicBase.sol | 23 ++++++++++++++++--- .../dispute-kits/DisputeKitGated.sol | 2 +- contracts/test/foundry/KlerosCore.t.sol | 4 ++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol index 3aacdcea7..97dfcd2ba 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol @@ -11,7 +11,7 @@ import {DisputeKitClassicBase, KlerosCore} from "./DisputeKitClassicBase.sol"; /// - an incentive system: equal split between coherent votes, /// - an appeal system: fund 2 choices only, vote on any choice. contract DisputeKitClassic is DisputeKitClassicBase { - string public constant override version = "0.8.0"; + string public constant override version = "0.9.0"; // ************************************* // // * Constructor * // diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol index ff6291e58..85a9cd8cb 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol @@ -238,7 +238,7 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi /// `n` is the number of votes. /// @param _coreDisputeID The ID of the dispute in Kleros Core. /// @param _voteIDs The IDs of the votes. - /// @param _commit The commit. Note that justification string is a part of the commit. + /// @param _commit The commitment hash. function castCommit( uint256 _coreDisputeID, uint256[] calldata _voteIDs, @@ -283,13 +283,14 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi Round storage round = dispute.rounds[dispute.rounds.length - 1]; (uint96 courtID, , , , ) = core.disputes(_coreDisputeID); (, bool hiddenVotes, , , , , ) = core.courts(courtID); + bytes32 voteHash = hashVote(_choice, _salt, _justification); // Save the votes. for (uint256 i = 0; i < _voteIDs.length; i++) { require(round.votes[_voteIDs[i]].account == msg.sender, "The caller has to own the vote."); require( - !hiddenVotes || round.votes[_voteIDs[i]].commit == keccak256(abi.encodePacked(_choice, _salt)), - "The commit must match the choice in courts with hidden votes." + !hiddenVotes || round.votes[_voteIDs[i]].commit == voteHash, + "The vote hash must match the commitment in courts with hidden votes." ); require(!round.votes[_voteIDs[i]].voted, "Vote already cast."); round.votes[_voteIDs[i]].choice = _choice; @@ -435,6 +436,22 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi // * Public Views * // // ************************************* // + /** + * @dev Computes the hash of a vote using ABI encoding + * @dev The unused parameters may be used by overriding contracts. + * @param _choice The choice being voted for + * @param _justification The justification for the vote + * @param _salt A random salt for commitment + * @return bytes32 The hash of the encoded vote parameters + */ + function hashVote( + uint256 _choice, + uint256 _salt, + string memory _justification + ) public pure virtual returns (bytes32) { + return keccak256(abi.encodePacked(_choice, _salt)); + } + function getFundedChoices(uint256 _coreDisputeID) public view returns (uint256[] memory fundedChoices) { Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]]; Round storage lastRound = dispute.rounds[dispute.rounds.length - 1]; diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol index c3788c432..1e9165048 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol @@ -27,7 +27,7 @@ interface IBalanceHolderERC1155 { /// - an incentive system: equal split between coherent votes, /// - an appeal system: fund 2 choices only, vote on any choice. contract DisputeKitGated is DisputeKitClassicBase { - string public constant override version = "0.8.0"; + string public constant override version = "0.9.0"; // ************************************* // // * Storage * // diff --git a/contracts/test/foundry/KlerosCore.t.sol b/contracts/test/foundry/KlerosCore.t.sol index 291b969d0..16d81cbe2 100644 --- a/contracts/test/foundry/KlerosCore.t.sol +++ b/contracts/test/foundry/KlerosCore.t.sol @@ -1654,11 +1654,11 @@ contract KlerosCoreTest is Test { // Check the require with the wrong choice and then with the wrong salt vm.prank(staker1); - vm.expectRevert(bytes("The commit must match the choice in courts with hidden votes.")); + vm.expectRevert(bytes("The vote hash must match the commitment in courts with hidden votes.")); disputeKit.castVote(disputeID, voteIDs, 2, salt, "XYZ"); vm.prank(staker1); - vm.expectRevert(bytes("The commit must match the choice in courts with hidden votes.")); + vm.expectRevert(bytes("The vote hash must match the commitment in courts with hidden votes.")); disputeKit.castVote(disputeID, voteIDs, YES, salt - 1, "XYZ"); vm.prank(staker1); From 8819241b0f2e6adb42f79629b8975f4607fd3fb6 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 30 Apr 2025 23:10:57 +0100 Subject: [PATCH 10/13] chore: removed redundant node-fetch --- contracts/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/contracts/package.json b/contracts/package.json index 101f5ec34..a1b1a8b5d 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -140,7 +140,6 @@ "hardhat-gas-reporter": "^2.2.2", "hardhat-tracer": "^3.1.0", "hardhat-watcher": "^2.5.0", - "node-fetch": "^3.3.2", "pino": "^8.21.0", "pino-pretty": "^10.3.1", "prettier": "^3.3.3", diff --git a/yarn.lock b/yarn.lock index f46f859f8..6ea49d46d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5587,7 +5587,6 @@ __metadata: hardhat-tracer: "npm:^3.1.0" hardhat-watcher: "npm:^2.5.0" isomorphic-fetch: "npm:^3.0.0" - node-fetch: "npm:^3.3.2" pino: "npm:^8.21.0" pino-pretty: "npm:^10.3.1" prettier: "npm:^3.3.3" From cd016b3dc880a3b523c48422c72be585791475a3 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 30 Apr 2025 23:14:48 +0100 Subject: [PATCH 11/13] chore: cleanup --- .../src/arbitration/dispute-kits/DisputeKitShutterPoC.sol | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol index 4d2d76add..31b8617d1 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol @@ -108,8 +108,4 @@ contract DisputeKitShutterPoC { } emit VoteCast(_coreDisputeID, msg.sender, _voteIDs, _choice, _justification); } - - function maxVoteIDs() public view returns (uint256) { - return votes.length - 1; - } } From 7fd59976d82fed763939d31f477b7d0bdc633df7 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Wed, 30 Apr 2025 23:30:42 +0100 Subject: [PATCH 12/13] chore: reverted changes to the PoC --- .../dispute-kits/DisputeKitClassic.sol | 2 +- .../dispute-kits/DisputeKitClassicBase.sol | 23 +++---------------- .../dispute-kits/DisputeKitGated.sol | 2 +- contracts/test/foundry/KlerosCore.t.sol | 4 ++-- 4 files changed, 7 insertions(+), 24 deletions(-) diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol index 97dfcd2ba..3aacdcea7 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassic.sol @@ -11,7 +11,7 @@ import {DisputeKitClassicBase, KlerosCore} from "./DisputeKitClassicBase.sol"; /// - an incentive system: equal split between coherent votes, /// - an appeal system: fund 2 choices only, vote on any choice. contract DisputeKitClassic is DisputeKitClassicBase { - string public constant override version = "0.9.0"; + string public constant override version = "0.8.0"; // ************************************* // // * Constructor * // diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol index 85a9cd8cb..ff6291e58 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitClassicBase.sol @@ -238,7 +238,7 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi /// `n` is the number of votes. /// @param _coreDisputeID The ID of the dispute in Kleros Core. /// @param _voteIDs The IDs of the votes. - /// @param _commit The commitment hash. + /// @param _commit The commit. Note that justification string is a part of the commit. function castCommit( uint256 _coreDisputeID, uint256[] calldata _voteIDs, @@ -283,14 +283,13 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi Round storage round = dispute.rounds[dispute.rounds.length - 1]; (uint96 courtID, , , , ) = core.disputes(_coreDisputeID); (, bool hiddenVotes, , , , , ) = core.courts(courtID); - bytes32 voteHash = hashVote(_choice, _salt, _justification); // Save the votes. for (uint256 i = 0; i < _voteIDs.length; i++) { require(round.votes[_voteIDs[i]].account == msg.sender, "The caller has to own the vote."); require( - !hiddenVotes || round.votes[_voteIDs[i]].commit == voteHash, - "The vote hash must match the commitment in courts with hidden votes." + !hiddenVotes || round.votes[_voteIDs[i]].commit == keccak256(abi.encodePacked(_choice, _salt)), + "The commit must match the choice in courts with hidden votes." ); require(!round.votes[_voteIDs[i]].voted, "Vote already cast."); round.votes[_voteIDs[i]].choice = _choice; @@ -436,22 +435,6 @@ abstract contract DisputeKitClassicBase is IDisputeKit, Initializable, UUPSProxi // * Public Views * // // ************************************* // - /** - * @dev Computes the hash of a vote using ABI encoding - * @dev The unused parameters may be used by overriding contracts. - * @param _choice The choice being voted for - * @param _justification The justification for the vote - * @param _salt A random salt for commitment - * @return bytes32 The hash of the encoded vote parameters - */ - function hashVote( - uint256 _choice, - uint256 _salt, - string memory _justification - ) public pure virtual returns (bytes32) { - return keccak256(abi.encodePacked(_choice, _salt)); - } - function getFundedChoices(uint256 _coreDisputeID) public view returns (uint256[] memory fundedChoices) { Dispute storage dispute = disputes[coreDisputeIDToLocal[_coreDisputeID]]; Round storage lastRound = dispute.rounds[dispute.rounds.length - 1]; diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol index 1e9165048..c3788c432 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitGated.sol @@ -27,7 +27,7 @@ interface IBalanceHolderERC1155 { /// - an incentive system: equal split between coherent votes, /// - an appeal system: fund 2 choices only, vote on any choice. contract DisputeKitGated is DisputeKitClassicBase { - string public constant override version = "0.9.0"; + string public constant override version = "0.8.0"; // ************************************* // // * Storage * // diff --git a/contracts/test/foundry/KlerosCore.t.sol b/contracts/test/foundry/KlerosCore.t.sol index 16d81cbe2..291b969d0 100644 --- a/contracts/test/foundry/KlerosCore.t.sol +++ b/contracts/test/foundry/KlerosCore.t.sol @@ -1654,11 +1654,11 @@ contract KlerosCoreTest is Test { // Check the require with the wrong choice and then with the wrong salt vm.prank(staker1); - vm.expectRevert(bytes("The vote hash must match the commitment in courts with hidden votes.")); + vm.expectRevert(bytes("The commit must match the choice in courts with hidden votes.")); disputeKit.castVote(disputeID, voteIDs, 2, salt, "XYZ"); vm.prank(staker1); - vm.expectRevert(bytes("The vote hash must match the commitment in courts with hidden votes.")); + vm.expectRevert(bytes("The commit must match the choice in courts with hidden votes.")); disputeKit.castVote(disputeID, voteIDs, YES, salt - 1, "XYZ"); vm.prank(staker1); From 3c54701346373669dce601e19c263840d543cbd7 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Fri, 2 May 2025 15:23:28 +0100 Subject: [PATCH 13/13] fix: more realistic autovote --- contracts/scripts/shutterAutoVote.ts | 57 ++++++++++++++----- .../dispute-kits/DisputeKitShutterPoC.sol | 8 ++- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/contracts/scripts/shutterAutoVote.ts b/contracts/scripts/shutterAutoVote.ts index 9a6c4fe47..084f77dfd 100644 --- a/contracts/scripts/shutterAutoVote.ts +++ b/contracts/scripts/shutterAutoVote.ts @@ -1,4 +1,4 @@ -import { createPublicClient, createWalletClient, http, Hex, getContract } from "viem"; +import { createPublicClient, createWalletClient, http, Hex, getContract, Address, decodeEventLog } from "viem"; import { mnemonicToAccount } from "viem/accounts"; import { hardhat } from "viem/chains"; import { encrypt, decrypt, DECRYPTION_DELAY } from "./shutter"; @@ -11,11 +11,10 @@ const SEPARATOR = "␟"; // U+241F // Store encrypted votes for later decryption type EncryptedVote = { coreDisputeID: bigint; - encryptedCommitment: string; + juror: Address; identity: Hex; + encryptedVote: string; timestamp: number; - voteIDs: bigint[]; - salt: Hex; }; const encryptedVotes: EncryptedVote[] = []; @@ -43,6 +42,15 @@ const disputeKit = getContract({ client: { public: publicClient, wallet: walletClient }, }); +type CommitCastEventArgs = { + _coreDisputeID: bigint; + _juror: Address; + _voteIDs: bigint[]; + _commit: Hex; + _identity: Hex; + _encryptedVote: string; +}; + /** * Generate a random salt */ @@ -97,13 +105,19 @@ async function castCommit({ }); // Encrypt the message using shutter.ts - const { encryptedCommitment, identity } = await encrypt(message); + const { encryptedCommitment: encryptedVote, identity } = await encrypt(message); // Compute hash using all vote IDs const commitHash = await disputeKit.read.hashVote([choice, salt, justification]); // Cast the commit on-chain - const txHash = await disputeKit.write.castCommit([coreDisputeID, voteIDs, commitHash, identity as Hex]); + const txHash = await disputeKit.write.castCommit([ + coreDisputeID, + voteIDs, + commitHash, + identity as Hex, + encryptedVote, + ]); // Wait for transaction to be mined await publicClient.waitForTransactionReceipt({ hash: txHash }); @@ -115,11 +129,10 @@ async function castCommit({ // Store encrypted vote for later decryption encryptedVotes.push({ coreDisputeID, - encryptedCommitment, + juror: account.address, identity: identity as Hex, + encryptedVote, timestamp: Math.floor(Date.now() / 1000), - voteIDs, - salt, }); return { commitHash, identity, salt }; @@ -143,8 +156,26 @@ export async function autoVote() { for (const vote of readyVotes) { try { + // Retrieve the CommitCast event + const filter = await publicClient.createContractEventFilter({ + abi: DisputeKitShutterPoCAbi, + eventName: "CommitCast", + args: [vote.coreDisputeID, vote.juror], + }); + let events = await publicClient.getLogs(filter); + if (events.length !== 1) { + throw new Error("No CommitCast event found"); + } + const { args } = decodeEventLog({ + abi: DisputeKitShutterPoCAbi, + eventName: "CommitCast", + topics: events[0].topics, + data: events[0].data, + }); + const commitCast = args as unknown as CommitCastEventArgs; // Workaround getLogs type inference issue + // Attempt to decrypt the vote - const decryptedMessage = await decrypt(vote.encryptedCommitment, vote.identity); + const decryptedMessage = await decrypt(commitCast._encryptedVote, commitCast._identity); // Decode the decrypted message const { choice, salt, justification } = decode(decryptedMessage); @@ -152,7 +183,7 @@ export async function autoVote() { // Cast the vote on-chain const txHash = await disputeKit.write.castVote([ vote.coreDisputeID, - vote.voteIDs, + commitCast._voteIDs, choice, salt, justification, @@ -162,14 +193,14 @@ export async function autoVote() { await publicClient.waitForTransactionReceipt({ hash: txHash }); // Watch for VoteCast event - const events = await disputeKit.getEvents.VoteCast(); + events = await disputeKit.getEvents.VoteCast(); console.log("VoteCast event:", (events[0] as any).args); // Remove the processed vote const index = encryptedVotes.indexOf(vote); if (index > -1) encryptedVotes.splice(index, 1); } catch (error) { - console.error(`Error processing vote ${vote.voteIDs.join(",")}:`, error); + console.error(`Error processing vote for identity ${vote.identity}:`, error); } } diff --git a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol index 31b8617d1..09aa846ba 100644 --- a/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol +++ b/contracts/src/arbitration/dispute-kits/DisputeKitShutterPoC.sol @@ -23,7 +23,8 @@ contract DisputeKitShutterPoC { address indexed _juror, uint256[] _voteIDs, bytes32 _commit, - bytes32 _identity + bytes32 _identity, + bytes _encryptedVote ); event VoteCast( @@ -57,7 +58,8 @@ contract DisputeKitShutterPoC { uint256 _coreDisputeID, uint256[] calldata _voteIDs, bytes32 _commit, - bytes32 _identity + bytes32 _identity, + bytes calldata _encryptedVote ) external { // Store the commitment hash for each voteID for (uint256 i = 0; i < _voteIDs.length; i++) { @@ -66,7 +68,7 @@ contract DisputeKitShutterPoC { } totalCommitted += _voteIDs.length; - emit CommitCast(_coreDisputeID, msg.sender, _voteIDs, _commit, _identity); + emit CommitCast(_coreDisputeID, msg.sender, _voteIDs, _commit, _identity, _encryptedVote); } function castVote(