gnotask.gno
11.88 Kb · 412 lines
1package gnotask
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7 "strconv"
8 "strings"
9 "time"
10)
11
12const (
13 StatusOpen = "Open"
14 StatusClaimed = "Claimed"
15 StatusSubmitted = "Submitted"
16 StatusChangesRequested = "ChangesRequested"
17 StatusPaid = "Paid"
18 StatusRefunded = "Refunded"
19 StatusCancelled = "Cancelled"
20
21 NativeRewardDenom = "ugnot"
22 MinReviewWindowSeconds = int64(60 * 60)
23 MaxReviewWindowSeconds = int64(30 * 24 * 60 * 60)
24)
25
26// Task is the authoritative escrow record. Descriptions, GitHub evidence,
27// search metadata, and private operational data remain off-chain.
28type Task struct {
29 ID string
30 Sponsor address
31 Contributor address
32 SpecHash string
33 RewardAmount int64
34 RewardDenom string
35 DeliveryDueAt time.Time
36 ReviewWindowSeconds int64
37 ReviewDeadline time.Time
38 Status string
39 RevisionRequests int
40 EvidenceHash string
41 ReviewHash string
42 Escrowed bool
43 ReleaseReason string
44}
45
46var (
47 tasks = make(map[string]*Task)
48 taskIDs []string
49)
50
51// A direct MsgCall is required anywhere an address authorizes a state change.
52// IsUserCall deliberately rejects intermediate code and user-run ephemeral
53// realms, which is required when unsafe.OriginSend is treated as a receipt.
54func requireDirectUser(cur realm) address {
55 previous := cur.Previous()
56 if !previous.IsUserCall() {
57 panic("direct user call required")
58 }
59 return previous.Address()
60}
61
62func requireTask(taskID string) *Task {
63 task := tasks[taskID]
64 if task == nil {
65 panic("task not found")
66 }
67 return task
68}
69
70func requireSponsor(task *Task, caller address) {
71 if task.Sponsor != caller {
72 panic("only sponsor")
73 }
74}
75
76func requireContributor(task *Task, caller address) {
77 if task.Contributor != caller {
78 panic("only selected contributor")
79 }
80}
81
82func requireTaskID(taskID string) {
83 if len(taskID) == 0 || len(taskID) > 64 {
84 panic("invalid task id")
85 }
86 for index := 0; index < len(taskID); index++ {
87 char := taskID[index]
88 valid := char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' || char == '_'
89 if !valid {
90 panic("invalid task id")
91 }
92 }
93}
94
95func requireHash(value, field string) {
96 if len(value) != 64 {
97 panic(field + " must be a lowercase sha256 hash")
98 }
99 for index := 0; index < len(value); index++ {
100 char := value[index]
101 if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
102 panic(field + " must be a lowercase sha256 hash")
103 }
104 }
105}
106
107func requireExactEscrow(rewardAmount int64, rewardDenom string) {
108 sent := unsafe.OriginSend()
109 if len(sent) != 1 || sent[0].Denom != rewardDenom || sent[0].Amount != rewardAmount {
110 panic("exact reward must be attached")
111 }
112}
113
114func settleEscrow(cur realm, task *Task, recipient address, reason, terminalStatus string) {
115 if !task.Escrowed {
116 panic("reward is not escrowed")
117 }
118 task.Escrowed = false
119 task.ReleaseReason = reason
120 task.Status = terminalStatus
121 coins := chain.Coins{{Denom: task.RewardDenom, Amount: task.RewardAmount}}
122 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(cur.Address(), recipient, coins)
123}
124
125// CreateTask locks the exact GNOT reward attached to this direct transaction.
126// No Task becomes Open from a URL, promise, or off-chain balance screenshot.
127func CreateTask(
128 cur realm,
129 taskID string,
130 specHash string,
131 rewardAmount int64,
132 rewardDenom string,
133 deliveryDueUnix int64,
134 reviewWindowSeconds int64,
135) {
136 sponsor := requireDirectUser(cur)
137 requireTaskID(taskID)
138 if tasks[taskID] != nil {
139 panic("task already exists")
140 }
141 requireHash(specHash, "spec hash")
142 if rewardAmount <= 0 {
143 panic("reward must be positive")
144 }
145 if rewardDenom != NativeRewardDenom {
146 panic("only ugnot rewards are supported")
147 }
148 if deliveryDueUnix <= time.Now().Unix() {
149 panic("delivery deadline must be in the future")
150 }
151 if reviewWindowSeconds < MinReviewWindowSeconds || reviewWindowSeconds > MaxReviewWindowSeconds {
152 panic("invalid review window")
153 }
154 requireExactEscrow(rewardAmount, rewardDenom)
155
156 tasks[taskID] = &Task{
157 ID: taskID,
158 Sponsor: sponsor,
159 SpecHash: specHash,
160 RewardAmount: rewardAmount,
161 RewardDenom: rewardDenom,
162 DeliveryDueAt: time.Unix(deliveryDueUnix, 0),
163 ReviewWindowSeconds: reviewWindowSeconds,
164 Status: StatusOpen,
165 Escrowed: true,
166 }
167 taskIDs = append(taskIDs, taskID)
168 chain.Emit("TaskCreated",
169 "task_id", taskID,
170 "sponsor", sponsor.String(),
171 "spec_hash", specHash,
172 )
173 chain.Emit("FundingLocked",
174 "task_id", taskID,
175 "amount", strconv.FormatInt(rewardAmount, 10),
176 "denom", rewardDenom,
177 "delivery_due_unix", strconv.FormatInt(deliveryDueUnix, 10),
178 )
179}
180
181// CancelOpenTask returns the full escrow while nobody has been selected.
182func CancelOpenTask(cur realm, taskID string) {
183 caller := requireDirectUser(cur)
184 task := requireTask(taskID)
185 requireSponsor(task, caller)
186 if task.Status != StatusOpen {
187 panic("only an open task can be cancelled")
188 }
189 settleEscrow(cur, task, task.Sponsor, "sponsor_cancelled", StatusCancelled)
190 chain.Emit("TaskCancelled", "task_id", taskID)
191 chain.Emit("RewardRefunded", "task_id", taskID, "reason", task.ReleaseReason)
192}
193
194// SelectContributor records the one contributor chosen through the off-chain
195// application flow. The reward remains in the Realm.
196func SelectContributor(cur realm, taskID string, contributor address) {
197 caller := requireDirectUser(cur)
198 task := requireTask(taskID)
199 requireSponsor(task, caller)
200 if task.Status != StatusOpen {
201 panic("task is not open")
202 }
203 if !contributor.IsValid() || contributor == caller {
204 panic("invalid contributor")
205 }
206 task.Contributor = contributor
207 task.Status = StatusClaimed
208 chain.Emit("ContributorSelected",
209 "task_id", taskID,
210 "contributor", contributor.String(),
211 )
212}
213
214// SubmitEvidence anchors a canonical hash and starts the on-chain review timer.
215func SubmitEvidence(cur realm, taskID, evidenceHash string) {
216 caller := requireDirectUser(cur)
217 task := requireTask(taskID)
218 requireContributor(task, caller)
219 if task.Status != StatusClaimed && task.Status != StatusChangesRequested {
220 panic("task does not accept evidence")
221 }
222 now := time.Now()
223 if task.Status == StatusClaimed && !now.Before(task.DeliveryDueAt) {
224 panic("delivery deadline reached")
225 }
226 if task.Status == StatusChangesRequested && (task.ReviewDeadline.IsZero() || !now.Before(task.ReviewDeadline)) {
227 panic("revision deadline reached")
228 }
229 requireHash(evidenceHash, "evidence hash")
230 task.EvidenceHash = evidenceHash
231 task.ReviewDeadline = now.Add(time.Duration(task.ReviewWindowSeconds) * time.Second)
232 task.Status = StatusSubmitted
233 chain.Emit("WorkSubmitted",
234 "task_id", taskID,
235 "contributor", caller.String(),
236 "evidence_hash", evidenceHash,
237 "review_deadline_unix", strconv.FormatInt(task.ReviewDeadline.Unix(), 10),
238 )
239}
240
241// Review either requests the one allowed revision or atomically accepts and
242// releases the escrow to the selected Contributor.
243func Review(cur realm, taskID string, accept bool, reviewHash string) {
244 caller := requireDirectUser(cur)
245 task := requireTask(taskID)
246 requireSponsor(task, caller)
247 if task.Status != StatusSubmitted {
248 panic("task is not submitted")
249 }
250 requireHash(reviewHash, "review hash")
251 now := time.Now()
252 if !accept && (task.ReviewDeadline.IsZero() || !now.Before(task.ReviewDeadline)) {
253 panic("review deadline reached")
254 }
255 task.ReviewHash = reviewHash
256 if accept {
257 settleEscrow(cur, task, task.Contributor, "sponsor_accepted", StatusPaid)
258 chain.Emit("TaskAccepted", "task_id", taskID, "review_hash", reviewHash)
259 chain.Emit("RewardReleased",
260 "task_id", taskID,
261 "contributor", task.Contributor.String(),
262 "reason", task.ReleaseReason,
263 )
264 return
265 }
266 if task.RevisionRequests >= 1 {
267 panic("revision limit reached")
268 }
269 task.RevisionRequests++
270 // The same bounded window now becomes the Contributor's revision window.
271 task.ReviewDeadline = now.Add(time.Duration(task.ReviewWindowSeconds) * time.Second)
272 task.Status = StatusChangesRequested
273 chain.Emit("ChangesRequested", "task_id", taskID, "review_hash", reviewHash)
274}
275
276// ClaimAfterReviewDeadline protects a Contributor from an absent Sponsor.
277func ClaimAfterReviewDeadline(cur realm, taskID string) {
278 caller := requireDirectUser(cur)
279 task := requireTask(taskID)
280 requireContributor(task, caller)
281 if task.Status != StatusSubmitted {
282 panic("task is not awaiting review")
283 }
284 if task.ReviewDeadline.IsZero() || time.Now().Before(task.ReviewDeadline) {
285 panic("review deadline not reached")
286 }
287 settleEscrow(cur, task, task.Contributor, "review_timeout", StatusPaid)
288 chain.Emit("RewardReleased",
289 "task_id", taskID,
290 "contributor", task.Contributor.String(),
291 "reason", task.ReleaseReason,
292 )
293}
294
295// RefundExpiredTask returns the escrow when the selected Contributor misses
296// either the initial delivery deadline or the bounded revision deadline.
297// Submitted work is protected by the review timer and cannot use this path.
298func RefundExpiredTask(cur realm, taskID string) {
299 caller := requireDirectUser(cur)
300 task := requireTask(taskID)
301 requireSponsor(task, caller)
302 if task.Status != StatusClaimed && task.Status != StatusChangesRequested {
303 panic("task is not refundable after delivery timeout")
304 }
305 deadline := task.DeliveryDueAt
306 if task.Status == StatusChangesRequested {
307 deadline = task.ReviewDeadline
308 }
309 if deadline.IsZero() || time.Now().Before(deadline) {
310 panic("delivery deadline not reached")
311 }
312 settleEscrow(cur, task, task.Sponsor, "delivery_timeout", StatusRefunded)
313 chain.Emit("RewardRefunded", "task_id", taskID, "reason", task.ReleaseReason)
314}
315
316func TaskCount() int {
317 return len(taskIDs)
318}
319
320func GetTask(taskID string) (
321 string,
322 address,
323 address,
324 string,
325 string,
326 int64,
327 string,
328 int64,
329 int64,
330 int64,
331 int,
332 string,
333 string,
334 bool,
335 string,
336) {
337 task := requireTask(taskID)
338 reviewDeadlineUnix := int64(0)
339 if !task.ReviewDeadline.IsZero() {
340 reviewDeadlineUnix = task.ReviewDeadline.Unix()
341 }
342 return task.ID,
343 task.Sponsor,
344 task.Contributor,
345 task.Status,
346 task.SpecHash,
347 task.RewardAmount,
348 task.RewardDenom,
349 task.DeliveryDueAt.Unix(),
350 task.ReviewWindowSeconds,
351 reviewDeadlineUnix,
352 task.RevisionRequests,
353 task.EvidenceHash,
354 task.ReviewHash,
355 task.Escrowed,
356 task.ReleaseReason
357}
358
359// GetTaskState is a compact, query-friendly representation for off-chain
360// indexers. Every field is constrained so the pipe delimiter is unambiguous.
361func GetTaskState(taskID string) string {
362 task := requireTask(taskID)
363 contributor := ""
364 if task.Contributor.IsValid() {
365 contributor = task.Contributor.String()
366 }
367 reviewDeadlineUnix := int64(0)
368 if !task.ReviewDeadline.IsZero() {
369 reviewDeadlineUnix = task.ReviewDeadline.Unix()
370 }
371 return strings.Join([]string{
372 task.ID,
373 task.Sponsor.String(),
374 contributor,
375 task.Status,
376 task.SpecHash,
377 strconv.FormatInt(task.RewardAmount, 10),
378 task.RewardDenom,
379 strconv.FormatInt(task.DeliveryDueAt.Unix(), 10),
380 strconv.FormatInt(task.ReviewWindowSeconds, 10),
381 strconv.FormatInt(reviewDeadlineUnix, 10),
382 strconv.Itoa(task.RevisionRequests),
383 task.EvidenceHash,
384 task.ReviewHash,
385 strconv.FormatBool(task.Escrowed),
386 task.ReleaseReason,
387 }, "|")
388}
389
390func Render(path string) string {
391 taskID := strings.TrimPrefix(path, "/")
392 if taskID == "" {
393 return "# GnoTask\n\nGNOT-escrowed open-source work.\n\nOn-chain Tasks: **" + strconv.Itoa(len(taskIDs)) + "**"
394 }
395 task := requireTask(taskID)
396 contributor := "Not selected"
397 if task.Contributor.IsValid() {
398 contributor = task.Contributor.String()
399 }
400 escrow := "Released"
401 if task.Escrowed {
402 escrow = "Locked in Realm"
403 }
404 return "# Task `" + task.ID + "`\n\n" +
405 "- Status: **" + task.Status + "**\n" +
406 "- Escrow: **" + escrow + "**\n" +
407 "- Sponsor: `" + task.Sponsor.String() + "`\n" +
408 "- Contributor: `" + contributor + "`\n" +
409 "- Reward: **" + strconv.FormatInt(task.RewardAmount, 10) + " " + task.RewardDenom + "**\n" +
410 "- Delivery deadline: `" + task.DeliveryDueAt.Format(time.RFC3339) + "`\n" +
411 "- Spec hash: `" + task.SpecHash + "`"
412}