gnotask.gno
14.28 Kb · 440 lines
1package gnotaskv04
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime/unsafe"
7 "strconv"
8 "strings"
9 "time"
10)
11
12const (
13 StatusAwaitingProvider = "AwaitingProvider"
14 StatusActive = "Active"
15 StatusSubmitted = "Submitted"
16 StatusChangesRequested = "ChangesRequested"
17 StatusDisputed = "Disputed"
18 StatusPaid = "Paid"
19 StatusRefunded = "Refunded"
20 StatusCancelled = "Cancelled"
21
22 NativeRewardDenom = "ugnot"
23 MinReviewWindowSeconds = int64(24 * 60 * 60)
24 MaxReviewWindowSeconds = int64(30 * 24 * 60 * 60)
25)
26
27// Order is one independently funded fixed-price PhaseOrder. A Project may
28// reference several Orders off-chain, but this Realm never pools future-stage
29// money or promises that another Order will be funded.
30type Order struct {
31 ID string
32 Employer address
33 Provider address
34 Operator address
35 SpecHash string
36 RewardAmount int64
37 RewardDenom string
38 DeliveryDueAt time.Time
39 ReviewWindowSeconds int64
40 ReviewDeadline time.Time
41 Status string
42 RevisionRequests int
43 EvidenceHash string
44 ReviewHash string
45 DisputeHash string
46 ResolutionHash string
47 DisputeOpenedBy address
48 EmployerCancelConfirmed bool
49 ProviderCancelConfirmed bool
50 Escrowed bool
51 ReleaseReason string
52}
53
54var (
55 orders = make(map[string]*Order)
56 orderIDs []string
57)
58
59func requireDirectUser(cur realm) address {
60 previous := cur.Previous()
61 if !previous.IsUserCall() {
62 panic("direct user call required")
63 }
64 return previous.Address()
65}
66
67func requireOrder(orderID string) *Order {
68 order := orders[orderID]
69 if order == nil {
70 panic("order not found")
71 }
72 return order
73}
74
75func requireEmployer(order *Order, caller address) {
76 if order.Employer != caller {
77 panic("only employer")
78 }
79}
80
81func requireProvider(order *Order, caller address) {
82 if order.Provider != caller {
83 panic("only selected provider")
84 }
85}
86
87func requireParty(order *Order, caller address) {
88 if order.Employer != caller && order.Provider != caller {
89 panic("only order party")
90 }
91}
92
93func requireOrderID(orderID string) {
94 if len(orderID) == 0 || len(orderID) > 64 {
95 panic("invalid order id")
96 }
97 for index := 0; index < len(orderID); index++ {
98 char := orderID[index]
99 valid := char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '-' || char == '_'
100 if !valid {
101 panic("invalid order id")
102 }
103 }
104}
105
106func requireHash(value, field string) {
107 if len(value) != 64 {
108 panic(field + " must be a lowercase sha256 hash")
109 }
110 for index := 0; index < len(value); index++ {
111 char := value[index]
112 if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
113 panic(field + " must be a lowercase sha256 hash")
114 }
115 }
116}
117
118func requireExactEscrow(rewardAmount int64, rewardDenom string) {
119 sent := unsafe.OriginSend()
120 if len(sent) != 1 || sent[0].Denom != rewardDenom || sent[0].Amount != rewardAmount {
121 panic("exact reward must be attached")
122 }
123}
124
125func settleEscrow(cur realm, order *Order, recipient address, reason, terminalStatus string) {
126 if !order.Escrowed {
127 panic("reward is not escrowed")
128 }
129 order.Escrowed = false
130 order.ReleaseReason = reason
131 order.Status = terminalStatus
132 coins := chain.Coins{{Denom: order.RewardDenom, Amount: order.RewardAmount}}
133 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(cur.Address(), recipient, coins)
134}
135
136// CreateOrder locks the exact GNOT price for one mutually confirmed
137// gnotask-spec-v3 Agreement. The Operator is fixed before funding and cannot
138// be replaced during a dispute.
139func CreateOrder(
140 cur realm,
141 orderID string,
142 specHash string,
143 rewardAmount int64,
144 rewardDenom string,
145 deliveryDueUnix int64,
146 reviewWindowSeconds int64,
147 provider address,
148 operator address,
149) {
150 employer := requireDirectUser(cur)
151 requireOrderID(orderID)
152 if orders[orderID] != nil {
153 panic("order already exists")
154 }
155 requireHash(specHash, "spec hash")
156 if rewardAmount <= 0 {
157 panic("reward must be positive")
158 }
159 if rewardDenom != NativeRewardDenom {
160 panic("only ugnot rewards are supported")
161 }
162 if deliveryDueUnix <= time.Now().Unix() {
163 panic("delivery deadline must be in the future")
164 }
165 if reviewWindowSeconds < MinReviewWindowSeconds || reviewWindowSeconds > MaxReviewWindowSeconds {
166 panic("invalid review window")
167 }
168 if !provider.IsValid() || provider == employer {
169 panic("invalid provider")
170 }
171 if !operator.IsValid() || operator == employer || operator == provider {
172 panic("invalid operator")
173 }
174 requireExactEscrow(rewardAmount, rewardDenom)
175
176 orders[orderID] = &Order{
177 ID: orderID,
178 Employer: employer,
179 Provider: provider,
180 Operator: operator,
181 SpecHash: specHash,
182 RewardAmount: rewardAmount,
183 RewardDenom: rewardDenom,
184 DeliveryDueAt: time.Unix(deliveryDueUnix, 0),
185 ReviewWindowSeconds: reviewWindowSeconds,
186 Status: StatusAwaitingProvider,
187 Escrowed: true,
188 }
189 orderIDs = append(orderIDs, orderID)
190 chain.Emit("OrderCreated",
191 "order_id", orderID,
192 "employer", employer.String(),
193 "provider", provider.String(),
194 "operator", operator.String(),
195 "spec_hash", specHash,
196 )
197 chain.Emit("FundingLocked",
198 "order_id", orderID,
199 "amount", strconv.FormatInt(rewardAmount, 10),
200 "denom", rewardDenom,
201 "delivery_due_unix", strconv.FormatInt(deliveryDueUnix, 10),
202 )
203}
204
205// CancelBeforeProviderAcceptance refunds the Employer if the selected
206// Provider has not yet accepted the funded Order from the bound address.
207func CancelBeforeProviderAcceptance(cur realm, orderID string) {
208 caller := requireDirectUser(cur)
209 order := requireOrder(orderID)
210 requireEmployer(order, caller)
211 if order.Status != StatusAwaitingProvider {
212 panic("provider has already accepted the order")
213 }
214 settleEscrow(cur, order, order.Employer, "employer_cancelled_before_provider_acceptance", StatusCancelled)
215 chain.Emit("OrderCancelled", "order_id", orderID)
216 chain.Emit("RewardRefunded", "order_id", orderID, "reason", order.ReleaseReason)
217}
218
219// AcceptOrder is the selected Provider's own on-chain address confirmation.
220func AcceptOrder(cur realm, orderID string) {
221 caller := requireDirectUser(cur)
222 order := requireOrder(orderID)
223 requireProvider(order, caller)
224 if order.Status != StatusAwaitingProvider {
225 panic("order is not awaiting provider")
226 }
227 order.Status = StatusActive
228 chain.Emit("ProviderAccepted", "order_id", orderID, "provider", caller.String())
229}
230
231// ConfirmMutualCancel records either party's direct confirmation. It moves no
232// money until both bound addresses have confirmed and no submission exists.
233func ConfirmMutualCancel(cur realm, orderID string) {
234 caller := requireDirectUser(cur)
235 order := requireOrder(orderID)
236 if order.Status != StatusActive || order.EvidenceHash != "" {
237 panic("mutual cancellation is only available before first submission")
238 }
239 requireParty(order, caller)
240 if caller == order.Employer {
241 order.EmployerCancelConfirmed = true
242 } else {
243 order.ProviderCancelConfirmed = true
244 }
245 chain.Emit("MutualCancelConfirmed", "order_id", orderID, "party", caller.String())
246 if !order.EmployerCancelConfirmed || !order.ProviderCancelConfirmed {
247 return
248 }
249 settleEscrow(cur, order, order.Employer, "mutual_cancel_before_submission", StatusCancelled)
250 chain.Emit("OrderCancelled", "order_id", orderID)
251 chain.Emit("RewardRefunded", "order_id", orderID, "reason", order.ReleaseReason)
252}
253
254func SubmitEvidence(cur realm, orderID, evidenceHash string) {
255 caller := requireDirectUser(cur)
256 order := requireOrder(orderID)
257 requireProvider(order, caller)
258 if order.Status != StatusActive && order.Status != StatusChangesRequested {
259 panic("order does not accept evidence")
260 }
261 now := time.Now()
262 if order.Status == StatusActive && !now.Before(order.DeliveryDueAt) {
263 panic("delivery deadline reached")
264 }
265 if order.Status == StatusChangesRequested && (order.ReviewDeadline.IsZero() || !now.Before(order.ReviewDeadline)) {
266 panic("revision deadline reached")
267 }
268 requireHash(evidenceHash, "evidence hash")
269 order.EvidenceHash = evidenceHash
270 order.ReviewDeadline = now.Add(time.Duration(order.ReviewWindowSeconds) * time.Second)
271 order.Status = StatusSubmitted
272 chain.Emit("WorkSubmitted",
273 "order_id", orderID,
274 "provider", caller.String(),
275 "evidence_hash", evidenceHash,
276 "review_deadline_unix", strconv.FormatInt(order.ReviewDeadline.Unix(), 10),
277 )
278}
279
280func Review(cur realm, orderID string, accept bool, reviewHash string) {
281 caller := requireDirectUser(cur)
282 order := requireOrder(orderID)
283 requireEmployer(order, caller)
284 if order.Status != StatusSubmitted {
285 panic("order is not submitted")
286 }
287 requireHash(reviewHash, "review hash")
288 now := time.Now()
289 if !accept && (order.ReviewDeadline.IsZero() || !now.Before(order.ReviewDeadline)) {
290 panic("review deadline reached")
291 }
292 order.ReviewHash = reviewHash
293 if accept {
294 settleEscrow(cur, order, order.Provider, "employer_accepted", StatusPaid)
295 chain.Emit("OrderAccepted", "order_id", orderID, "review_hash", reviewHash)
296 chain.Emit("RewardReleased", "order_id", orderID, "provider", order.Provider.String(), "reason", order.ReleaseReason)
297 return
298 }
299 if order.RevisionRequests >= 1 {
300 panic("revision limit reached")
301 }
302 order.RevisionRequests++
303 order.ReviewDeadline = now.Add(time.Duration(order.ReviewWindowSeconds) * time.Second)
304 order.Status = StatusChangesRequested
305 chain.Emit("ChangesRequested", "order_id", orderID, "review_hash", reviewHash)
306}
307
308// OpenDispute must happen before the current review or revision deadline. The
309// Disputed status freezes review-timeout claims, refunds, and Review calls.
310func OpenDispute(cur realm, orderID, disputeHash string) {
311 caller := requireDirectUser(cur)
312 order := requireOrder(orderID)
313 requireParty(order, caller)
314 if order.Status != StatusSubmitted && order.Status != StatusChangesRequested {
315 panic("order is not disputable")
316 }
317 if order.ReviewDeadline.IsZero() || !time.Now().Before(order.ReviewDeadline) {
318 panic("dispute deadline reached")
319 }
320 requireHash(disputeHash, "dispute hash")
321 order.DisputeHash = disputeHash
322 order.DisputeOpenedBy = caller
323 order.Status = StatusDisputed
324 chain.Emit("DisputeOpened", "order_id", orderID, "opened_by", caller.String(), "dispute_hash", disputeHash)
325}
326
327// ResolveDispute deliberately supports only all-or-nothing outcomes in v0.4.
328func ResolveDispute(cur realm, orderID string, releaseToProvider bool, resolutionHash string) {
329 caller := requireDirectUser(cur)
330 order := requireOrder(orderID)
331 if caller != order.Operator {
332 panic("only operator")
333 }
334 if order.Status != StatusDisputed {
335 panic("order is not disputed")
336 }
337 requireHash(resolutionHash, "resolution hash")
338 order.ResolutionHash = resolutionHash
339 if releaseToProvider {
340 settleEscrow(cur, order, order.Provider, "dispute_released_to_provider", StatusPaid)
341 chain.Emit("DisputeResolved", "order_id", orderID, "outcome", "release_all", "resolution_hash", resolutionHash)
342 chain.Emit("RewardReleased", "order_id", orderID, "provider", order.Provider.String(), "reason", order.ReleaseReason)
343 return
344 }
345 settleEscrow(cur, order, order.Employer, "dispute_refunded_to_employer", StatusRefunded)
346 chain.Emit("DisputeResolved", "order_id", orderID, "outcome", "refund_all", "resolution_hash", resolutionHash)
347 chain.Emit("RewardRefunded", "order_id", orderID, "reason", order.ReleaseReason)
348}
349
350func ClaimAfterReviewDeadline(cur realm, orderID string) {
351 caller := requireDirectUser(cur)
352 order := requireOrder(orderID)
353 requireProvider(order, caller)
354 if order.Status != StatusSubmitted {
355 panic("order is not awaiting review")
356 }
357 if order.ReviewDeadline.IsZero() || time.Now().Before(order.ReviewDeadline) {
358 panic("review deadline not reached")
359 }
360 settleEscrow(cur, order, order.Provider, "review_timeout", StatusPaid)
361 chain.Emit("RewardReleased", "order_id", orderID, "provider", order.Provider.String(), "reason", order.ReleaseReason)
362}
363
364func RefundExpiredOrder(cur realm, orderID string) {
365 caller := requireDirectUser(cur)
366 order := requireOrder(orderID)
367 requireEmployer(order, caller)
368 if order.Status != StatusActive && order.Status != StatusChangesRequested {
369 panic("order is not refundable after delivery timeout")
370 }
371 deadline := order.DeliveryDueAt
372 if order.Status == StatusChangesRequested {
373 deadline = order.ReviewDeadline
374 }
375 if deadline.IsZero() || time.Now().Before(deadline) {
376 panic("delivery deadline not reached")
377 }
378 settleEscrow(cur, order, order.Employer, "delivery_timeout", StatusRefunded)
379 chain.Emit("RewardRefunded", "order_id", orderID, "reason", order.ReleaseReason)
380}
381
382func OrderCount() int {
383 return len(orderIDs)
384}
385
386// GetOrderState is a compact query response consumed by the v0.4 API verifier.
387func GetOrderState(orderID string) string {
388 order := requireOrder(orderID)
389 disputeOpenedBy := ""
390 if order.DisputeOpenedBy.IsValid() {
391 disputeOpenedBy = order.DisputeOpenedBy.String()
392 }
393 reviewDeadlineUnix := int64(0)
394 if !order.ReviewDeadline.IsZero() {
395 reviewDeadlineUnix = order.ReviewDeadline.Unix()
396 }
397 return strings.Join([]string{
398 order.ID,
399 order.Employer.String(),
400 order.Provider.String(),
401 order.Operator.String(),
402 order.Status,
403 order.SpecHash,
404 strconv.FormatInt(order.RewardAmount, 10),
405 order.RewardDenom,
406 strconv.FormatInt(order.DeliveryDueAt.Unix(), 10),
407 strconv.FormatInt(order.ReviewWindowSeconds, 10),
408 strconv.FormatInt(reviewDeadlineUnix, 10),
409 strconv.Itoa(order.RevisionRequests),
410 order.EvidenceHash,
411 order.ReviewHash,
412 order.DisputeHash,
413 order.ResolutionHash,
414 disputeOpenedBy,
415 strconv.FormatBool(order.EmployerCancelConfirmed),
416 strconv.FormatBool(order.ProviderCancelConfirmed),
417 strconv.FormatBool(order.Escrowed),
418 order.ReleaseReason,
419 }, "|")
420}
421
422func Render(path string) string {
423 orderID := strings.TrimPrefix(path, "/")
424 if orderID == "" {
425 return "# GnoTask v0.4\n\nIndependently funded GNOT service Orders.\n\nOn-chain Orders: **" + strconv.Itoa(len(orderIDs)) + "**"
426 }
427 order := requireOrder(orderID)
428 escrow := "Released"
429 if order.Escrowed {
430 escrow = "Locked in Realm"
431 }
432 return "# Order `" + order.ID + "`\n\n" +
433 "- Status: **" + order.Status + "**\n" +
434 "- Escrow: **" + escrow + "**\n" +
435 "- Employer: `" + order.Employer.String() + "`\n" +
436 "- Provider: `" + order.Provider.String() + "`\n" +
437 "- Operator: `" + order.Operator.String() + "`\n" +
438 "- Price: **" + strconv.FormatInt(order.RewardAmount, 10) + " " + order.RewardDenom + "**\n" +
439 "- Spec hash: `" + order.SpecHash + "`"
440}