Skip to content

Shutterized Dispute Kit #1965

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 33 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2bffa61
chore: shutter script experiment
jaybuidl Mar 15, 2025
3570b46
feat(shutter): added decryption logic
jaybuidl Mar 19, 2025
ddae29a
fix: better error handling
jaybuidl Mar 19, 2025
04e3f5c
feat: naive shutterized dispute kit and auto-voting bot
jaybuidl Apr 24, 2025
be6e1ce
feat: commitment hashing and verification onchain
jaybuidl Apr 29, 2025
020dbab
chore: cleanup
jaybuidl Apr 29, 2025
30804a8
feat: support for multiple voteIDs at once, fixed salt handling by bot
jaybuidl Apr 30, 2025
1a9b72d
chore: cleanup before integration into a fully fletched dispute kit
jaybuidl Apr 30, 2025
b580556
feat: fully fletched DisputeKitShutter
jaybuidl Apr 30, 2025
8819241
chore: removed redundant node-fetch
jaybuidl Apr 30, 2025
cd016b3
chore: cleanup
jaybuidl Apr 30, 2025
8e46e05
feat: fully fletched DisputeKitShutter
jaybuidl Apr 30, 2025
98ec3ba
chore: deployment of DisputeKitShutter in devnet
jaybuidl Apr 30, 2025
74d1506
fix: missing parameter, upgraded DisputeKitShutter
jaybuidl Apr 30, 2025
b40abc2
chore: upgraded DisputeKitClassic
jaybuidl Apr 30, 2025
cb7997f
fix: external call to castCommit() changes msg.sender, fixed by extra…
jaybuidl May 1, 2025
ee2ceb6
chore: enable shutter DK on the devnet general court
jaybuidl May 2, 2025
317aed6
feat: support for shutter disputekit in devnet
kemuru May 6, 2025
05dcdb0
fix: create new classic dispute entity correctly with correct round i…
kemuru May 8, 2025
44ea55e
fix: correct round check
kemuru May 8, 2025
a995e1e
chore: subgraph update scripts now relies on a template and removes d…
jaybuidl May 8, 2025
ca14ada
Merge branch 'dev' into feat/shutter-dispute-kit
jaybuidl May 8, 2025
f3f235c
Merge branch 'feat/shutter-dispute-kit' into feat(subgraph)/support-f…
kemuru May 9, 2025
4b85135
Merge branch 'dev' into feat/shutter-dispute-kit
jaybuidl May 9, 2025
edc4a7d
Merge branch 'dev' into feat/shutter-dispute-kit
jaybuidl May 12, 2025
78b2951
chore: fix to support deployment to Alchemy
jaybuidl May 13, 2025
6b32fc3
chore: lock file
jaybuidl May 13, 2025
74625e1
Merge pull request #1966 from kleros/feat(subgraph)/support-for-multi…
jaybuidl May 14, 2025
0dad7c4
Merge branch 'dev' into feat/shutter-dispute-kit
kemuru May 15, 2025
6cd4369
Merge branch 'dev' into feat/shutter-dispute-kit
kemuru May 15, 2025
14994db
fix: bug fix in subgraph
kemuru May 15, 2025
142273c
Merge pull request #1995 from kleros/fix(subgraph)/localrounds-fix
kemuru May 15, 2025
5989928
chore: subgraphs version bump
jaybuidl May 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -157,6 +156,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"
}
}
267 changes: 267 additions & 0 deletions contracts/scripts/shutter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { encryptData, decrypt as shutterDecrypt } from "@shutter-network/shutter-sdk";
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 = 5;

interface ShutterApiMessageData {
eon: number;
identity: string;
identity_prefix: string;
eon_key: string;
tx_hash: string;
}

interface ShutterApiResponse {
message: ShutterApiMessageData;
error?: string;
}

interface ShutterDecryptionKeyData {
decryption_key: string;
identity: string;
decryption_timestamp: number;
}

