Skip to content

Commit aa01874

Browse files
committed
feat: keeper bot for shutter auto-reveal (wip)
1 parent b1d3fa9 commit aa01874

File tree

2 files changed

+294
-0
lines changed

2 files changed

+294
-0
lines changed

contracts/scripts/keeperBotShutter.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import hre from "hardhat";
2+
import { getBytes } from "ethers";
3+
import { DisputeKitShutter, SortitionModule, SortitionModuleNeo } from "../typechain-types";
4+
import { decrypt } from "./shutter";
5+
import env from "./utils/env";
6+
import loggerFactory from "./utils/logger";
7+
import { Cores, getContracts as getContractsForCoreType } from "./utils/contracts";
8+
9+
const SUBGRAPH_URL = env.require("SUBGRAPH_URL");
10+
const CORE_TYPE = env.optional("CORE_TYPE", "base");
11+
12+
const loggerOptions = env.optionalNoDefault("LOGTAIL_TOKEN_KEEPER_BOT")
13+
? {
14+
transportTargetOptions: {
15+
target: "@logtail/pino",
16+
options: { sourceToken: env.require("LOGTAIL_TOKEN_KEEPER_BOT") },
17+
level: env.optional("LOG_LEVEL", "info"),
18+
},
19+
level: env.optional("LOG_LEVEL", "info"), // for pino-pretty
20+
}
21+
: {
22+
level: env.optional("LOG_LEVEL", "info"),
23+
};
24+
const logger = loggerFactory.createLogger(loggerOptions);
25+
26+
const getContracts = async () => {
27+
const coreType = Cores[CORE_TYPE.toUpperCase() as keyof typeof Cores];
28+
if (coreType === Cores.UNIVERSITY) {
29+
throw new Error("University is not supported yet");
30+
}
31+
const contracts = await getContractsForCoreType(hre, coreType);
32+
return { ...contracts, sortition: contracts.sortition as SortitionModule | SortitionModuleNeo };
33+
};
34+
35+
/**
36+
* Decodes a message string into its component parts
37+
* @param message The message to decode
38+
* @returns Object containing the decoded components
39+
*/
40+
function decode(message: string) {
41+
const SEPARATOR = "-";
42+
const [choice, salt, justification] = message.split(SEPARATOR);
43+
return {
44+
choice: BigInt(choice),
45+
salt,
46+
justification,
47+
};
48+
}
49+
50+
/**
51+
* Parses a Graph vote ID string (e.g., "2-45-1-0") into its components.
52+
* @param graphVoteId - The vote ID string from the Graph.
53+
* @returns An object with disputeKitID, localDisputeID, localRoundID, and voteID as numbers.
54+
*/
55+
function parseGraphVoteId(graphVoteId: string) {
56+
const [disputeKitID, localDisputeID, localRoundID, voteID] = graphVoteId.split("-").map(Number);
57+
return { disputeKitID, localDisputeID, localRoundID, voteID };
58+
}
59+
60+
type DisputeVotes = {
61+
votes: {
62+
id: string;
63+
commit: string;
64+
commited: boolean;
65+
voted: boolean;
66+
juror: {
67+
id: string;
68+
};
69+
}[];
70+
coreDispute: {
71+
id: string;
72+
currentRoundIndex: string;
73+
};
74+
};
75+
76+
const getShutterDisputesToReveal = async (disputeKitShutter: DisputeKitShutter): Promise<DisputeVotes[]> => {
77+
const { gql, request } = await import("graphql-request"); // workaround for ESM import
78+
const query = gql`
79+
query DisputeToAutoReveal($shutterDisputeKit: Bytes) {
80+
disputeKits(where: { address: $shutterDisputeKit, courts_: { hiddenVotes: true } }) {
81+
id
82+
rounds(where: { isCurrentRound: true, dispute_: { period: vote } }) {
83+
id
84+
dispute {
85+
disputeID
86+
currentRoundIndex
87+
currentRound {
88+
id
89+
}
90+
disputeKitDispute {
91+
localRounds {
92+
... on ClassicRound {
93+
votes {
94+
id
95+
... on ClassicVote {
96+
commit
97+
commited
98+
voted
99+
juror {
100+
id
101+
}
102+
}
103+
}
104+
}
105+
}
106+
id
107+
currentLocalRoundIndex
108+
coreDispute {
109+
id
110+
currentRoundIndex
111+
}
112+
}
113+
period
114+
}
115+
}
116+
}
117+
}
118+
`;
119+
type ShutterDisputes = {
120+
disputeKits: Array<{
121+
id: string;
122+
rounds: Array<{
123+
id: string;
124+
dispute: {
125+
disputeID: string;
126+
currentRoundIndex: string;
127+
currentRound: {
128+
id: string;
129+
};
130+
disputeKitDispute: Array<{
131+
localRounds: Array<{
132+
votes: Array<{
133+
id: string;
134+
commit: string;
135+
commited: boolean;
136+
voted: boolean;
137+
juror: {
138+
id: string;
139+
};
140+
}>;
141+
}>;
142+
id: string;
143+
currentLocalRoundIndex: string;
144+
coreDispute: {
145+
id: string;
146+
currentRoundIndex: string;
147+
};
148+
}>;
149+
period: string;
150+
};
151+
}>;
152+
}>;
153+
};
154+
155+
logger.debug(`Using Shutter dispute kit: ${disputeKitShutter.target}`);
156+
const variables = { shutterDisputeKit: disputeKitShutter.target };
157+
const { disputeKits } = await request<ShutterDisputes>(SUBGRAPH_URL, query, variables);
158+
if (disputeKits.length === 0) {
159+
logger.debug("No Shutter dispute kit found, skipping auto-reveal");
160+
return [];
161+
}
162+
// For each round, if dispute.disputeKitDispute.length !== 1, filter out the round
163+
let filteredRounds = disputeKits[0].rounds.filter((round) => round.dispute.disputeKitDispute.length === 1);
164+
165+
// Remove the rounds which are not the current ones
166+
filteredRounds = filteredRounds.filter((round) => round.id === round.dispute.currentRound.id);
167+
168+
// For each filteredRound, in dispute.disputeKitDispute[0], keep only localRounds[currentLocalRoundIndex]
169+
const disputeVotes = filteredRounds.map((round) => {
170+
const dk = round.dispute.disputeKitDispute[0];
171+
const idx = Number(dk.currentLocalRoundIndex);
172+
const filteredLocalRounds = dk.localRounds.filter((_, i) => i === idx);
173+
return {
174+
coreDispute: dk.coreDispute,
175+
votes: filteredLocalRounds[0].votes,
176+
};
177+
});
178+
179+
// Filter out the votes where commited is false or voted is true
180+
const filteredDisputeVotes = disputeVotes.map((item) => ({
181+
...item,
182+
votes: item.votes.filter((vote) => vote.commited && !vote.voted),
183+
}));
184+
185+
return filteredDisputeVotes;
186+
};
187+
188+
async function shutterAutoReveal(disputeKitShutter: DisputeKitShutter | null) {
189+
if (!disputeKitShutter) {
190+
logger.debug("No Shutter dispute kit found, skipping auto-reveal");
191+
return [];
192+
}
193+
194+
const shutterDisputes = await getShutterDisputesToReveal(disputeKitShutter);
195+
console.log(JSON.stringify(shutterDisputes, null, 2));
196+
197+
for (const dispute of shutterDisputes) {
198+
const { coreDispute, votes } = dispute;
199+
if (Number(coreDispute.id) < 55) {
200+
logger.info(`Skipping disputeID: ${coreDispute.id}`);
201+
continue;
202+
}
203+
const decryptCache = new Map<string, string>(); // Cache for decrypted messages: key is `${_encryptedVote}-${_identity}`
204+
const decryptedToVoteIDs = new Map<string, number[]>(); // Map from decryptedMessage string to array of voteIDs
205+
const decryptedToSample = new Map<string, { _encryptedVote: any; _identity: any }>(); // Map from decryptedMessage string to a sample { _encryptedVote, _identity } (for logging/debug)
206+
207+
// For each vote, decrypt the message and group voteIDs by decryptedMessage
208+
for (const vote of votes) {
209+
const { voteID } = parseGraphVoteId(vote.id);
210+
211+
// Retrieve the CommitCastShutter events
212+
const filter = disputeKitShutter.filters.CommitCastShutter(coreDispute.id, vote.juror.id, getBytes(vote.commit));
213+
const events = await disputeKitShutter.queryFilter(filter);
214+
if (events.length === 0) {
215+
logger.error(`No CommitCastShutter event found for disputeID: ${coreDispute.id}, voteID: ${vote.id}`);
216+
continue;
217+
}
218+
if (events.length > 1) {
219+
logger.warn(
220+
`Multiple CommitCastShutter events found for disputeID: ${coreDispute.id}, voteID: ${vote.id}, using the first one only`
221+
);
222+
}
223+
const { _encryptedVote, _identity } = events[0].args;
224+
logger.debug(`CommitCastShutter event: ${JSON.stringify({ _encryptedVote, _identity }, null, 2)}`);
225+
226+
// Decrypt the message
227+
const cacheKey = `${_encryptedVote.toString()}-${_identity.toString()}`;
228+
let decryptedMessage: string;
229+
if (decryptCache.has(cacheKey)) {
230+
logger.debug(`Using cached value for ${cacheKey}`);
231+
decryptedMessage = decryptCache.get(cacheKey)!;
232+
} else {
233+
try {
234+
logger.debug(`Decrypting message for ${cacheKey}`);
235+
decryptedMessage = await decrypt(_encryptedVote, _identity);
236+
decryptCache.set(cacheKey, decryptedMessage);
237+
} catch (e) {
238+
logger.error(`Error decrypting message for ${cacheKey}: ${e}`);
239+
continue;
240+
}
241+
}
242+
logger.debug(`Decrypted message: ${decryptedMessage}`);
243+
244+
// Group voteIDs by decryptedMessage
245+
if (!decryptedToVoteIDs.has(decryptedMessage)) {
246+
decryptedToVoteIDs.set(decryptedMessage, []);
247+
decryptedToSample.set(decryptedMessage, { _encryptedVote, _identity });
248+
}
249+
decryptedToVoteIDs.get(decryptedMessage)!.push(voteID);
250+
}
251+
252+
// For each unique decryptedMessage, decode and castVote once with all voteIDs
253+
for (const [decryptedMessage, voteIDs] of decryptedToVoteIDs.entries()) {
254+
const decodedMessage = decode(decryptedMessage);
255+
logger.info(
256+
`Decoded message for voteIDs [${voteIDs.join(", ")}]: ${JSON.stringify({ choice: decodedMessage.choice.toString(), salt: decodedMessage.salt, justification: decodedMessage.justification }, null, 2)}`
257+
);
258+
const tx = await disputeKitShutter.castVoteShutter(
259+
coreDispute.id,
260+
voteIDs,
261+
decodedMessage.choice,
262+
decodedMessage.salt,
263+
decodedMessage.justification
264+
);
265+
logger.info(`Cast vote transaction: ${tx.hash}`);
266+
}
267+
}
268+
}
269+
270+
async function main() {
271+
logger.debug("Starting...");
272+
const { disputeKitShutter } = await getContracts();
273+
await shutterAutoReveal(disputeKitShutter);
274+
}
275+
276+
if (require.main === module) {
277+
main()
278+
.then(() => process.exit(0))
279+
.catch((error) => {
280+
console.error(error);
281+
process.exit(1);
282+
})
283+
.finally(() => {
284+
logger.flush();
285+
});
286+
}

