jury.gno
10.02 Kb · 341 lines
1// Package jury is a commit-reveal adversarial review protocol for disputes
2// over agent work.
3//
4// The hard part of an agent economy is not paying agents — it is deciding
5// what happens when an agent's output is disputed. This realm runs a small
6// jury: a fixed panel of reviewers each *commit* to a hidden verdict, then
7// *reveal* it. Commit-reveal means no juror can copy another's vote or be
8// swayed after seeing the tally; every verdict is locked in blind.
9//
10// The subject of a case is an opaque string — a receipt sequence, a claim
11// id, an off-chain artifact hash — so this realm composes with the receipt
12// and gnomem demos without depending on them.
13//
14// Bonding and slashing (a juror stakes coins, loses them for provably bad
15// verdicts) are the natural economic layer on top; this demo shows the
16// mechanism without the money.
17package jury
18
19import (
20 "chain"
21 "chain/runtime"
22 "strings"
23
24 "gno.land/p/nym-moultest123/agents/commit/v0"
25 "gno.land/p/nym-moultest123/kit/ui/v0"
26 "gno.land/p/nt/ufmt/v0"
27)
28
29// Phase is a case's position in the commit-reveal lifecycle.
30type Phase string
31
32const (
33 PhaseCommit Phase = "commit"
34 PhaseReveal Phase = "reveal"
35 PhaseClosed Phase = "closed"
36)
37
38// Juror is one panelist and their (eventually revealed) verdict.
39type Juror struct {
40 Addr address
41 Commitment string // commit.Verdict(verdict, salt)
42 Committed bool
43 Revealed bool
44 Verdict bool // meaningful once Revealed
45 Height int64
46}
47
48// Case is a single dispute under review by a fixed panel.
49type Case struct {
50 ID uint64
51 Subject string // opaque reference to the disputed thing
52 Opener address
53 Phase Phase
54 Jurors []*Juror
55 OpenedAt int64
56 Outcome string // "upheld" | "rejected" | "tie" once closed
57 Yes int
58 No int
59}
60
61var cases []*Case // index 0 => ID 1
62
63// The bounds on what a wallet may open. OpenCase is callable by anyone, so
64// without them the case list and every panel grow without limit, and so does
65// the index page that renders them.
66const (
67 MaxCases = 1000
68 MaxJurors = 25
69 MaxSubject = 200 // bytes
70 IndexRecent = 50 // cases the index lists, newest first
71
72 MaxCommitment = 128 // bytes; commit.Verdict returns 64
73)
74
75// OpenCase starts a dispute with a fixed panel of jurors, given as
76// comma-separated addresses. An odd panel size avoids ties; duplicates and
77// invalid addresses are rejected.
78//
79// The panel is a string, not a []address, because MsgCall carries every
80// argument as a string and the VM decodes no slice but []byte: a []address
81// parameter made this function unreachable from any wallet.
82func OpenCase(cur realm, subject, jurorsCSV string) uint64 {
83 opener := caller(cur)
84 // Checked after sanitizing: a subject of only zero-width runes is not empty
85 // as bytes, and renders as nothing.
86 assert(strings.TrimSpace(ui.Inline(subject)) != "", "empty subject")
87 assert(len(subject) <= MaxSubject, "subject too long")
88 assert(len(cases) < MaxCases, "the jury is full")
89 jurors := parseJurors(jurorsCSV)
90 assert(len(jurors) >= 1, "need at least one juror")
91 assert(len(jurors) <= MaxJurors, "panel too large")
92
93 seen := make(map[string]bool)
94 panel := make([]*Juror, 0, len(jurors))
95 for _, j := range jurors {
96 key := j.String()
97 assert(!seen[key], "duplicate juror: "+key)
98 seen[key] = true
99 panel = append(panel, &Juror{Addr: j})
100 }
101
102 id := uint64(len(cases)) + 1
103 cases = append(cases, &Case{
104 ID: id,
105 Subject: subject,
106 Opener: opener,
107 Phase: PhaseCommit,
108 Jurors: panel,
109 OpenedAt: runtime.ChainHeight(),
110 })
111 chain.Emit("CaseOpened", "id", ufmt.Sprintf("%d", id), "jurors", ufmt.Sprintf("%d", len(panel)))
112 return id
113}
114
115// Commit locks in a juror's hidden verdict. commitment must equal
116// commit.Verdict(verdict, salt) — computed off-chain. When the last juror
117// commits, the case advances to the reveal phase automatically.
118func Commit(cur realm, caseID uint64, commitment string) {
119 who := caller(cur)
120 c := mustCase(caseID)
121 assert(c.Phase == PhaseCommit, "not in commit phase")
122 assert(commitment != "", "empty commitment")
123 // commit.Verdict is a hex sha256; anything longer is not one, and would be
124 // stored and compared on every later Commit in the case.
125 assert(len(commitment) <= MaxCommitment, "commitment too long")
126 j := mustJuror(c, who)
127 assert(!j.Committed, "already committed")
128 // Every commitment is public through Get, so without this a juror could
129 // commit a copy of another's hash, then reveal the same (verdict, salt)
130 // once the first juror's reveal puts it in a transaction. Refusing a
131 // repeat makes a copied commitment unrevealable.
132 for _, other := range c.Jurors {
133 assert(!other.Committed || other.Commitment != commitment, "commitment already used in this case")
134 }
135 j.Commitment = commitment
136 j.Committed = true
137 j.Height = runtime.ChainHeight()
138
139 if allCommitted(c) {
140 c.Phase = PhaseReveal
141 chain.Emit("CasePhase", "id", ufmt.Sprintf("%d", caseID), "phase", string(PhaseReveal))
142 }
143}
144
145// Reveal opens a juror's verdict. The (verdict, salt) must reproduce the
146// commitment made earlier, or the reveal is rejected. When the last juror
147// reveals, the case closes and the majority outcome is recorded.
148func Reveal(cur realm, caseID uint64, verdict bool, salt string) {
149 who := caller(cur)
150 c := mustCase(caseID)
151 assert(c.Phase == PhaseReveal, "not in reveal phase")
152 j := mustJuror(c, who)
153 assert(j.Committed && !j.Revealed, "nothing to reveal")
154 assert(commit.Verdict(verdict, salt) == j.Commitment, "reveal does not match commitment")
155
156 j.Revealed = true
157 j.Verdict = verdict
158 if verdict {
159 c.Yes++
160 } else {
161 c.No++
162 }
163 chain.Emit("VerdictRevealed", "id", ufmt.Sprintf("%d", caseID), "verdict", boolStr(verdict))
164
165 if allRevealed(c) {
166 closeCase(c)
167 }
168}
169
170// ---- read-only API ----
171
172// Get returns a copy of a case (jurors flattened separately via Jury).
173func Get(caseID uint64) Case { return *mustCase(caseID) }
174
175// Outcome returns the recorded outcome, or "" if the case is still open.
176func Outcome(caseID uint64) string { return mustCase(caseID).Outcome }
177
178// Count returns the number of cases.
179func Count() int { return len(cases) }
180
181// ---- internal ----
182
183func closeCase(c *Case) {
184 switch {
185 case c.Yes > c.No:
186 c.Outcome = "upheld"
187 case c.No > c.Yes:
188 c.Outcome = "rejected"
189 default:
190 c.Outcome = "tie"
191 }
192 c.Phase = PhaseClosed
193 chain.Emit("CaseClosed", "id", ufmt.Sprintf("%d", c.ID), "outcome", c.Outcome)
194}
195
196func allCommitted(c *Case) bool {
197 for _, j := range c.Jurors {
198 if !j.Committed {
199 return false
200 }
201 }
202 return true
203}
204
205func allRevealed(c *Case) bool {
206 for _, j := range c.Jurors {
207 if !j.Revealed {
208 return false
209 }
210 }
211 return true
212}
213
214func mustCase(id uint64) *Case {
215 assert(id >= 1 && id <= uint64(len(cases)), "unknown case")
216 return cases[id-1]
217}
218
219func mustJuror(c *Case, addr address) *Juror {
220 for _, j := range c.Jurors {
221 if j.Addr == addr {
222 return j
223 }
224 }
225 panic("caller is not on this jury")
226}
227
228func boolStr(b bool) string {
229 if b {
230 return "yes"
231 }
232 return "no"
233}
234
235func assert(cond bool, msg string) {
236 if !cond {
237 panic(msg)
238 }
239}
240
241// Render shows all cases, or one case's panel + tally at :<id>.
242func Render(path string) string {
243 if path == "" {
244 return renderIndex()
245 }
246 return renderCase(parseID(path))
247}
248
249func renderIndex() string {
250 var sb strings.Builder
251 sb.WriteString("# Agent Jury\n\n")
252 sb.WriteString("_Who checks the agents?_ Blind commit-reveal review by a fixed panel.\n\n")
253 if len(cases) == 0 {
254 sb.WriteString("_No cases yet._\n")
255 return sb.String()
256 }
257 if len(cases) > IndexRecent {
258 sb.WriteString(ufmt.Sprintf("The %d most recent of %d cases, newest first.\n\n", IndexRecent, len(cases)))
259 }
260 sb.WriteString("| # | Subject | Phase | ✅ | ❌ | Outcome |\n")
261 sb.WriteString("|---|---|---|---|---|---|\n")
262 // Newest first, and only the last IndexRecent: the page has to render at
263 // MaxCases too.
264 for i := len(cases) - 1; i >= 0 && i >= len(cases)-IndexRecent; i-- {
265 c := cases[i]
266 out := c.Outcome
267 if out == "" {
268 out = "—"
269 }
270 sb.WriteString(ufmt.Sprintf("| [%d](/r/nym-moultest123/agents/jury/v0:%d) | %s | %s | %d | %d | %s |\n",
271 c.ID, c.ID, ui.Cell(c.Subject), string(c.Phase), c.Yes, c.No, out))
272 }
273 return sb.String()
274}
275
276func renderCase(id uint64) string {
277 c := mustCase(id)
278 var sb strings.Builder
279 sb.WriteString(ufmt.Sprintf("# Case #%d\n\n", c.ID))
280 sb.WriteString(ufmt.Sprintf("- **Subject:** %s\n", ui.Inline(c.Subject)))
281 sb.WriteString(ufmt.Sprintf("- **Phase:** %s\n", string(c.Phase)))
282 sb.WriteString(ufmt.Sprintf("- **Opened by:** %s at height %d\n", short(c.Opener), c.OpenedAt))
283 if c.Outcome != "" {
284 sb.WriteString(ufmt.Sprintf("- **Outcome:** **%s** (%d ✅ / %d ❌)\n", c.Outcome, c.Yes, c.No))
285 }
286 sb.WriteString("\n## Panel\n\n")
287 sb.WriteString("| Juror | Committed | Revealed | Verdict | Dissent |\n|---|---|---|---|---|\n")
288 majority := c.Yes >= c.No // for dissent marking once closed
289 for _, j := range c.Jurors {
290 verdict, dissent := "—", ""
291 if j.Revealed {
292 verdict = boolStr(j.Verdict)
293 if c.Phase == PhaseClosed && c.Outcome != "tie" && j.Verdict != majority {
294 dissent = "⚠"
295 }
296 }
297 sb.WriteString(ufmt.Sprintf("| %s | %t | %t | %s | %s |\n",
298 short(j.Addr), j.Committed, j.Revealed, verdict, dissent))
299 }
300 return sb.String()
301}
302
303func parseID(s string) uint64 {
304 var n uint64
305 for i := 0; i < len(s); i++ {
306 ch := s[i]
307 assert(ch >= '0' && ch <= '9', "invalid case path: "+s)
308 n = n*10 + uint64(ch-'0')
309 }
310 return n
311}
312
313func short(a address) string {
314 s := a.String()
315 if len(s) <= 12 {
316 return s
317 }
318 return s[:8] + "…" + s[len(s)-4:]
319}
320
321// parseJurors splits a comma-separated panel, trimming spaces around each
322// address and refusing an empty or invalid one.
323func parseJurors(csv string) []address {
324 var out []address
325 for _, part := range strings.Split(csv, ",") {
326 a := address(strings.TrimSpace(part))
327 assert(a.IsValid(), "invalid juror address: "+string(a))
328 out = append(out, a)
329 }
330 return out
331}
332
333// caller is the address that crossed into this realm, after checking that the
334// realm token is this frame's own. Every entry point reads it first, before
335// any other work.
336func caller(cur realm) address {
337 if !cur.IsCurrent() {
338 panic("jury: spoofed realm")
339 }
340 return cur.Previous().Address()
341}