diff --git a/contribs/gnodev/cmd/gnodev/main.go b/contribs/gnodev/cmd/gnodev/main.go index 2038dc647e7..30196527311 100644 --- a/contribs/gnodev/cmd/gnodev/main.go +++ b/contribs/gnodev/cmd/gnodev/main.go @@ -256,6 +256,13 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { // Setup gnoweb webhandler := setupGnoWebServer(logger.WithGroup(WebLogName), cfg, devNode) + mux.HandleFunc("/reset", func(res http.ResponseWriter, req *http.Request) { + if err := devNode.Reset(req.Context()); err != nil { + logger.Error("failed to reset", slog.Any("err", err)) + res.WriteHeader(http.StatusInternalServerError) + } + }) + // Setup HotReload if needed if !cfg.noWatch { evtstarget := fmt.Sprintf("%s/_events", server.Addr) diff --git a/examples/gno.land/p/demo/dao_maker/jsonutil/gno.mod b/examples/gno.land/p/demo/dao_maker/jsonutil/gno.mod new file mode 100644 index 00000000000..86765ddeb64 --- /dev/null +++ b/examples/gno.land/p/demo/dao_maker/jsonutil/gno.mod @@ -0,0 +1,7 @@ +module gno.land/p/demo/dao_maker/jsonutil + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/json v0.0.0-latest + gno.land/p/demo/users v0.0.0-latest +) diff --git a/examples/gno.land/p/demo/dao_maker/jsonutil/jsonutil.gno b/examples/gno.land/p/demo/dao_maker/jsonutil/jsonutil.gno new file mode 100644 index 00000000000..8bc5c05e687 --- /dev/null +++ b/examples/gno.land/p/demo/dao_maker/jsonutil/jsonutil.gno @@ -0,0 +1,131 @@ +package jsonutil + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/json" + "gno.land/p/demo/users" +) + +func UnionNode(variant string, value *json.Node) *json.Node { + return json.ObjectNode("", map[string]*json.Node{ + variant: value, + }) +} + +func MustUnion(value *json.Node) (string, *json.Node) { + obj := value.MustObject() + for key, value := range obj { + return key, value + } + + panic("no variant in union") +} + +func TimeNode(value time.Time) *json.Node { + j, err := value.MarshalJSON() + if err != nil { + panic(err) + } + + return json.StringNode("", string(j[1:len(j)-1])) +} + +func MustTime(value *json.Node) time.Time { + t := time.Time{} + err := t.UnmarshalJSON([]byte(value.String())) + if err != nil { + panic(err) + } + + return t +} + +func DurationNode(value time.Duration) *json.Node { + return Int64Node(value.Nanoseconds()) +} + +func MustDurationSeconds(value *json.Node) time.Duration { + return time.Duration(MustInt64(value)) * time.Second +} + +func EmptyObjectNode() *json.Node { + return json.ObjectNode("", nil) +} + +// int is always 64 bits in gno so we need a string to represent it without loss of precision in a lot of javascript environment, I wish bigint in json was more widely supported +func IntNode(value int) *json.Node { + return json.StringNode("", strconv.Itoa(value)) +} + +func MustInt(value *json.Node) int { + i, err := strconv.Atoi(value.MustString()) + if err != nil { + panic(err) + } + + return i +} + +func Uint32Node(value uint32) *json.Node { + return json.StringNode("", strconv.FormatUint(uint64(value), 10)) +} + +func MustUint32(value *json.Node) uint32 { + return uint32(MustInt(value)) +} + +func Int64Node(value int64) *json.Node { + return json.StringNode("", strconv.FormatInt(value, 10)) +} + +func MustInt64(value *json.Node) int64 { + return int64(MustInt(value)) +} + +func Uint64Node(value uint64) *json.Node { + return json.StringNode("", strconv.FormatUint(value, 10)) +} + +func MustUint64(value *json.Node) uint64 { + return uint64(MustInt(value)) // FIXME: full uint64 range support (currently limited to [-2^63, 2^63-1]) +} + +func AVLTreeNode(root *avl.Tree, transform func(elem interface{}) *json.Node) *json.Node { + if root == nil { + return EmptyObjectNode() + } + + fields := make(map[string]*json.Node) + root.Iterate("", "", func(key string, val interface{}) bool { + fields[key] = transform(val) + return false + }) + + return json.ObjectNode("", fields) +} + +func AddressNode(addr std.Address) *json.Node { + return json.StringNode("", addr.String()) +} + +func MustAddress(value *json.Node) std.Address { + addr := std.Address(value.MustString()) + if !addr.IsValid() { + panic("invalid address") + } + + return addr +} + +func AddressOrNameNode(aon users.AddressOrName) *json.Node { + return json.StringNode("", string(aon)) +} + +func MustAddressOrName(value *json.Node) users.AddressOrName { + aon := users.AddressOrName(value.MustString()) + return aon +} diff --git a/examples/gno.land/p/demo/stokey/gno.mod b/examples/gno.land/p/demo/stokey/gno.mod new file mode 100644 index 00000000000..45145f2d71d --- /dev/null +++ b/examples/gno.land/p/demo/stokey/gno.mod @@ -0,0 +1,3 @@ +module gno.land/p/demo/stokey + +require gno.land/p/demo/seqid v0.0.0-latest diff --git a/examples/gno.land/p/demo/stokey/storekey.gno b/examples/gno.land/p/demo/stokey/storekey.gno new file mode 100644 index 00000000000..e5f5a81da86 --- /dev/null +++ b/examples/gno.land/p/demo/stokey/storekey.gno @@ -0,0 +1,95 @@ +package stokey + +import ( + "std" + "strings" + + "gno.land/p/demo/seqid" +) + +type SubKey interface { + KeyString() string +} + +type Key []SubKey + +func NewKey(subKeys ...SubKey) Key { + return Key(subKeys) +} + +func (k Key) String() string { + b := strings.Builder{} + for _, subKey := range k { + b.WriteString(subKey.KeyString()) + } + return b.String() +} + +// std.Address + +type addrSubKey std.Address + +func Address(addr std.Address) SubKey { + return addrSubKey(addr) +} + +func (a addrSubKey) KeyString() string { + _, b, ok := std.DecodeBech32(std.Address(a)) + if !ok { + panic("invalid address") + } + return string(b[:]) +} + +type noAddrSubKey struct{} + +func NoAddress() SubKey { + return noAddrSubKey{} +} + +func (na noAddrSubKey) KeyString() string { + return string(make([]byte, 20)) +} + +type nextAddrSubKey std.Address + +func NextAddress(addr std.Address) SubKey { + return nextAddrSubKey(addr) +} + +func (na nextAddrSubKey) KeyString() string { + _, b, ok := std.DecodeBech32(std.Address(na)) + if !ok { + panic("invalid address") + } + for i := len(b) - 1; i >= 0; i-- { + if b[i] == 255 { + if i == 0 { + panic("overflow") + } + b[i] = 0 + } else { + b[i]++ + break + } + } + return string(b[:]) +} + +// uint64 + +type uint64SubKey uint64 + +func Uint64(u uint64) SubKey { + return uint64SubKey(u) +} + +func (u uint64SubKey) KeyString() string { + return seqid.ID(u).String() +} + +// uint32 + +func Uint32(u uint32) SubKey { + return uint64SubKey(u) +} diff --git a/examples/gno.land/p/demo/stokey/utils.gno b/examples/gno.land/p/demo/stokey/utils.gno new file mode 100644 index 00000000000..008cfcccc09 --- /dev/null +++ b/examples/gno.land/p/demo/stokey/utils.gno @@ -0,0 +1,3 @@ +package stokey + +// TODO: add a KeySchema type to explicitely define keys shape and allow more qol utils diff --git a/examples/gno.land/r/demo/teritori/projects_manager/filter.gno b/examples/gno.land/r/demo/teritori/projects_manager/filter.gno new file mode 100644 index 00000000000..0d727f3353d --- /dev/null +++ b/examples/gno.land/r/demo/teritori/projects_manager/filter.gno @@ -0,0 +1,81 @@ +package projects_manager + +import ( + "std" + + "gno.land/p/demo/dao_maker/jsonutil" + "gno.land/p/demo/json" + "gno.land/p/demo/ufmt" +) + +type Filter interface { + FromJSON(ast *json.Node) +} + +func FilterFromJSON(ast *json.Node) Filter { + if ast.IsNull() { + return nil + } + var filter Filter + key, member := jsonutil.MustUnion(ast) + switch key { + case "byCandidatesForFunder": + filter = &FilterByCandidatesForFunder{} + case "byFunder": + filter = &FilterByFunder{} + case "byContractor": + filter = &FilterByContractor{} + case "byContractorAndFunder": + filter = &FilterByContractorAndFunder{} + default: + panic(ufmt.Sprintf("invalid filter kind `%s`", key)) + } + filter.FromJSON(member) + return filter +} + +type FilterByCandidatesForFunder struct { + Funder std.Address +} + +func (f *FilterByCandidatesForFunder) FromJSON(ast *json.Node) { + obj := ast.MustObject() + f.Funder = jsonutil.MustAddress(obj["funder"]) +} + +var _ Filter = &FilterByCandidatesForFunder{} + +type FilterByFunder struct { + Funder std.Address +} + +func (f *FilterByFunder) FromJSON(ast *json.Node) { + obj := ast.MustObject() + f.Funder = jsonutil.MustAddress(obj["funder"]) +} + +var _ Filter = &FilterByFunder{} + +type FilterByContractor struct { + Contractor std.Address +} + +func (f *FilterByContractor) FromJSON(ast *json.Node) { + obj := ast.MustObject() + f.Contractor = jsonutil.MustAddress(obj["contractor"]) +} + +var _ Filter = &FilterByContractor{} + +type FilterByContractorAndFunder struct { + Contractor std.Address + Funder std.Address +} + +func (f *FilterByContractorAndFunder) FromJSON(ast *json.Node) { + obj := ast.MustObject() + f.Contractor = jsonutil.MustAddress(obj["contractor"]) + f.Funder = jsonutil.MustAddress(obj["funder"]) +} + +var _ Filter = &FilterByContractorAndFunder{} diff --git a/examples/gno.land/r/demo/teritori/projects_manager/gno.mod b/examples/gno.land/r/demo/teritori/projects_manager/gno.mod new file mode 100644 index 00000000000..d828758f043 --- /dev/null +++ b/examples/gno.land/r/demo/teritori/projects_manager/gno.mod @@ -0,0 +1,9 @@ +module gno.land/r/demo/teritori/projects_manager + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/dao_maker/jsonutil v0.0.0-latest + gno.land/p/demo/json v0.0.0-latest + gno.land/p/demo/seqid v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest +) diff --git a/examples/gno.land/r/demo/teritori/projects_manager/projects_manager.gno b/examples/gno.land/r/demo/teritori/projects_manager/projects_manager.gno new file mode 100644 index 00000000000..ff3da646d6f --- /dev/null +++ b/examples/gno.land/r/demo/teritori/projects_manager/projects_manager.gno @@ -0,0 +1,941 @@ +package projects_manager + +import ( + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/dao_maker/jsonutil" + "gno.land/p/demo/json" + "gno.land/p/demo/seqid" + "gno.land/p/demo/ufmt" +) + +type ContractStatus uint32 + +const ( + CREATED ContractStatus = 1 + ACCEPTED ContractStatus = 2 + CANCELED ContractStatus = 3 + COMPLETED ContractStatus = 5 + REJECTED ContractStatus = 6 + CONFLICT ContractStatus = 7 + ABORTED_IN_FAVOR_OF_CONTRACTOR ContractStatus = 8 + ABORTED_IN_FAVOR_OF_FUNDER ContractStatus = 9 +) + +func (x ContractStatus) String() string { + switch x { + case CREATED: + return "CREATED" + case ACCEPTED: + return "ACCEPTED" + case CANCELED: + return "CANCELED" + case COMPLETED: + return "COMPLETED" + case REJECTED: + return "REJECTED" + case CONFLICT: + return "CONFLICT" + case ABORTED_IN_FAVOR_OF_CONTRACTOR: + return "ABORTED_IN_FAVOR_OF_CONTRACTOR" + case ABORTED_IN_FAVOR_OF_FUNDER: + return "ABORTED_IN_FAVOR_OF_FUNDER" + } + return "UNKNOWN" +} + +func (x ContractStatus) ToJSON() *json.Node { + return json.StringNode("", x.String()) +} + +type ConflictOutcome uint32 + +const ( + RESUME_CONTRACT ConflictOutcome = 1 + REFUND_FUNDER ConflictOutcome = 2 + PAY_CONTRACTOR ConflictOutcome = 3 +) + +func (x ConflictOutcome) String() string { + switch x { + case RESUME_CONTRACT: + return "RESUME_CONTRACT" + case REFUND_FUNDER: + return "REFUND_FUNDER" + case PAY_CONTRACTOR: + return "PAY_CONTRACTOR" + } + return "UNKNOWN" +} + +func (x ConflictOutcome) ToJSON() *json.Node { + return json.StringNode("", x.String()) +} + +type MilestoneStatus uint32 + +const ( + MS_OPEN MilestoneStatus = 1 + MS_PROGRESS MilestoneStatus = 2 + MS_REVIEW MilestoneStatus = 3 + MS_COMPLETED MilestoneStatus = 4 +) + +func (x MilestoneStatus) String() string { + switch x { + case MS_OPEN: + return "MS_OPEN" + case MS_PROGRESS: + return "MS_PROGRESS" + case MS_REVIEW: + return "MS_REVIEW" + case MS_COMPLETED: + return "MS_COMPLETED" + } + return "UNKNOWN" +} + +func (x MilestoneStatus) ToJSON() *json.Node { + return json.StringNode("", x.String()) +} + +type MilestonePriority uint32 + +const ( + MS_PRIORITY_HIGH MilestonePriority = 1 + MS_PRIORITY_MEDIUM MilestonePriority = 2 + MS_PRIORITY_LOW MilestonePriority = 3 +) + +func (x MilestonePriority) String() string { + switch x { + case MS_PRIORITY_HIGH: + return "MS_PRIORITY_HIGH" + case MS_PRIORITY_MEDIUM: + return "MS_PRIORITY_MEDIUM" + case MS_PRIORITY_LOW: + return "MS_PRIORITY_LOW" + } + return "UNKNOWN" +} + +func MilestonePriorityFromString(s string) MilestonePriority { + switch s { + case "MS_PRIORITY_HIGH": + return MS_PRIORITY_HIGH + case "MS_PRIORITY_MEDIUM": + return MS_PRIORITY_MEDIUM + case "MS_PRIORITY_LOW": + return MS_PRIORITY_LOW + } + panic("invalid MilestonePriority") +} + +func (x MilestonePriority) ToJSON() *json.Node { + return json.StringNode("", x.String()) +} + +type Milestone struct { + id uint64 + title string + desc string + amount int64 + paid int64 + duration time.Duration // marshal as seconds + link string // milestone reference link + funded bool + priority MilestonePriority + status MilestoneStatus +} + +func (ms Milestone) ToJSON() *json.Node { + return json.ObjectNode("", map[string]*json.Node{ + "id": json.StringNode("", strconv.FormatUint(ms.id, 10)), + "title": json.StringNode("", ms.title), + "desc": json.StringNode("", ms.desc), + "amount": json.StringNode("", strconv.FormatInt(ms.amount, 10)), + "paid": json.StringNode("", strconv.FormatInt(ms.paid, 10)), + "duration": json.NumberNode("", ms.duration.Seconds()), + "link": json.StringNode("", ms.link), + "funded": json.BoolNode("", ms.funded), + "priority": ms.priority.ToJSON(), + "status": ms.status.ToJSON(), + }) +} + +type Conflict struct { + initiator std.Address + createdAt time.Time + respondedAt *time.Time + resolvedAt *time.Time + initiatorMessage string + responseMessage *string + resolutionMessage *string + outcome *ConflictOutcome +} + +func (c Conflict) ToJSON() *json.Node { + children := map[string]*json.Node{ + "initiator": json.StringNode("", c.initiator.String()), + "createdAt": json.StringNode("", c.createdAt.Format(time.RFC3339)), + "initiatorMessage": json.StringNode("", c.initiatorMessage), + } + + if c.responseMessage != nil { + children["responseMessage"] = json.StringNode("", *c.responseMessage) + } + if c.respondedAt != nil { + children["respondedAt"] = json.StringNode("", c.respondedAt.Format(time.RFC3339)) + } + if c.resolvedAt != nil { + children["resolvedAt"] = json.StringNode("", c.resolvedAt.Format(time.RFC3339)) + } + if c.resolutionMessage != nil { + children["resolutionMessage"] = json.StringNode("", *c.resolutionMessage) + } + if c.outcome != nil { + children["outcome"] = c.outcome.ToJSON() + } + + return json.ObjectNode("", children) +} + +type Contract struct { + id uint64 + sender std.Address + contractor std.Address + contractorCandidates []std.Address + funder std.Address // funder address + paymentDenom string // banker denom + metadata string // store data forforimage, tags, name, description, links for twitter/github... + status ContractStatus + expireAt time.Time + funderFeedback string + contractorFeedback string + milestones []Milestone + pausedBy string + conflictHandler string // can be a realm path or a caller address + handlerCandidate string // conflict handler candidate suggested by one party + handlerSuggestor string // the suggestor off the conflict handler candidate + createdAt time.Time + budget int64 + funded bool + rejectReason string + conflicts []Conflict +} + +func (c Contract) ToJSON() *json.Node { + candidates := make([]*json.Node, len(c.contractorCandidates)) + for i, candidate := range c.contractorCandidates { + candidates[i] = json.StringNode("", candidate.String()) + } + + milestones := make([]*json.Node, len(c.milestones)) + for i, milestone := range c.milestones { + milestones[i] = milestone.ToJSON() + } + + conflicts := make([]*json.Node, len(c.conflicts)) + for i, conflict := range c.conflicts { + conflicts[i] = conflict.ToJSON() + } + + return json.ObjectNode("", map[string]*json.Node{ + "id": json.StringNode("", strconv.FormatUint(c.id, 10)), + "sender": json.StringNode("", c.sender.String()), + "contractor": json.StringNode("", c.contractor.String()), + "contractorCandidates": json.ArrayNode("", candidates), + "funder": json.StringNode("", c.funder.String()), + "paymentDenom": json.StringNode("", c.paymentDenom), + "metadata": json.StringNode("", c.metadata), + "status": c.status.ToJSON(), + "expireAt": json.StringNode("", c.expireAt.Format(time.RFC3339)), + "funderFeedback": json.StringNode("", c.funderFeedback), + "contractorFeedback": json.StringNode("", c.contractorFeedback), + "milestones": json.ArrayNode("", milestones), + "pausedBy": json.StringNode("", c.pausedBy), + "conflictHandler": json.StringNode("", c.conflictHandler), + "handlerCandidate": json.StringNode("", c.handlerCandidate), + "handlerSuggestor": json.StringNode("", c.handlerSuggestor), + "createdAt": json.StringNode("", c.createdAt.Format(time.RFC3339)), + "budget": json.StringNode("", strconv.FormatInt(c.budget, 10)), + "funded": json.BoolNode("", c.funded), + "rejectReason": json.StringNode("", c.rejectReason), + "conflicts": json.ArrayNode("", conflicts), + }) +} + +// State +var ( + contracts []*Contract + contractsByFunder = avl.NewTree() // std.Address(funder) => contractID => *Contract + contractsByContractor = avl.NewTree() // std.Address(contractor) => contractID => *Contract + contractsByFunderAndContractor = avl.NewTree() // std.Address(funder) + std.Address(contractor) => contractID => *Contract + contractsWithCandidates = avl.NewTree() // std.Address(funder) => contractID => *Contract +) + +func setIndices(contract *Contract) { + if contract == nil { + panic("contract is nil") + } + + if contract.contractor != "" { + contractorKey := std.Address(contract.contractor).String() + byIDTree, ok := contractsByContractor.Get(contractorKey) + if !ok { + byIDTree = avl.NewTree() + contractsByContractor.Set(contractorKey, byIDTree) + } + + byIDTree.(*avl.Tree).Set(seqid.ID(contract.id).String(), contract) + } + + if contract.funder != "" { + funderKey := std.Address(contract.funder).String() + byIDTree, ok := contractsByFunder.Get(funderKey) + if !ok { + byIDTree = avl.NewTree() + contractsByFunder.Set(funderKey, byIDTree) + } + + byIDTree.(*avl.Tree).Set(seqid.ID(contract.id).String(), contract) + } + + if contract.contractor != "" && contract.funder != "" { + byIDTree, ok := contractsByFunderAndContractor.Get(std.Address(contract.funder).String() + std.Address(contract.contractor).String()) + if !ok { + byIDTree = avl.NewTree() + contractsByFunderAndContractor.Set(std.Address(contract.funder).String()+std.Address(contract.contractor).String(), byIDTree) + } + + byIDTree.(*avl.Tree).Set(seqid.ID(contract.id).String(), contract) + } +} + +func CurrentRealm() string { + return std.CurrentRealm().Addr().String() +} + +type MilestoneDefinition struct { + Title string + Desc string + Amount int64 + Duration time.Duration + Link string + Priority MilestonePriority +} + +func CreateContract( + contractor std.Address, + funder std.Address, + paymentDenom string, + metadata string, + expiryDurationSeconds uint64, + milestones []MilestoneDefinition, + conflictHandler string, +) { + if contractor != "" && !contractor.IsValid() { + panic("invalid contractor address") + } + + if funder != "" && !funder.IsValid() { + panic("invalid funder address") + } + + caller := std.PrevRealm().Addr() + if expiryDurationSeconds == 0 { + panic("invalid expiryDuration") + } + if paymentDenom == "" { + panic("empty escrow token") + } + + // For now, one of funder or contract could be empty and can be set later + if contractor == "" && funder == "" { + panic("contractor and funder cannot be both empty") + } + + if contractor != caller && funder != caller { + panic("caller should be one of contractor or funder") + } + + if len(milestones) == 0 { + panic("milestones should not be empty") + } + + mss := make([]Milestone, 0, len(milestones)) + projectBudget := int64(0) + for _, ms := range milestones { + projectBudget += ms.Amount + mss = append(mss, Milestone{ + id: uint64(len(mss)), + title: ms.Title, + desc: ms.Desc, + amount: ms.Amount, + paid: 0, + duration: ms.Duration, + link: ms.Link, + priority: ms.Priority, + status: MS_OPEN, + }) + } + + // If contract creator is funder then he needs to send all the needed fund to contract + funded := false + if caller == funder { + sent := std.GetOrigSend() + amount := sent.AmountOf(paymentDenom) + if amount != projectBudget { + panic(ufmt.Sprintf("funder `%s` should send `%d%s`, got `%d%s`", caller, projectBudget, paymentDenom, amount, paymentDenom)) + } + funded = true + } + + expiryDuration := time.Duration(expiryDurationSeconds) * time.Second + now := time.Now() + + contractId := uint64(len(contracts)) + contracts = append(contracts, &Contract{ + id: contractId, + sender: caller, + contractor: contractor, + funder: funder, + paymentDenom: paymentDenom, + metadata: metadata, + status: CREATED, + expireAt: now.Add(expiryDuration), + milestones: mss, + conflictHandler: conflictHandler, + budget: projectBudget, + createdAt: now, + funded: funded, + }) + setIndices(contracts[contractId]) +} + +func CreateContractJSON( + contractor std.Address, + funder std.Address, + paymentDenom string, + metadata string, + expiryDurationSeconds uint64, + milestonesJSON string, + conflictHandler string, +) { + ast, err := json.Unmarshal([]byte(milestonesJSON)) + if err != nil { + panic(err) + } + vals := ast.MustArray() + milestones := make([]MilestoneDefinition, 0, len(vals)) + for _, val := range vals { + obj := val.MustObject() + milestone := MilestoneDefinition{ + Title: obj["title"].MustString(), + Desc: obj["desc"].MustString(), + Amount: jsonutil.MustInt64(obj["amount"]), + Duration: jsonutil.MustDurationSeconds(obj["duration"]), + Link: obj["link"].MustString(), + Priority: MilestonePriorityFromString(obj["priority"].MustString()), + } + milestones = append(milestones, milestone) + } + CreateContract(contractor, funder, paymentDenom, metadata, expiryDurationSeconds, milestones, conflictHandler) +} + +func CancelContract(contractId uint64) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + if contract.status != CREATED { + panic("contract can only be cancelled at CREATED status") + } + + if contract.sender != caller { + panic("not authorized to cancel the contract") + } + + contracts[contractId].status = CANCELED +} + +func RejectContract(contractId uint64, rejectReason string) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + if contract.status != CREATED { + panic("contract can only be cancelled at CREATED status") + } + + if contract.sender == contract.contractor && caller != contract.funder { + // If contract creator is contractor then only funder can reject + panic("only funder can reject a request from contractor") + } else if contract.sender == contract.funder && caller != contract.contractor { + // If contract creator is funder then only contractor can reject + panic("only contractor can reject a request from funder") + } + + contracts[contractId].status = REJECTED + contracts[contractId].rejectReason = rejectReason +} + +func AcceptContract(contractId uint64) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + if contract.status != CREATED { + panic("contract can only be accepted at CREATED status") + } + + if time.Now().After(contract.expireAt) { + panic("contract already expired") + } + + if contract.sender == caller { + panic("contract sender is not able to accept the contract") + } + + if contract.funder != caller && contract.contractor != caller { + panic("only contract counterparty is allowed to accept") + } + contracts[contractId].status = ACCEPTED +} + +// Submit a funder by putting funds for specific milestones +func SubmitFunder(contractId uint64) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + + if contract.status != CREATED { + panic("can only submit candidate to a CREATED contract") + } + + if contract.funder != "" { + panic("the contract has already a funder") + } + + if caller == contract.contractor { + panic("you cannot become a funder of your requested contract") + } + + sent := std.GetOrigSend() + amount := sent.AmountOf(contract.paymentDenom) + if amount != contract.budget { + panic("wrong amount of funds sent") + } + + contracts[contractId].funded = true + contracts[contractId].status = ACCEPTED + contracts[contractId].funder = caller +} + +// Accept candidate as a contractor +func AcceptContractor(contractId uint64, contractor std.Address) { + if !contractor.IsValid() { + panic("invalid contractor address") + } + + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + + if contract.status != CREATED { + panic("can only submit candidate to a CREATED contract") + } + + if contract.contractor != "" { + panic("the contract has already a contractor") + } + + if caller != contract.funder { + panic("Only contract funder can accept contractor") + } + + candidates := contracts[contractId].contractorCandidates + for _, candidate := range candidates { + // Accept the contract if the address already submitted candidate request + if candidate == contractor { + contracts[contractId].status = ACCEPTED + } + } + + contracts[contractId].contractor = contractor + + funderKey := contract.funder.String() + byIDTreeIface, ok := contractsWithCandidates.Get(funderKey) + if !ok { + byIDTreeIface = avl.NewTree() + contractsWithCandidates.Set(funderKey, byIDTreeIface) + } + byIDTree := byIDTreeIface.(*avl.Tree) + byIDTree.Remove(seqid.ID(contract.id).String()) + if byIDTree.Size() == 0 { + contractsWithCandidates.Remove(funderKey) + } +} + +func SubmitContractorCandidate(contractId uint64) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + + if contract.status != CREATED { + panic("can only submit candidate to a CREATED contract") + } + + if contract.contractor != "" { + panic("the contract has already a contractor") + } + + if caller == contract.funder { + panic("you cannot become a contractor of your funded contract") + } + + candidates := contracts[contractId].contractorCandidates + for _, candidate := range candidates { + if candidate == caller { + panic("already a contractor candidate") + } + } + + contracts[contractId].contractorCandidates = append(candidates, caller) + + funderKey := contract.funder.String() + byIDTree, ok := contractsWithCandidates.Get(funderKey) + if !ok { + byIDTree = avl.NewTree() + contractsWithCandidates.Set(funderKey, byIDTree) + } + byIDTree.(*avl.Tree).Set(seqid.ID(contract.id).String(), contract) +} + +// Complete any milestone in review status and pay the needed amount +func CompleteMilestoneAndPay(contractId uint64, milestoneId uint64) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + if contract.funder != caller { + panic("only contract funder can pay the milestone") + } + + if contract.status != ACCEPTED { + panic("only accepted contract can be paid") + } + + milestone := contract.milestones[milestoneId] + if milestone.status != MS_REVIEW { + panic("can only complete and pay a milestone which is in review status") + } + + // Pay the milestone + unpaid := milestone.amount - milestone.paid + if unpaid > 0 { + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + contract.contractor, + std.Coins{std.Coin{contract.paymentDenom, int64(unpaid)}}) + contracts[contractId].milestones[milestoneId].paid += unpaid + } + + contracts[contractId].milestones[milestoneId].status = MS_COMPLETED + + // If finish all milestone then complete the contract + completedCount := 0 + for _, milestone := range contract.milestones { + if milestone.status == MS_COMPLETED { + completedCount++ + } + } + + if completedCount == len(contract.milestones) { + contracts[contractId].status = COMPLETED + } +} + +// Set milestone status +func ChangeMilestoneStatus(contractId uint64, milestoneId int, newStatus MilestoneStatus) { + if newStatus == MS_COMPLETED { + panic("use CompleteMilestoneAndPay to complete and pay the milestone") + } + + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + contract := contracts[contractId] + + caller := std.PrevRealm().Addr() + if contract.funder != caller && contract.contractor != caller { + panic("only contract participant can execute the action") + } + + if contract.status != ACCEPTED { + panic("contract is not on accepted status") + } + + if len(contract.milestones) <= milestoneId { + panic("milestone Id does not exist in contract") + } + milestone := contract.milestones[milestoneId] + + if milestone.status == MS_COMPLETED { + panic("milestone is completed") + } + + contracts[contractId].milestones[milestoneId].status = newStatus +} + +func RequestConflictResolution(contractId uint64, message string) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + if contract.funder != caller && contract.contractor != caller { + panic("only contract participants can request conflict resolution") + } + + if contract.status != ACCEPTED { + panic("conflict resolution can only be requested at ACCEPTED status") + } + + contracts[contractId].status = CONFLICT + + contracts[contractId].conflicts = append(contract.conflicts, Conflict{ + initiator: caller, + createdAt: time.Now(), + initiatorMessage: message, + }) +} + +func RespondToConflict(contractId uint64, message string) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + if contract.status != CONFLICT { + panic("conflict can only be responded at CONFLICT status") + } + + if len(contract.conflicts) == 0 { + panic("no conflict exists, this should not happen") + } + + conflictId := len(contract.conflicts) - 1 + conflict := contract.conflicts[conflictId] + + if conflict.initiator == contract.funder { + if contract.contractor != caller { + panic("only contract funder can respond to this conflict") + } + } else if conflict.initiator == contract.contractor { + if contract.funder != caller { + panic("only contract contractor can respond to this conflict") + } + } else { + panic("conflict initiator is not valid") + } + + contracts[contractId].conflicts[conflictId].responseMessage = &message + now := time.Now() + contracts[contractId].conflicts[conflictId].respondedAt = &now +} + +func ResolveConflict(contractId uint64, outcome ConflictOutcome, resolutionMessage string) { + caller := std.PrevRealm().Addr() + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + contract := contracts[contractId] + if contract.conflictHandler != caller.String() { + panic("only conflictHandler is allowed for this operation") + } + + if contract.status != CONFLICT { + panic("conflict can only be resolved at CONFLICT status") + } + + if len(contract.conflicts) == 0 { + panic("no conflict exists") + } + + conflictId := len(contract.conflicts) - 1 + + switch outcome { + case RESUME_CONTRACT: + contracts[contractId].status = ACCEPTED + case REFUND_FUNDER: + totalPaid := int64(0) + for _, milestone := range contract.milestones { + totalPaid += milestone.paid + } + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + contract.funder, + std.Coins{std.Coin{contract.paymentDenom, contract.budget - totalPaid}}) + contracts[contractId].status = ABORTED_IN_FAVOR_OF_FUNDER + case PAY_CONTRACTOR: + totalPaid := int64(0) + for _, milestone := range contract.milestones { + totalPaid += milestone.paid + } + banker := std.GetBanker(std.BankerTypeRealmSend) + banker.SendCoins( + std.CurrentRealm().Addr(), + contract.contractor, + std.Coins{std.Coin{contract.paymentDenom, contract.budget - totalPaid}}) + contracts[contractId].status = ABORTED_IN_FAVOR_OF_CONTRACTOR + default: + panic("invalid outcome") + } + + contracts[contractId].conflicts[conflictId].resolutionMessage = &resolutionMessage + contracts[contractId].conflicts[conflictId].outcome = &outcome + now := time.Now() + contracts[contractId].conflicts[conflictId].resolvedAt = &now +} + +func GetContractorCandidatesJSON(contractId uint64) string { + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + candidates := contracts[contractId].contractorCandidates + candidatesJSON := make([]*json.Node, len(candidates)) + for i, candidate := range candidates { + candidatesJSON[i] = json.StringNode("", candidate.String()) + } + + ret, err := json.Marshal(json.ArrayNode("", candidatesJSON)) + if err != nil { + panic(err) + } + return string(ret) +} + +func GetContracts(offset, limit int, filter Filter) []*Contract { + if offset < 0 { + offset = 0 + } + + if limit <= 0 || offset >= len(contracts) { + return nil + } + + if filter == nil { + end := offset + limit + if end > len(contracts) { + end = len(contracts) + } + return contracts[offset:end] + } + + var tree interface{} + switch f := filter.(type) { + case *FilterByCandidatesForFunder: + tree, _ = contractsWithCandidates.Get(f.Funder.String()) + case *FilterByContractorAndFunder: + tree, _ = contractsByFunderAndContractor.Get(f.Funder.String() + f.Contractor.String()) + case *FilterByContractor: + tree, _ = contractsByContractor.Get(f.Contractor.String()) + case *FilterByFunder: + tree, _ = contractsByFunder.Get(f.Funder.String()) + default: + panic("unknown filter") + } + + if tree == nil { + return nil + } + + var results []*Contract + tree.(*avl.Tree).IterateByOffset(offset, limit, func(key string, value interface{}) bool { + results = append(results, value.(*Contract)) + return false + }) + + return results +} + +func RenderContractJSON(contractId uint64) string { + if int(contractId) >= len(contracts) { + panic("invalid contract id") + } + + c := contracts[contractId] + ret, err := json.Marshal(c.ToJSON()) + if err != nil { + panic(err) + } + + return string(ret) +} + +func RenderContractsJSON(offset, limit int, filterJSON string) string { + filter := FilterFromJSON(json.Must(json.Unmarshal([]byte(filterJSON)))) + contractsRes := GetContracts(offset, limit, filter) + return renderContractsJSON(contractsRes) +} + +func renderContractsJSON(contractsRes []*Contract) string { + contractsJSON := make([]*json.Node, len(contractsRes)) + for i, c := range contractsRes { + contractsJSON[i] = c.ToJSON() + } + + ret, err := json.Marshal(json.ArrayNode("", contractsJSON)) + if err != nil { + panic(err) + } + return string(ret) +} + +func Render(path string) string { + b := strings.Builder{} + b.WriteString("# Projects Manager\n") + b.WriteString("## Overview\n") + b.WriteString("This contract is a simple project manager that allows users to create projects and manage them.\n") + b.WriteString(ufmt.Sprintf("Contracts managed: %d\n", len(contracts))) + b.WriteString("## Latest projects\n") + numContracts := 3 + if len(contracts) < 3 { + numContracts = len(contracts) + } + for i := 0; i < numContracts; i++ { + b.WriteString("```json\n") + b.WriteString(RenderContractJSON(uint64(len(contracts) - (i + 1)))) + b.WriteRune('\n') + b.WriteString("```\n") + } + return b.String() +} diff --git a/examples/gno.land/r/demo/teritori/projects_manager/projects_manager_test.gno b/examples/gno.land/r/demo/teritori/projects_manager/projects_manager_test.gno new file mode 100644 index 00000000000..f9c5d7d9dcf --- /dev/null +++ b/examples/gno.land/r/demo/teritori/projects_manager/projects_manager_test.gno @@ -0,0 +1,74 @@ +package projects_manager + +import ( + "std" + "testing" + "time" + + "gno.land/p/demo/json" +) + +func TestJSONRender(t *testing.T) { + createdAt := time.Date(2021, time.August, 1, 0, 0, 0, 0, time.UTC) + duration := time.Hour * 24 * 30 + expireAt := createdAt.Add(duration) + + // golden contract + contract := Contract{ + id: 1, + sender: std.Address("sender"), + contractor: std.Address("contractor2"), + contractorCandidates: []std.Address{"contractor1", "contractor2"}, + funder: "funder", + paymentDenom: "denom", + metadata: "metadata", + status: CREATED, + expireAt: expireAt, + funderFeedback: "funderFeedback", + contractorFeedback: "contractorFeedback", + milestones: []Milestone{ + { + id: 1, + title: "title", + desc: "desc", + amount: 100, + paid: 0, + duration: duration, + link: "link", + funded: false, + priority: MS_PRIORITY_HIGH, + status: MS_OPEN, + }, + }, + pausedBy: "pausedBy", + conflictHandler: "conflictHandler", + handlerCandidate: "handlerCandidate", + handlerSuggestor: "handlerSuggestor", + createdAt: createdAt, + budget: 1000, + funded: false, + rejectReason: "rejectReason", + conflicts: []Conflict{ + { + initiator: "initiator", + createdAt: createdAt, + respondedAt: nil, + resolvedAt: nil, + initiatorMessage: "initiatorMessage", + responseMessage: nil, + resolutionMessage: nil, + outcome: nil, + }, + }, + } + + output, err := json.Marshal(contract.ToJSON()) + if err != nil { + t.Fatalf("Error marshalling contract to JSON: %s", err) + } + + expected := `{"id":"1","sender":"sender","contractor":"contractor2","contractorCandidates":["contractor1","contractor2"],"funder":"funder","paymentDenom":"denom","metadata":"metadata","status":"CREATED","expireAt":"2021-08-31T00:00:00Z","funderFeedback":"funderFeedback","contractorFeedback":"contractorFeedback","milestones":[{"id":"1","title":"title","desc":"desc","amount":"100","paid":"0","duration":2592000,"link":"link","funded":false,"priority":"MS_PRIORITY_HIGH","status":"MS_OPEN"}],"pausedBy":"pausedBy","conflictHandler":"conflictHandler","handlerCandidate":"handlerCandidate","handlerSuggestor":"handlerSuggestor","createdAt":"2021-08-01T00:00:00Z","budget":"1000","funded":false,"rejectReason":"rejectReason","conflicts":[{"initiator":"initiator","createdAt":"2021-08-01T00:00:00Z","initiatorMessage":"initiatorMessage"}]}` + if string(output) != expected { + t.Errorf("Expected output to be `%s`, got:\n`%s`", expected, string(output)) + } +} diff --git a/gno.land/pkg/gnoweb/static/css/app.css b/gno.land/pkg/gnoweb/static/css/app.css index 9cb56fde4da..f890a662950 100644 --- a/gno.land/pkg/gnoweb/static/css/app.css +++ b/gno.land/pkg/gnoweb/static/css/app.css @@ -844,4 +844,4 @@ code.hljs { .hljs-strong { font-weight: bold; -} +} \ No newline at end of file diff --git a/gno.land/pkg/sdk/vm/convert.go b/gno.land/pkg/sdk/vm/convert.go index f70f99403a8..a8b6148f82c 100644 --- a/gno.land/pkg/sdk/vm/convert.go +++ b/gno.land/pkg/sdk/vm/convert.go @@ -184,7 +184,7 @@ func convertArgToGno(arg string, argT gno.Type) (tv gno.TypedValue) { } return } else { - panic("unexpected slice type in contract arg") + panic(fmt.Sprintf("unexpected slice type in contract arg %q", arg)) } default: panic(fmt.Sprintf("unexpected type in contract arg: %v", argT))