contracts/scripts/utils/contracts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
BlockHashRNG,
44
ChainlinkRNG,
55
DisputeKitClassic,
6+
DisputeKitShutter,
67
DisputeResolver,
78
DisputeTemplateRegistry,
89
KlerosCore,
@@ -38,18 +39,21 @@ export const getContractNames = (coreType: Core) => {
3839
core: "KlerosCoreNeo",
3940
sortition: "SortitionModuleNeo",
4041
disputeKitClassic: "DisputeKitClassicNeo",
42+
disputeKitShutter: "DisputeKitShutterNeo",
4143
disputeResolver: "DisputeResolverNeo",
4244
},
4345
[Cores.BASE]: {
4446
core: "KlerosCore",
4547
sortition: "SortitionModule",
4648
disputeKitClassic: "DisputeKitClassic",
49+
disputeKitShutter: "DisputeKitShutter",
4750
disputeResolver: "DisputeResolver",
4851
},
4952
[Cores.UNIVERSITY]: {
5053
core: "KlerosCoreUniversity",
5154
sortition: "SortitionModuleUniversity",
5255
disputeKitClassic: "DisputeKitClassicUniversity",
56+
disputeKitShutter: "DisputeKitShutterUniversity",
5357
disputeResolver: "DisputeResolverUniversity",
5458
},
5559
};
@@ -97,6 +101,9 @@ export const getContracts = async (hre: HardhatRuntimeEnvironment, coreType: Cor
97101
throw new Error("Invalid core type, must be one of BASE, NEO, or UNIVERSITY");
98102
}
99103
const disputeKitClassic = await ethers.getContract<DisputeKitClassic>(getContractNames(coreType).disputeKitClassic);
104+
const disputeKitShutter = await ethers.getContractOrNull<DisputeKitShutter>(
105+
getContractNames(coreType).disputeKitShutter
106+
);
100107
const disputeResolver = await ethers.getContract<DisputeResolver>(getContractNames(coreType).disputeResolver);
101108
const disputeTemplateRegistry = await ethers.getContract<DisputeTemplateRegistry>(
102109
getContractNames(coreType).disputeTemplateRegistry
@@ -115,6 +122,7 @@ export const getContracts = async (hre: HardhatRuntimeEnvironment, coreType: Cor
115122
core,
116123
sortition,
117124
disputeKitClassic,
125+
disputeKitShutter,
118126
disputeResolver,
119127
disputeTemplateRegistry,
120128
evidence,

0 commit comments

Comments
 (0)