Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}