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

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}