/**
* 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<ShutterApiMessageData> {
try {
console.log(`Sending request to Shutter API with decryption timestamp: ${decryptionTimestamp}`);

// Generate a random identity prefix
const identityPrefix = generateRandomBytes32();
console.log(`Generated identity prefix: ${identityPrefix}`);

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,
identityPrefix,
}),
});

// 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;
}
}

/**
* Fetches the decryption key from the Shutter API
* @param identity The identity used for encryption
* @returns Promise with the decryption key data
*/
async function fetchDecryptionKey(identity: string): Promise<ShutterDecryptionKeyData> {
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",
},
});

// Get the response text
const responseText = await response.text();

// 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}`);
}

// 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}`);
}

// Check if we have the message data
if (!jsonResponse.message) {
throw new Error(`API response missing message data: ${JSON.stringify(jsonResponse)}`);
}

return jsonResponse.message;
}

/**
* 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 32 bytes
* @returns Random 32 bytes as a hex string with 0x prefix
*/
function generateRandomBytes32(): `0x${string}` {
return ("0x" +
crypto
.getRandomValues(new Uint8Array(32))
.reduce((acc, byte) => acc + byte.toString(16).padStart(2, "0"), "")) as Hex;
}
Comment on lines +148 to +153
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

crypto.getRandomValues is not available in Node.js

getRandomValues is a Web-Crypto API. In a Node environment use
crypto.randomBytes.

-  return ("0x" +
-    crypto
-      .getRandomValues(new Uint8Array(32))
-      .reduce((acc, byte) => acc + byte.toString(16).padStart(2, "0"), "")) as Hex;
+  return ("0x" + crypto.randomBytes(32).toString("hex")) as Hex;

This prevents runtime crashes when the script is executed with ts-node.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function generateRandomBytes32(): `0x${string}` {
return ("0x" +
crypto
.getRandomValues(new Uint8Array(32))
.reduce((acc, byte) => acc + byte.toString(16).padStart(2, "0"), "")) as Hex;
}
function generateRandomBytes32(): `0x${string}` {
return ("0x" + crypto.randomBytes(32).toString("hex")) as Hex;
}


/**
* Encrypts a message using the Shutter API
* @param message The message to encrypt
* @returns Promise with the encrypted commitment and identity
*/
export async function encrypt(message: string): Promise<{ encryptedCommitment: string; identity: string }> {
// Set decryption timestamp
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}...`);
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, 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
*/
export async function decrypt(encryptedMessage: string, identity: string): Promise<string> {
// 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);

// Decrypt the message
const decryptedHexMessage = await shutterDecrypt(encryptedMessage, decryptionKey);

// Convert the decrypted hex message back to a string
return hexToString(decryptedHexMessage as `0x${string}`);
}

async function main() {
try {
const command = process.argv[2]?.toLowerCase();

if (!command) {
console.error(`
Usage: yarn ts-node shutter.ts <command> [arguments]

Commands:
encrypt <message> Encrypt a message
decrypt <encrypted message> <identity> Decrypt a message (requires the identity used during encryption)

Examples:
yarn ts-node shutter.ts encrypt "my secret message"
yarn ts-node shutter.ts decrypt "encrypted-data" "0x1234..."`);
process.exit(1);
}

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 <message>");
process.exit(1);
}
const { encryptedCommitment, identity } = await encrypt(message);
console.log("\nEncrypted Commitment:", encryptedCommitment);
console.log("Identity:", identity);
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 <encrypted-message> <identity>");
console.error("Note: The identity is the one returned during encryption");
process.exit(1);
}
const decryptedMessage = await decrypt(encryptedMessage, identity);
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("\nError:", error);
process.exit(1);
}
}

// Execute if run directly
if (require.main === module) {
main();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 * //
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion contracts/src/arbitration/dispute-kits/DisputeKitGated.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 * //
Expand Down
Loading
Loading