From 3b2e1a471a7fba50afe26c089156ff12b43a53bb Mon Sep 17 00:00:00 2001 From: Omar Sy Date: Thu, 13 Jun 2024 18:54:22 +0100 Subject: [PATCH 01/10] feat: add social_feed --- gno/r/social_feeds/CMD.md | 67 ++++ gno/r/social_feeds/Makefile | 148 ++++++++ gno/r/social_feeds/binutils_extra.gno | 12 + gno/r/social_feeds/feed.gno | 259 +++++++++++++ gno/r/social_feeds/feeds.gno | 22 ++ gno/r/social_feeds/feeds_test.gno | 516 ++++++++++++++++++++++++++ gno/r/social_feeds/flags.gno | 30 ++ gno/r/social_feeds/gno.mod | 12 + gno/r/social_feeds/messages.gno | 85 +++++ gno/r/social_feeds/misc.gno | 75 ++++ gno/r/social_feeds/post.gno | 193 ++++++++++ gno/r/social_feeds/public.gno | 313 ++++++++++++++++ gno/r/social_feeds/render.gno | 60 +++ 13 files changed, 1792 insertions(+) create mode 100644 gno/r/social_feeds/CMD.md create mode 100644 gno/r/social_feeds/Makefile create mode 100644 gno/r/social_feeds/binutils_extra.gno create mode 100644 gno/r/social_feeds/feed.gno create mode 100644 gno/r/social_feeds/feeds.gno create mode 100644 gno/r/social_feeds/feeds_test.gno create mode 100644 gno/r/social_feeds/flags.gno create mode 100644 gno/r/social_feeds/gno.mod create mode 100644 gno/r/social_feeds/messages.gno create mode 100644 gno/r/social_feeds/misc.gno create mode 100644 gno/r/social_feeds/post.gno create mode 100644 gno/r/social_feeds/public.gno create mode 100644 gno/r/social_feeds/render.gno diff --git a/gno/r/social_feeds/CMD.md b/gno/r/social_feeds/CMD.md new file mode 100644 index 0000000000..e47839e999 --- /dev/null +++ b/gno/r/social_feeds/CMD.md @@ -0,0 +1,67 @@ +gnokey maketx call \ + -pkgpath "gno.land/r/demo/social_feeds" \ + -func "CreateFeed" \ + -gas-fee 1000000ugnot \ + -gas-wanted 3000000 \ + -send "" \ + -broadcast \ + -args "teritori" \ + test1 + +gnokey maketx call \ + -pkgpath "gno.land/r/demo/social_feeds" \ + -func "CreatePost" \ + -gas-fee 1000000ugnot \ + -gas-wanted 2000000 \ + -send "" \ + -broadcast \ + -args "1" \ + -args "0" \ + -args "2" \ + -args '{"gifs": [], "files": [], "title": "", "message": "Hello world 2 !", "hashtags": [], "mentions": [], "createdAt": "2023-08-03T01:39:45.522Z", "updatedAt": "2023-08-03T01:39:45.522Z"}' \ + test1 + +gnokey maketx call \ + -pkgpath "gno.land/r/demo/social_feeds" \ + -func "TipPost" \ + -gas-fee 1000000ugnot \ + -gas-wanted 3000000 \ + -send "1000000ugnot" \ + -broadcast \ + -args "1" \ + -args "1" \ + test1 + +gnokey maketx call \ + -pkgpath "gno.land/r/demo/social_feeds" \ + -func "HidePostForMe" \ + -gas-fee 1000000ugnot \ + -gas-wanted 3000000 \ + -send "" \ + -broadcast \ + -args "1" \ + -args "1" \ + test1 + +// Query posts +gnokey query vm/qeval --data 'gno.land/r/demo/social_feeds +GetPosts(1, "", []uint64{}, 0, 10)' + +gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -pkgdir="." \ + -pkgpath="gno.land/r/demo/social_feeds_v2" \ + test1 + +gnokey maketx call \ + -pkgpath "gno.land/r/demo/social_feeds" \ + -func "MigrateFromPreviousFeed" \ + -gas-fee 1000000ugnot \ + -gas-wanted 2000000 \ + -send "" \ + -broadcast \ + test1 + diff --git a/gno/r/social_feeds/Makefile b/gno/r/social_feeds/Makefile new file mode 100644 index 0000000000..b62f6368ef --- /dev/null +++ b/gno/r/social_feeds/Makefile @@ -0,0 +1,148 @@ +KEY = test1 +GNOKEY = gnokey maketx call \ + -pkgpath "gno.land/r/demo/social_feeds" \ + -gas-fee 1000000ugnot \ + -gas-wanted 3000000 \ + -send "" \ + -broadcast + +ADDPKG = gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -chainid="teritori-1" \ + -remote="https://testnet.gno.teritori.com:26657" \ + -broadcast="true" \ + +.PHONY: create_feed +create_feed: + ${GNOKEY} \ + -func "CreateFeed" \ + -args "teritori" \ + ${KEY} + +.PHONY: create_post +create_post: + ${GNOKEY} \ + -func "CreatePost" \ + -args "1" \ + -args "0" \ + -args "2" \ + -args '{"gifs": [], "files": [], "title": "", "message": "Hello world 2 !", "hashtags": [], "mentions": [], "createdAt": "2023-08-03T01:39:45.522Z", "updatedAt": "2023-08-03T01:39:45.522Z"}' \ + ${KEY} + +.PHONY: tip_post +tip_post: + ${GNOKEY} \ + -send "1000000ugnot" \ + -func "TipPost" \ + -args "1" \ + -args "1" \ + ${KEY} + + +.PHONY: flag_post +flag_post: + ${GNOKEY} \ + -func "FlagPost" \ + -args "1" \ + -args "1" \ + ${KEY} + +.PHONY: get_post +get_post: + gnokey query vm/qeval --data 'gno.land/r/demo/social_feeds\nGetPosts(1, "", []uint64{}, 0, 10)' + +.PHONY: propose_ban_post +propose_ban_post: + ${GNOKEY} \ + -pkgpath "gno.land/r/demo/social_feeds_dao" \ + -func "Propose" \ + -args "0" \ + -args "Ban Post" \ + -args "" \ + -args "" \ + ${KEY} + +.PHONY: vote_yes +vote_yes: + ${GNOKEY} \ + -pkgpath "gno.land/r/demo/social_feeds_dao" \ + -func "Vote" \ + -args "0" \ + -args "0" \ + -args "0" \ + -args "This is not good" \ + ${KEY} + +.PHONY: execute_proposal +execute_proposal: + ${GNOKEY} \ + -pkgpath "gno.land/r/demo/social_feeds_dao" \ + -func "Execute" \ + -args "0" \ + -args "0" \ + ${KEY} + +.PHONY: add_member +add_member: + ${GNOKEY} \ + -pkgpath "gno.land/r/demo/groups_v9" \ + -func "AddMember" \ + -args "0000000001" \ + -args "g1kcdd3n0d472g2p5l8svyg9t0wq6h5857nq992f" \ + -args "1" \ + -args "" \ + ${KEY} + +.PHONY: add_pkg_social_feeds +add_pkg_social_feeds: + ${ADDPKG} \ + -pkgdir="." \ + -pkgpath="gno.land/r/demo/social_feeds_v7" \ + ${KEY} + +.PHONY: add_pkg_social_feeds_dao +add_pkg_social_feeds_dao: + ${ADDPKG} \ + -pkgdir="../social_feeds_dao" \ + -pkgpath="gno.land/r/demo/social_feeds_dao" \ + ${KEY} + +.PHONY: init +init: create_feed create_post create_post tip_post flag_post + +.PHONY: upgrade_pkg +upgrade_pkg: + gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="7000000" \ + -chainid="teritori-1" \ + -remote="https://testnet.gno.teritori.com:26657" \ + -broadcast="true" \ + -pkgdir="." \ + -pkgpath="gno.land/r/demo/social_feeds_dao" \ + test1 + + gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -chainid="teritori-1" \ + -remote="https://testnet.gno.teritori.com:26657" \ + -broadcast="true" \ + -pkgdir="." \ + -pkgpath="gno.land/p/demo/ujson" \ + test1 + + gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -chainid="teritori-1" \ + -remote="https://testnet.gno.teritori.com:26657" \ + -broadcast="true" \ + -pkgdir="." \ + -pkgpath="gno.land/p/demo/daodao/core_v6" \ + test1 diff --git a/gno/r/social_feeds/binutils_extra.gno b/gno/r/social_feeds/binutils_extra.gno new file mode 100644 index 0000000000..623ab932c1 --- /dev/null +++ b/gno/r/social_feeds/binutils_extra.gno @@ -0,0 +1,12 @@ +package social_feeds + +import ( + "encoding/binary" +) + +func EncodeLengthPrefixedStringUint32BE(s string) []byte { + b := make([]byte, 4+len(s)) + binary.BigEndian.PutUint32(b, uint32(len(s))) + copy(b[4:], s) + return b +} diff --git a/gno/r/social_feeds/feed.gno b/gno/r/social_feeds/feed.gno new file mode 100644 index 0000000000..a3f3c3d196 --- /dev/null +++ b/gno/r/social_feeds/feed.gno @@ -0,0 +1,259 @@ +package social_feeds + +import ( + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/teritori/flags_index" + ujson "gno.land/p/demo/teritori/ujson" + "gno.land/p/demo/ufmt" +) + +type FeedID uint64 + +func (fid FeedID) String() string { + return strconv.Itoa(int(fid)) +} + +func (fid *FeedID) FromJSON(ast *ujson.JSONASTNode) { + val, err := strconv.Atoi(ast.Value) + if err != nil { + panic(err) + } + *fid = FeedID(val) +} + +func (fid FeedID) ToJSON() string { + return strconv.Itoa(int(fid)) +} + +type Feed struct { + id FeedID + url string + name string + creator std.Address + owner std.Address + posts avl.Tree // pidkey -> *Post + createdAt int64 + + flags *flags_index.FlagsIndex + hiddenPostsByUser avl.Tree // std.Address => *avl.Tree (postID => bool) + + postsCtr uint64 +} + +func newFeed(fid FeedID, url string, name string, creator std.Address) *Feed { + if !reName.MatchString(name) { + panic("invalid feed name: " + name) + } + + if gFeedsByName.Has(name) { + panic("feed already exists: " + name) + } + + return &Feed{ + id: fid, + url: url, + name: name, + creator: creator, + owner: creator, + posts: avl.Tree{}, + createdAt: time.Now().Unix(), + flags: flags_index.NewFlagsIndex(), + postsCtr: 0, + } +} + +func (feed *Feed) incGetPostID() PostID { + feed.postsCtr++ + return PostID(feed.postsCtr) +} + +func (feed *Feed) GetPost(pid PostID) *Post { + pidkey := postIDKey(pid) + post_, exists := feed.posts.Get(pidkey) + if !exists { + return nil + } + return post_.(*Post) +} + +func (feed *Feed) MustGetPost(pid PostID) *Post { + post := feed.GetPost(pid) + if post == nil { + panic("post does not exist") + } + return post +} + +func (feed *Feed) AddPost(creator std.Address, parentID PostID, category uint64, metadata string) *Post { + pid := feed.incGetPostID() + pidkey := postIDKey(pid) + + post := newPost(feed, pid, creator, parentID, category, metadata) + feed.posts.Set(pidkey, post) + + // If post is a comment then increase the comment count on parent + if uint64(parentID) != 0 { + parent := feed.MustGetPost(parentID) + parent.commentsCount += 1 + } + + return post +} + +func (feed *Feed) FlagPost(flagBy std.Address, pid PostID) { + flagID := getFlagID(feed.id, pid) + + if feed.flags.HasFlagged(flagID, flagBy.String()) { + panic("already flagged") + } + + feed.flags.Flag(flagID, flagBy.String()) +} + +func (feed *Feed) BanPost(pid PostID) { + pidkey := postIDKey(pid) + _, removed := feed.posts.Remove(pidkey) + if !removed { + panic("post does not exist with id " + pid.String()) + } +} + +func (feed *Feed) HidePostForUser(caller std.Address, pid PostID) { + userAddr := caller.String() + + value, exists := feed.hiddenPostsByUser.Get(userAddr) + var hiddenPosts *avl.Tree + if exists { + hiddenPosts = value.(*avl.Tree) + } else { + hiddenPosts = avl.NewTree() + feed.hiddenPostsByUser.Set(userAddr, hiddenPosts) + } + + if hiddenPosts.Has(pid.String()) { + panic("PostID is already hidden: " + pid.String()) + } + + hiddenPosts.Set(pid.String(), true) +} + +func (feed *Feed) UnHidePostForUser(userAddress std.Address, pid PostID) { + value, exists := feed.hiddenPostsByUser.Get(userAddress.String()) + var hiddenPosts *avl.Tree + if exists { + hiddenPosts = value.(*avl.Tree) + _, removed := hiddenPosts.Remove(pid.String()) + if !removed { + panic("Post is not hidden: " + pid.String()) + } + } else { + panic("User has not hidden post: " + pid.String()) + } +} + +func (feed *Feed) Render() string { + pkgpath := std.CurrentRealm().PkgPath() + + str := "" + str += ufmt.Sprintf("Feed: %s (ID: %s) - Owner: %s", feed.name, feed.id, feed.owner) + str += "\n\n There are " + intToString(feed.posts.Size()) + " post(s) \n\n" + + if feed.posts.Size() > 0 { + feed.posts.Iterate("", "", func(key string, value interface{}) bool { + if str != "" { + str += "\n" + } + + post := value.(*Post) + postUrl := strings.Replace(pkgpath, "gno.land", "", -1) + ":" + feed.name + "/" + post.id.String() + + str += " * [" + + "PostID: " + post.id.String() + + " - " + intToString(post.reactions.Size()) + " reactions " + + " - " + ufmt.Sprintf("%d", post.tipAmount) + " tip amount" + + "]" + + "(" + postUrl + ")" + + "\n" + return false + }) + + str += "-------------------------\n" + str += feed.flags.Dump() + } + + str += "---------------------------------------\n" + if feed.hiddenPostsByUser.Size() > 0 { + str += "Hidden posts by users:\n\n" + + feed.hiddenPostsByUser.Iterate("", "", func(userAddr string, value interface{}) bool { + hiddenPosts := value.(*avl.Tree) + str += "\nUser address: " + userAddr + "\n" + + hiddenPosts.Iterate("", "", func(pid string, value interface{}) bool { + str += "- PostID: " + pid + "\n" + return false + }) + + return false + }) + } + + return str +} + +func (feed *Feed) ToJSON() string { + posts := []ujson.FormatKV{} + feed.posts.Iterate("", "", func(key string, value interface{}) bool { + posts = append(posts, ujson.FormatKV{ + Key: key, + Value: value.(*Post), + }) + return false + }) + feedJSON := ujson.FormatObject([]ujson.FormatKV{ + {Key: "id", Value: uint64(feed.id)}, + {Key: "url", Value: feed.url}, + {Key: "name", Value: feed.name}, + {Key: "creator", Value: feed.creator}, + {Key: "owner", Value: feed.owner}, + {Key: "posts", Value: ujson.FormatObject(posts), Raw: true}, + {Key: "createdAt", Value: feed.createdAt}, + {Key: "postsCtr", Value: feed.postsCtr}, + // TODO: convert flags, hiddenPostsByUser + // {Key: "flags", Value: feed.flags}, + // {Key: "hiddenPostsByUser", Value: feed.hiddenPostsByUser}, + }) + return feedJSON +} + +func (feed *Feed) FromJSON(jsonData string) { + ast := ujson.TokenizeAndParse(jsonData) + ast.ParseObject([]*ujson.ParseKV{ + {Key: "id", CustomParser: func(node *ujson.JSONASTNode) { + fid, _ := strconv.Atoi(node.Value) + feed.id = FeedID(fid) + }}, + {Key: "url", Value: &feed.url}, + {Key: "name", Value: &feed.name}, + {Key: "creator", Value: &feed.creator}, + {Key: "owner", Value: &feed.owner}, + {Key: "posts", CustomParser: func(node *ujson.JSONASTNode) { + posts := avl.NewTree() + for _, child := range node.ObjectChildren { + postNode := child.Value + + post := Post{} + post.FromJSON(postNode.String()) + posts.Set(child.Key, &post) + } + feed.posts = *posts + }}, + {Key: "createdAt", Value: &feed.createdAt}, + {Key: "postsCtr", Value: &feed.postsCtr}, + }) +} diff --git a/gno/r/social_feeds/feeds.gno b/gno/r/social_feeds/feeds.gno new file mode 100644 index 0000000000..ae0f4ed8ce --- /dev/null +++ b/gno/r/social_feeds/feeds.gno @@ -0,0 +1,22 @@ +package social_feeds + +import ( + "regexp" + + "gno.land/p/demo/avl" +) + +//---------------------------------------- +// Realm (package) state + +var ( + gFeeds avl.Tree // id -> *Feed + gFeedsCtr int // increments Feed.id + gFeedsByName avl.Tree // name -> *Feed + gDefaultAnonFee = 100000000 // minimum fee required if anonymous +) + +//---------------------------------------- +// Constants + +var reName = regexp.MustCompile(`^[a-z]+[_a-z0-9]{2,29}$`) diff --git a/gno/r/social_feeds/feeds_test.gno b/gno/r/social_feeds/feeds_test.gno new file mode 100644 index 0000000000..3c76f878c1 --- /dev/null +++ b/gno/r/social_feeds/feeds_test.gno @@ -0,0 +1,516 @@ +package social_feeds + +import ( + "encoding/base64" + "fmt" + "std" + "strconv" + "strings" + "testing" + + "gno.land/p/demo/avl" + ujson "gno.land/p/demo/teritori/ujson" + "gno.land/p/demo/testutils" + "gno.land/r/demo/boards" + // Fake previous version for testing + feedsV7 "gno.land/r/demo/teritori/social_feeds" + "gno.land/r/demo/users" +) + +var ( + rootPostID = PostID(0) + postID1 = PostID(1) + feedID1 = FeedID(1) + cat1 = uint64(1) + cat2 = uint64(2) + user = testutils.TestAddress("user") + filter_all = []uint64{} +) + +func getFeed1() *Feed { + return mustGetFeed(feedID1) +} + +func getPost1() *Post { + feed1 := getFeed1() + post1 := feed1.MustGetPost(postID1) + return post1 +} + +func testCreateFeed(t *testing.T) { + feedID := CreateFeed("teritori1") + feed := mustGetFeed(feedID) + + if feedID != 1 { + t.Fatalf("expected feedID: 1, got %q.", feedID) + } + + if feed.name != "teritori1" { + t.Fatalf("expected feedName: teritori1, got %q.", feed.name) + } +} + +func testCreatePost(t *testing.T) { + metadata := `{"gifs": [], "files": [], "title": "", "message": "testouille", "hashtags": [], "mentions": [], "createdAt": "2023-03-29T12:19:04.858Z", "updatedAt": "2023-03-29T12:19:04.858Z"}` + postID := CreatePost(feedID1, rootPostID, cat1, metadata) + feed := mustGetFeed(feedID1) + post := feed.MustGetPost(postID) + + if postID != 1 { + t.Fatalf("expected postID: 1, got %q.", postID) + } + + if post.category != cat1 { + t.Fatalf("expected categoryID: %q, got %q.", cat1, post.category) + } +} + +func toPostIDsStr(posts []*Post) string { + var postIDs []string + for _, post := range posts { + postIDs = append(postIDs, post.id.String()) + } + + postIDsStr := strings.Join(postIDs, ",") + return postIDsStr +} + +func testGetPosts(t *testing.T) { + user := std.Address("user") + std.TestSetOrigCaller(user) + + feedID := CreateFeed("teritori10") + feed := mustGetFeed(feedID) + + CreatePost(feedID, rootPostID, cat1, "post1") + CreatePost(feedID, rootPostID, cat1, "post2") + CreatePost(feedID, rootPostID, cat1, "post3") + CreatePost(feedID, rootPostID, cat1, "post4") + CreatePost(feedID, rootPostID, cat1, "post5") + postIDToFlagged := CreatePost(feedID, rootPostID, cat1, "post6") + postIDToHide := CreatePost(feedID, rootPostID, cat1, "post7") + CreatePost(feedID, rootPostID, cat1, "post8") + + var posts []*Post + var postIDsStr string + + // Query last 3 posts + posts = getPosts(feed, 0, "", "", []uint64{}, 0, 3) + postIDsStr = toPostIDsStr(posts) + + if postIDsStr != "8,7,6" { + t.Fatalf("expected posts order: 8,7,6. Got: %s", postIDsStr) + } + + // Query page 2 + posts = getPosts(feed, 0, "", "", []uint64{}, 3, 3) + postIDsStr = toPostIDsStr(posts) + if postIDsStr != "5,4,3" { + t.Fatalf("expected posts order: 5,4,3. Got: %s", postIDsStr) + } + + // Exclude hidden post + HidePostForMe(feed.id, postIDToHide) + + posts = getPosts(feed, 0, user.String(), "", []uint64{}, 0, 3) + postIDsStr = toPostIDsStr(posts) + + if postIDsStr != "8,6,5" { + t.Fatalf("expected posts order: 8,6,5. Got: %s", postIDsStr) + } + + // Exclude flagged post + FlagPost(feed.id, postIDToFlagged) + + posts = getPosts(feed, 0, user.String(), "", []uint64{}, 0, 3) + postIDsStr = toPostIDsStr(posts) + + if postIDsStr != "8,5,4" { + t.Fatalf("expected posts order: 8,5,4. Got: %s", postIDsStr) + } + + // Pagination with hidden/flagged posts + posts = getPosts(feed, 0, user.String(), "", []uint64{}, 3, 3) + postIDsStr = toPostIDsStr(posts) + + if postIDsStr != "3,2,1" { + t.Fatalf("expected posts order: 3,2,1. Got: %s", postIDsStr) + } + + // Query out of range + posts = getPosts(feed, 0, user.String(), "", []uint64{}, 6, 3) + postIDsStr = toPostIDsStr(posts) + + if postIDsStr != "" { + t.Fatalf("expected posts order: ''. Got: %s", postIDsStr) + } +} + +func testReactPost(t *testing.T) { + feed := getFeed1() + post := getPost1() + + icon := "🥰" + ReactPost(feed.id, post.id, icon, true) + + // Set reaction + reactionCount_, ok := post.reactions.Get("🥰") + if !ok { + t.Fatalf("expected 🥰 exists") + } + + reactionCount := reactionCount_.(int) + if reactionCount != 1 { + t.Fatalf("expected reactionCount: 1, got %q.", reactionCount) + } + + // Unset reaction + ReactPost(feed.id, post.id, icon, false) + _, exist := post.reactions.Get("🥰") + if exist { + t.Fatalf("expected 🥰 not exist") + } +} + +func testCreateAndDeleteComment(t *testing.T) { + feed1 := getFeed1() + post1 := getPost1() + + metadata := `empty_meta_data` + + commentID1 := CreatePost(feed1.id, post1.id, cat1, metadata) + commentID2 := CreatePost(feed1.id, post1.id, cat1, metadata) + comment2 := feed1.MustGetPost(commentID2) + + if comment2.id != 3 { // 1 post + 2 comments = 3 + t.Fatalf("expected comment postID: 3, got %q.", comment2.id) + } + + if comment2.parentID != post1.id { + t.Fatalf("expected comment parentID: %q, got %q.", post1.id, comment2.parentID) + } + + // Check comment count on parent + if post1.commentsCount != 2 { + t.Fatalf("expected comments count: 2, got %d.", post1.commentsCount) + } + + // Get comments + comments := GetComments(feed1.id, post1.id, 0, 10) + commentsParsed := ujson.ParseSlice(comments) + + if len(commentsParsed) != 2 { + t.Fatalf("expected encoded comments: 2, got %q.", commentsParsed) + } + + // Delete 1 comment + DeletePost(feed1.id, comment2.id) + comments = GetComments(feed1.id, post1.id, 0, 10) + commentsParsed = ujson.ParseSlice(comments) + + if len(commentsParsed) != 1 { + t.Fatalf("expected encoded comments: 1, got %q.", commentsParsed) + } + + // Check comment count on parent + if post1.commentsCount != 1 { + t.Fatalf("expected comments count: 1, got %d.", post1.commentsCount) + } +} + +func countPosts(feedID FeedID, categories []uint64, limit uint8) int { + offset := uint64(0) + + postsStr := GetPosts(feedID, 0, "", categories, offset, limit) + if postsStr == "[]" { + return 0 + } + + parsedPosts := ujson.ParseSlice(postsStr) + postsCount := len(parsedPosts) + return postsCount +} + +func countPostsByUser(feedID FeedID, user string) int { + offset := uint64(0) + limit := uint8(10) + + postsStr := GetPosts(feedID, 0, user, []uint64{}, offset, limit) + if postsStr == "[]" { + return 0 + } + + parsedPosts := ujson.ParseSlice(postsStr) + postsCount := len(parsedPosts) + return postsCount +} + +func testFilterByCategories(t *testing.T) { + // // Re-add reaction to test post list + // ReactPost(1, postID, "🥰", true) + // ReactPost(1, postID, "😇", true) + + filter_cat1 := []uint64{1} + filter_cat1_2 := []uint64{1, 2} + filter_cat9 := []uint64{9} + filter_cat1_2_9 := []uint64{1, 2, 9} + + feedID2 := CreateFeed("teritori2") + feed2 := mustGetFeed(feedID2) + + // Create 2 posts on root with cat1 + postID1 := CreatePost(feed2.id, rootPostID, cat1, "metadata") + postID2 := CreatePost(feed2.id, rootPostID, cat1, "metadata") + + // Create 1 posts on root with cat2 + postID3 := CreatePost(feed2.id, rootPostID, cat2, "metadata") + + // Create comments on post 1 + commentPostID1 := CreatePost(feed2.id, postID1, cat1, "metadata") + + // cat1: Should return max = limit + if count := countPosts(feed2.id, filter_cat1, 1); count != 1 { + t.Fatalf("expected posts count: 1, got %q.", count) + } + + // cat1: Should return max = total + if count := countPosts(feed2.id, filter_cat1, 10); count != 2 { + t.Fatalf("expected posts count: 2, got %q.", count) + } + + // cat 1 + 2: Should return max = limit + if count := countPosts(feed2.id, filter_cat1_2, 2); count != 2 { + t.Fatalf("expected posts count: 2, got %q.", count) + } + + // cat 1 + 2: Should return max = total on both + if count := countPosts(feed2.id, filter_cat1_2, 10); count != 3 { + t.Fatalf("expected posts count: 3, got %q.", count) + } + + // cat 1, 2, 9: Should return total of 1, 2 + if count := countPosts(feed2.id, filter_cat1_2_9, 10); count != 3 { + t.Fatalf("expected posts count: 3, got %q.", count) + } + + // cat 9: Should return 0 + if count := countPosts(feed2.id, filter_cat9, 10); count != 0 { + t.Fatalf("expected posts count: 0, got %q.", count) + } + + // cat all: should return all + if count := countPosts(feed2.id, filter_all, 10); count != 3 { + t.Fatalf("expected posts count: 3, got %q.", count) + } + + // add comments should not impact the results + CreatePost(feed2.id, postID1, cat1, "metadata") + CreatePost(feed2.id, postID2, cat1, "metadata") + + if count := countPosts(feed2.id, filter_all, 10); count != 3 { + t.Fatalf("expected posts count: 3, got %q.", count) + } + + // delete a post should affect the result + DeletePost(feed2.id, postID1) + + if count := countPosts(feed2.id, filter_all, 10); count != 2 { + t.Fatalf("expected posts count: 2, got %q.", count) + } +} + +func testTipPost(t *testing.T) { + creator := testutils.TestAddress("creator") + std.TestIssueCoins(creator, std.Coins{{"ugnot", 100_000_000}}) + + // NOTE: Dont know why the address should be this to be able to call banker (= std.GetCallerAt(1)) + tipper := testutils.TestAddress("tipper") + std.TestIssueCoins(tipper, std.Coins{{"ugnot", 50_000_000}}) + + banker := std.GetBanker(std.BankerTypeReadonly) + + // Check Original coins of creator/tipper + if coins := banker.GetCoins(creator); coins[0].Amount != 100_000_000 { + t.Fatalf("expected creator coin count: 100_000_000, got %d.", coins[0].Amount) + } + + if coins := banker.GetCoins(tipper); coins[0].Amount != 50_000_000 { + t.Fatalf("expected tipper coin count: 50_000_000, got %d.", coins[0].Amount) + } + + // Creator creates feed, post + std.TestSetOrigCaller(creator) + + feedID3 := CreateFeed("teritori3") + feed3 := mustGetFeed(feedID3) + + postID1 := CreatePost(feed3.id, rootPostID, cat1, "metadata") + post1 := feed3.MustGetPost(postID1) + + // Tiper tips the ppst + std.TestSetOrigCaller(tipper) + std.TestSetOrigSend(std.Coins{{"ugnot", 1_000_000}}, nil) + TipPost(feed3.id, post1.id) + + // Coin must be increased for creator + if coins := banker.GetCoins(creator); coins[0].Amount != 101_000_000 { + t.Fatalf("expected creator coin after beging tipped: 101_000_000, got %d.", coins[0].Amount) + } + + // Total tip amount should increased + if post1.tipAmount != 1_000_000 { + t.Fatalf("expected total tipAmount: 1_000_000, got %d.", post1.tipAmount) + } + + // Add more tip should update this total + std.TestSetOrigSend(std.Coins{{"ugnot", 2_000_000}}, nil) + TipPost(feed3.id, post1.id) + + if post1.tipAmount != 3_000_000 { + t.Fatalf("expected total tipAmount: 3_000_000, got %d.", post1.tipAmount) + } +} + +func testFlagPost(t *testing.T) { + flagger := testutils.TestAddress("flagger") + + feedID9 := CreateFeed("teritori9") + feed9 := mustGetFeed(feedID9) + + CreatePost(feed9.id, rootPostID, cat1, "metadata1") + pid := CreatePost(feed9.id, rootPostID, cat1, "metadata1") + + // Flag post + std.TestSetOrigCaller(flagger) + FlagPost(feed9.id, pid) + + // Another user flags + another := testutils.TestAddress("another") + std.TestSetOrigCaller(another) + FlagPost(feed9.id, pid) + + flaggedPostsStr := GetFlaggedPosts(feed9.id, 0, 10) + parsed := ujson.ParseSlice(flaggedPostsStr) + if flaggedPostsCount := len(parsed); flaggedPostsCount != 1 { + t.Fatalf("expected flagged posts: 1, got %d.", flaggedPostsCount) + } +} + +func testFilterUser(t *testing.T) { + user1 := testutils.TestAddress("user1") + user2 := testutils.TestAddress("user2") + + // User1 create 2 posts + std.TestSetOrigCaller(user1) + + feedID4 := CreateFeed("teritori4") + feed4 := mustGetFeed(feedID4) + + CreatePost(feed4.id, rootPostID, cat1, `{"metadata": "value"}`) + CreatePost(feed4.id, rootPostID, cat1, `{"metadata2": "value"}`) + + // User2 create 1 post + std.TestSetOrigCaller(user2) + CreatePost(feed4.id, rootPostID, cat1, `{"metadata": "value"}`) + + if count := countPostsByUser(feed4.id, user1.String()); count != 2 { + t.Fatalf("expected total posts by user1: 2, got %d.", count) + } + + if count := countPostsByUser(feed4.id, user2.String()); count != 1 { + t.Fatalf("expected total posts by user2: 1, got %d.", count) + } + + if count := countPostsByUser(feed4.id, ""); count != 3 { + t.Fatalf("expected total posts: 3, got %d.", count) + } +} + +func testHidePostForMe(t *testing.T) { + user := std.Address("user") + std.TestSetOrigCaller(user) + + feedID8 := CreateFeed("teritor8") + feed8 := mustGetFeed(feedID8) + + postIDToHide := CreatePost(feed8.id, rootPostID, cat1, `{"metadata": "value"}`) + postID := CreatePost(feed8.id, rootPostID, cat1, `{"metadata": "value"}`) + + if count := countPosts(feed8.id, filter_all, 10); count != 2 { + t.Fatalf("expected posts count: 2, got %q.", count) + } + + // Hide a post for me + HidePostForMe(feed8.id, postIDToHide) + + if count := countPosts(feed8.id, filter_all, 10); count != 1 { + t.Fatalf("expected posts count after hidding: 1, got %q.", count) + } + + // Query from another user should return full list + another := std.Address("another") + std.TestSetOrigCaller(another) + + if count := countPosts(feed8.id, filter_all, 10); count != 2 { + t.Fatalf("expected posts count from another: 2, got %q.", count) + } + + // UnHide a post for me + std.TestSetOrigCaller(user) + UnHidePostForMe(feed8.id, postIDToHide) + + if count := countPosts(feed8.id, filter_all, 10); count != 2 { + t.Fatalf("expected posts count after unhidding: 2, got %q.", count) + } +} + +func testMigrateFeedData(t *testing.T) { + feedID := feedsV7.CreateFeed("teritor11") + + // Post to test + postID := feedsV7.CreatePost(feedID, feedsV7.PostID(0), 2, `{"metadata": "value"}`) + feedsV7.ReactPost(feedID, postID, "🇬🇸", true) + + // Add comment to post + commentID := feedsV7.CreatePost(feedID, postID, 2, `{"comment1": "value"}`) + feedsV7.ReactPost(feedID, commentID, "🇬🇸", true) + + // // Post with json metadata + feedsV7.CreatePost(feedID, feedsV7.PostID(0), 2, `{'a':1}`) + + // Expect: should convert feed data to JSON successfully without error + dataJSON := feedsV7.ExportFeedData(feedID) + if dataJSON == "" { + t.Fatalf("expected feed data exported successfully") + } + + // Import data ===================================== + ImportFeedData(FeedID(uint64(feedID)), dataJSON) + + // Test public func + // MigrateFromPreviousFeed(feedID) +} + +func Test(t *testing.T) { + testCreateFeed(t) + + testCreatePost(t) + + testGetPosts(t) + + testReactPost(t) + + testCreateAndDeleteComment(t) + + testFilterByCategories(t) + + testTipPost(t) + + testFilterUser(t) + + testFlagPost(t) + + testHidePostForMe(t) + + testMigrateFeedData(t) +} diff --git a/gno/r/social_feeds/flags.gno b/gno/r/social_feeds/flags.gno new file mode 100644 index 0000000000..26018d15f3 --- /dev/null +++ b/gno/r/social_feeds/flags.gno @@ -0,0 +1,30 @@ +package social_feeds + +import ( + "strconv" + "strings" + + "gno.land/p/demo/teritori/flags_index" +) + +var SEPARATOR = "/" + +func getFlagID(fid FeedID, pid PostID) flags_index.FlagID { + return flags_index.FlagID(fid.String() + SEPARATOR + pid.String()) +} + +func parseFlagID(flagID flags_index.FlagID) (FeedID, PostID) { + parts := strings.Split(string(flagID), SEPARATOR) + if len(parts) != 2 { + panic("invalid flag ID '" + string(flagID) + "'") + } + fid, err := strconv.Atoi(parts[0]) + if err != nil || fid == 0 { + panic("invalid feed ID in flag ID '" + parts[0] + "'") + } + pid, err := strconv.Atoi(parts[1]) + if err != nil || pid == 0 { + panic("invalid post ID in flag ID '" + parts[1] + "'") + } + return FeedID(fid), PostID(pid) +} diff --git a/gno/r/social_feeds/gno.mod b/gno/r/social_feeds/gno.mod new file mode 100644 index 0000000000..45e9eb93f4 --- /dev/null +++ b/gno/r/social_feeds/gno.mod @@ -0,0 +1,12 @@ +module gno.land/r/demo/teritori/social_feeds + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/teritori/dao_interfaces v0.0.0-latest + gno.land/p/demo/teritori/flags_index v0.0.0-latest + gno.land/p/demo/teritori/ujson v0.0.0-latest + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest + gno.land/r/demo/boards v0.0.0-latest + gno.land/r/demo/users v0.0.0-latest +) diff --git a/gno/r/social_feeds/messages.gno b/gno/r/social_feeds/messages.gno new file mode 100644 index 0000000000..edc3399520 --- /dev/null +++ b/gno/r/social_feeds/messages.gno @@ -0,0 +1,85 @@ +package social_feeds + +import ( + "strings" + + "gno.land/p/demo/teritori/dao_interfaces" + "gno.land/p/demo/teritori/ujson" +) + +var PKG_PATH = "gno.land/r/demo/teritori/social_feeds" + +// Ban a post +type ExecutableMessageBanPost struct { + dao_interfaces.ExecutableMessage + + FeedID FeedID + PostID PostID + Reason string +} + +func (msg ExecutableMessageBanPost) Type() string { + return "gno.land/r/demo/teritori/social_feeds.BanPost" +} + +func (msg *ExecutableMessageBanPost) ToJSON() string { + return ujson.FormatObject([]ujson.FormatKV{ + {Key: "feedId", Value: msg.FeedID}, + {Key: "postId", Value: msg.PostID}, + {Key: "reason", Value: msg.Reason}, + }) +} + +func (msg *ExecutableMessageBanPost) String() string { + var ss []string + ss = append(ss, msg.Type()) + + feed := getFeed(msg.FeedID) + s := "" + + if feed != nil { + s += "Feed: " + feed.name + " (" + feed.id.String() + ")" + + post := feed.GetPost(msg.PostID) + if post != nil { + s += "\n Post: " + post.id.String() + } else { + s += "\n Post: " + msg.PostID.String() + " (not found)" + } + } else { + s += "Feed: " + msg.FeedID.String() + " (not found)" + } + + s += "\nReason: " + msg.Reason + + ss = append(ss, s) + + return strings.Join(ss, "\n---\n") +} + +type BanPostHandler struct { + dao_interfaces.MessageHandler +} + +func NewBanPostHandler() *BanPostHandler { + return &BanPostHandler{} +} + +func (h *BanPostHandler) Execute(iMsg dao_interfaces.ExecutableMessage) { + msg := iMsg.(*ExecutableMessageBanPost) + BanPost(msg.FeedID, msg.PostID, msg.Reason) +} + +func (h BanPostHandler) Type() string { + return ExecutableMessageBanPost{}.Type() +} + +func (h *BanPostHandler) MessageFromJSON(ast *ujson.JSONASTNode) dao_interfaces.ExecutableMessage { + msg := &ExecutableMessageBanPost{} + ast.ParseObject([]*ujson.ParseKV{ + {Key: "feedId", Value: &msg.FeedID}, + {Key: "postId", Value: &msg.PostID}, + {Key: "reason", Value: &msg.Reason}, + }) + return msg +} diff --git a/gno/r/social_feeds/misc.gno b/gno/r/social_feeds/misc.gno new file mode 100644 index 0000000000..00d7ec8811 --- /dev/null +++ b/gno/r/social_feeds/misc.gno @@ -0,0 +1,75 @@ +package social_feeds + +import ( + "encoding/base64" + "std" + "strconv" + "strings" + + "gno.land/r/demo/users" +) + +func getFeed(fid FeedID) *Feed { + fidkey := feedIDKey(fid) + feed_, exists := gFeeds.Get(fidkey) + if !exists { + return nil + } + feed := feed_.(*Feed) + return feed +} + +func mustGetFeed(fid FeedID) *Feed { + feed := getFeed(fid) + if feed == nil { + panic("Feed does not exist") + } + return feed +} + +func incGetFeedID() FeedID { + gFeedsCtr++ + return FeedID(gFeedsCtr) +} + +func usernameOf(addr std.Address) string { + user := users.GetUserByAddress(addr) + if user == nil { + return "" + } else { + return user.Name + } +} + +func feedIDKey(fid FeedID) string { + return padZero(uint64(fid), 10) +} + +func postIDKey(pid PostID) string { + return padZero(uint64(pid), 10) +} + +func padLeft(str string, length int) string { + if len(str) >= length { + return str + } else { + return strings.Repeat(" ", length-len(str)) + str + } +} + +func padZero(u64 uint64, length int) string { + str := strconv.Itoa(int(u64)) + if len(str) >= length { + return str + } else { + return strings.Repeat("0", length-len(str)) + str + } +} + +func bytesToString(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +func intToString(val int) string { + return strconv.Itoa(val) +} diff --git a/gno/r/social_feeds/post.gno b/gno/r/social_feeds/post.gno new file mode 100644 index 0000000000..3130af2c01 --- /dev/null +++ b/gno/r/social_feeds/post.gno @@ -0,0 +1,193 @@ +package social_feeds + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/avl" + ujson "gno.land/p/demo/teritori/ujson" +) + +type PostID uint64 + +func (pid PostID) String() string { + return strconv.Itoa(int(pid)) +} + +func (pid *PostID) FromJSON(ast *ujson.JSONASTNode) { + val, err := strconv.Atoi(ast.Value) + if err != nil { + panic(err) + } + *pid = PostID(val) +} + +func (pid PostID) ToJSON() string { + return strconv.Itoa(int(pid)) +} + +type Reaction struct { + icon string + count uint64 +} + +var Categories []string = []string{ + "Reaction", + "Comment", + "Normal", + "Article", + "Picture", + "Audio", + "Video", +} + +type Post struct { + id PostID + parentID PostID + feedID FeedID + category uint64 + metadata string + reactions avl.Tree // icon -> count + comments avl.Tree // Post.id -> *Post + creator std.Address + tipAmount uint64 + deleted bool + commentsCount uint64 + + createdAt int64 + updatedAt int64 + deletedAt int64 +} + +func newPost(feed *Feed, id PostID, creator std.Address, parentID PostID, category uint64, metadata string) *Post { + return &Post{ + id: id, + parentID: parentID, + feedID: feed.id, + category: category, + metadata: metadata, + reactions: avl.Tree{}, + creator: creator, + createdAt: time.Now().Unix(), + } +} + +func (post *Post) String() string { + return post.ToJSON() +} + +func (post *Post) Update(category uint64, metadata string) { + post.category = category + post.metadata = metadata + post.updatedAt = time.Now().Unix() +} + +func (post *Post) Delete() { + post.deleted = true + post.deletedAt = time.Now().Unix() +} + +func (post *Post) Tip(from std.Address, to std.Address) { + receivedCoins := std.GetOrigSend() + amount := receivedCoins[0].Amount + + banker := std.GetBanker(std.BankerTypeOrigSend) + // banker := std.GetBanker(std.BankerTypeRealmSend) + coinsToSend := std.Coins{std.Coin{Denom: "ugnot", Amount: amount}} + pkgaddr := std.GetOrigPkgAddr() + + banker.SendCoins(pkgaddr, to, coinsToSend) + + // Update tip amount + post.tipAmount += uint64(amount) +} + +// Always remove reaction if count = 0 +func (post *Post) React(icon string, up bool) { + count_, ok := post.reactions.Get(icon) + count := 0 + + if ok { + count = count_.(int) + } + + if up { + count++ + } else { + count-- + } + + if count <= 0 { + post.reactions.Remove(icon) + } else { + post.reactions.Set(icon, count) + } +} + +func (post *Post) Render() string { + return post.metadata +} + +func (post *Post) FromJSON(jsonData string) { + ast := ujson.TokenizeAndParse(jsonData) + ast.ParseObject([]*ujson.ParseKV{ + {Key: "id", CustomParser: func(node *ujson.JSONASTNode) { + pid, _ := strconv.Atoi(node.Value) + post.id = PostID(pid) + }}, + {Key: "parentID", CustomParser: func(node *ujson.JSONASTNode) { + pid, _ := strconv.Atoi(node.Value) + post.parentID = PostID(pid) + }}, + {Key: "feedID", CustomParser: func(node *ujson.JSONASTNode) { + fid, _ := strconv.Atoi(node.Value) + post.feedID = FeedID(fid) + }}, + {Key: "category", Value: &post.category}, + {Key: "metadata", Value: &post.metadata}, + {Key: "reactions", CustomParser: func(node *ujson.JSONASTNode) { + reactions := avl.NewTree() + for _, child := range node.ObjectChildren { + reactionCount := child.Value + reactions.Set(child.Key, reactionCount) + } + post.reactions = *reactions + }}, + {Key: "commentsCount", Value: &post.commentsCount}, + {Key: "creator", Value: &post.creator}, + {Key: "tipAmount", Value: &post.tipAmount}, + {Key: "deleted", Value: &post.deleted}, + {Key: "createdAt", Value: &post.createdAt}, + {Key: "updatedAt", Value: &post.updatedAt}, + {Key: "deletedAt", Value: &post.deletedAt}, + }) +} + +func (post *Post) ToJSON() string { + reactionsKV := []ujson.FormatKV{} + post.reactions.Iterate("", "", func(key string, value interface{}) bool { + count := value.(int) + data := ujson.FormatKV{Key: key, Value: count} + reactionsKV = append(reactionsKV, data) + return false + }) + reactions := ujson.FormatObject(reactionsKV) + + postJSON := ujson.FormatObject([]ujson.FormatKV{ + {Key: "id", Value: uint64(post.id)}, + {Key: "parentID", Value: uint64(post.parentID)}, + {Key: "feedID", Value: uint64(post.feedID)}, + {Key: "category", Value: post.category}, + {Key: "metadata", Value: post.metadata}, + {Key: "reactions", Value: reactions, Raw: true}, + {Key: "creator", Value: post.creator}, + {Key: "tipAmount", Value: post.tipAmount}, + {Key: "deleted", Value: post.deleted}, + {Key: "commentsCount", Value: post.commentsCount}, + {Key: "createdAt", Value: post.createdAt}, + {Key: "updatedAt", Value: post.updatedAt}, + {Key: "deletedAt", Value: post.deletedAt}, + }) + return postJSON +} diff --git a/gno/r/social_feeds/public.gno b/gno/r/social_feeds/public.gno new file mode 100644 index 0000000000..cdac08c573 --- /dev/null +++ b/gno/r/social_feeds/public.gno @@ -0,0 +1,313 @@ +package social_feeds + +import ( + "std" + "strconv" + "strings" + + "gno.land/p/demo/avl" + "gno.land/p/demo/teritori/flags_index" + "gno.land/p/demo/ufmt" +) + +// Only registered user can create a new feed +// For the flexibility when testing, allow all user to create feed +func CreateFeed(name string) FeedID { + pkgpath := std.CurrentRealm().PkgPath() + + fid := incGetFeedID() + caller := std.PrevRealm().Addr() + url := strings.Replace(pkgpath, "gno.land", "", -1) + ":" + name + feed := newFeed(fid, url, name, caller) + fidkey := feedIDKey(fid) + gFeeds.Set(fidkey, feed) + gFeedsByName.Set(name, feed) + return feed.id +} + +// Anyone can create a post in a existing feed, allow un-registered users also +func CreatePost(fid FeedID, parentID PostID, catetory uint64, metadata string) PostID { + caller := std.PrevRealm().Addr() + + feed := mustGetFeed(fid) + post := feed.AddPost(caller, parentID, catetory, metadata) + return post.id +} + +// Only post's owner can edit post +func EditPost(fid FeedID, pid PostID, category uint64, metadata string) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + post := feed.MustGetPost(pid) + + if caller != post.creator { + panic("you are not creator of this post") + } + + post.Update(category, metadata) +} + +// Only feed creator/owner can call this +func SetOwner(fid FeedID, newOwner std.Address) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + + if caller != feed.creator && caller != feed.owner { + panic("you are not creator/owner of this feed") + } + + feed.owner = newOwner +} + +// Only feed creator/owner or post creator can delete the post +func DeletePost(fid FeedID, pid PostID) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + post := feed.MustGetPost(pid) + + if caller != post.creator && caller != feed.creator && caller != feed.owner { + panic("you are nor creator of this post neither creator/owner of the feed") + } + + post.Delete() + + // If post is comment then decrease comments count on parent + if uint64(post.parentID) != 0 { + parent := feed.MustGetPost(post.parentID) + parent.commentsCount -= 1 + } +} + +// Only feed owner can ban the post +func BanPost(fid FeedID, pid PostID, reason string) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + _ = feed.MustGetPost(pid) + + // For experimenting, we ban only the post for now + // TODO: recursive delete/ban comments + if caller != feed.owner { + panic("you are owner of the feed") + } + + feed.BanPost(pid) + + feed.flags.ClearFlagCount(getFlagID(fid, pid)) +} + +// Any one can react post +func ReactPost(fid FeedID, pid PostID, icon string, up bool) { + feed := mustGetFeed(fid) + post := feed.MustGetPost(pid) + + post.React(icon, up) +} + +func TipPost(fid FeedID, pid PostID) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + post := feed.MustGetPost(pid) + + post.Tip(caller, post.creator) +} + +// Get a list of flagged posts +// NOTE: We can support multi feeds in the future but for now we will have only 1 feed +// Return stringified list in format: postStr-count,postStr-count +func GetFlaggedPosts(fid FeedID, offset uint64, limit uint8) string { + feed := mustGetFeed(fid) + + // Already sorted by count descending + flags := feed.flags.GetFlags(uint64(limit), offset) + + var postList []string + for _, flagCount := range flags { + flagID := flagCount.FlagID + + feedID, postID := parseFlagID(flagID) + if feedID != feed.id { + continue + } + + post := feed.GetPost(postID) + postList = append(postList, ufmt.Sprintf("%s", post)) + } + + SEPARATOR := "," + res := strings.Join(postList, SEPARATOR) + return ufmt.Sprintf("[%s]", res) +} + +// NOTE: due to bug of std.PrevRealm().Addr() return "" when query so we user this proxy function temporary +// in waiting of correct behaviour of std.PrevRealm().Addr() +func GetPosts(fid FeedID, parentID PostID, user string, categories []uint64, offset uint64, limit uint8) string { + caller := std.PrevRealm().Addr() + data := GetPostsWithCaller(fid, parentID, caller.String(), user, categories, offset, limit) + return data +} + +func GetPostsWithCaller(fid FeedID, parentID PostID, callerAddrStr string, user string, categories []uint64, offset uint64, limit uint8) string { + // Return flagged posts, we process flagged posts differently using FlagIndex + if len(categories) == 1 && categories[0] == uint64(9) { + return GetFlaggedPosts(fid, offset, limit) + } + + // BUG: normally std.PrevRealm().Addr() should return a value instead of empty + // Fix is in progress on Gno side + feed := mustGetFeed(fid) + posts := getPosts(feed, parentID, callerAddrStr, user, categories, offset, limit) + + SEPARATOR := "," + var postListStr []string + + for _, post := range posts { + postListStr = append(postListStr, post.String()) + } + + res := strings.Join(postListStr, SEPARATOR) + return ufmt.Sprintf("[%s]", res) +} + +// user here is: filter by user +func getPosts(feed *Feed, parentID PostID, callerAddrStr string, user string, categories []uint64, offset uint64, limit uint8) []*Post { + caller := std.Address(callerAddrStr) + + var posts []*Post + var skipped uint64 + + // Create an avlTree for optimizing the check + requestedCategories := avl.NewTree() + for _, category := range categories { + catStr := strconv.FormatUint(category, 10) + requestedCategories.Set(catStr, true) + } + + feed.posts.ReverseIterate("", "", func(key string, value interface{}) bool { + post := value.(*Post) + + postCatStr := strconv.FormatUint(post.category, 10) + + // NOTE: this search mechanism is not efficient, only for demo purpose + if post.parentID == parentID && post.deleted == false { + if requestedCategories.Size() > 0 && !requestedCategories.Has(postCatStr) { + return false + } + + if user != "" && std.Address(user) != post.creator { + return false + } + + // Filter hidden post + flagID := getFlagID(feed.id, post.id) + if feed.flags.HasFlagged(flagID, callerAddrStr) { + return false + } + + // Check if post is in hidden list + value, exists := feed.hiddenPostsByUser.Get(caller.String()) + if exists { + hiddenPosts := value.(*avl.Tree) + // If post.id exists in hiddenPosts tree => that post is hidden + if hiddenPosts.Has(post.id.String()) { + return false + } + } + + if skipped < offset { + skipped++ + return false + } + + posts = append(posts, post) + } + + if len(posts) == int(limit) { + return true + } + + return false + }) + + return posts +} + +// Get comments list +func GetComments(fid FeedID, parentID PostID, offset uint64, limit uint8) string { + return GetPosts(fid, parentID, "", []uint64{}, offset, limit) +} + +// Get Post +func GetPost(fid FeedID, pid PostID) string { + feed := mustGetFeed(fid) + + data, ok := feed.posts.Get(postIDKey(pid)) + if !ok { + panic("Unable to get post") + } + + post := data.(*Post) + return post.String() +} + +func FlagPost(fid FeedID, pid PostID) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + + feed.FlagPost(caller, pid) +} + +func HidePostForMe(fid FeedID, pid PostID) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + + feed.HidePostForUser(caller, pid) +} + +func UnHidePostForMe(fid FeedID, pid PostID) { + caller := std.PrevRealm().Addr() + feed := mustGetFeed(fid) + + feed.UnHidePostForUser(caller, pid) +} + +func GetFlags(fid FeedID, limit uint64, offset uint64) string { + feed := mustGetFeed(fid) + + type FlagCount struct { + FlagID flags_index.FlagID + Count uint64 + } + + flags := feed.flags.GetFlags(limit, offset) + + var res []string + for _, flag := range flags { + res = append(res, ufmt.Sprintf("%s:%d", flag.FlagID, flag.Count)) + } + + return strings.Join(res, "|") +} + +// TODO: allow only creator to call +func GetFeedByID(fid FeedID) *Feed { + return mustGetFeed(fid) +} + +// TODO: allow only admin to call +func ExportFeedData(fid FeedID) string { + feed := mustGetFeed(fid) + feedJSON := feed.ToJSON() + return feedJSON +} + +// TODO: allow only admin to call +func ImportFeedData(fid FeedID, jsonData string) { + feed := mustGetFeed(fid) + feed.FromJSON(jsonData) +} + +// func MigrateFromPreviousFeed(fid feedsV7.FeedID) { +// // Get exported data from previous feeds +// jsonData := feedsV7.ExportFeedData(fid) +// ImportFeedData(FeedID(uint64(fid)), jsonData) +// } diff --git a/gno/r/social_feeds/render.gno b/gno/r/social_feeds/render.gno new file mode 100644 index 0000000000..af5dd49a4e --- /dev/null +++ b/gno/r/social_feeds/render.gno @@ -0,0 +1,60 @@ +package social_feeds + +import ( + "strconv" + "strings" +) + +func renderFeed(parts []string) string { + // /r/demo/social_feeds_v4:FEED_NAME + name := parts[0] + feedI, exists := gFeedsByName.Get(name) + if !exists { + return "feed does not exist: " + name + } + return feedI.(*Feed).Render() +} + +func renderPost(parts []string) string { + // /r/demo/boards:FEED_NAME/POST_ID + name := parts[0] + feedI, exists := gFeedsByName.Get(name) + if !exists { + return "feed does not exist: " + name + } + pid, err := strconv.Atoi(parts[1]) + if err != nil { + return "invalid thread id: " + parts[1] + } + feed := feedI.(*Feed) + post := feed.MustGetPost(PostID(pid)) + return post.Render() +} + +func renderFeedsList() string { + str := "There are " + intToString(gFeeds.Size()) + " available feeds:\n\n" + gFeeds.Iterate("", "", func(key string, value interface{}) bool { + feed := value.(*Feed) + str += " * [" + feed.url + " (FeedID: " + feed.id.String() + ")](" + feed.url + ")\n" + return false + }) + return str +} + +func Render(path string) string { + if path == "" { + return renderFeedsList() + } + + parts := strings.Split(path, "/") + + if len(parts) == 1 { + // /r/demo/social_feeds_v4:FEED_NAME + return renderFeed(parts) + } else if len(parts) == 2 { + // /r/demo/social_feeds_v4:FEED_NAME/POST_ID + return renderPost(parts) + } + + return "Not found" +} From dfbfb985b4923bd8a5236dd505be6cba6e419274 Mon Sep 17 00:00:00 2001 From: Omar Sy Date: Sun, 16 Jun 2024 15:25:04 +0100 Subject: [PATCH 02/10] feat: add makefile --- Makefile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f1703a0e27..9f38542d35 100644 --- a/Makefile +++ b/Makefile @@ -416,4 +416,18 @@ generate.internal-contracts-clients: node_modules || exit 1 ;\ npx tsx packages/scripts/makeTypescriptIndex $${outdir} || exit 1 ; \ done - + +all-gno: clean-gno clone-gno bin-gno test-gno + +clone-gno: + cd gnobuild && git clone https://github.com/gnolang/gno.git + +bin-gno: + cd gnobuild/gno/gnovm && make build + +test-gno: + ./gnobuild/gno/gnovm/build/gno lint ./gno/... + +clean-gno: + rm -rf gnobuild + mkdir gnobuild From f4a7556a8880ff567dae010d3ada281093299d41 Mon Sep 17 00:00:00 2001 From: Omar Sy Date: Sun, 16 Jun 2024 22:17:09 +0100 Subject: [PATCH 03/10] feat: delete unused things --- gno/r/social_feeds/CMD.md | 56 +++++++++++++++---------------- gno/r/social_feeds/feeds.gno | 7 ++-- gno/r/social_feeds/feeds_test.gno | 2 -- gno/r/social_feeds/flags.gno | 6 ++-- gno/r/social_feeds/gno.mod | 2 -- gno/r/social_feeds/messages.gno | 2 -- gno/r/social_feeds/misc.gno | 17 ---------- 7 files changed, 33 insertions(+), 59 deletions(-) diff --git a/gno/r/social_feeds/CMD.md b/gno/r/social_feeds/CMD.md index e47839e999..0f2d9d021d 100644 --- a/gno/r/social_feeds/CMD.md +++ b/gno/r/social_feeds/CMD.md @@ -1,39 +1,54 @@ +gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="50000000" \ + -broadcast="true" \ + -remote="https://rpc.gno.land:443" \ + -chainid="portal-loop" \ + -pkgdir="." \ + -pkgpath="gno.land/r/demo/teritori/social_feeds" \ + mykey2 + gnokey maketx call \ - -pkgpath "gno.land/r/demo/social_feeds" \ + -pkgpath "gno.land/r/demo/teritori/social_feeds" \ -func "CreateFeed" \ -gas-fee 1000000ugnot \ -gas-wanted 3000000 \ - -send "" \ + -remote="https://rpc.gno.land:443" \ + -chainid="portal-loop" \ -broadcast \ -args "teritori" \ - test1 + mykey2 gnokey maketx call \ - -pkgpath "gno.land/r/demo/social_feeds" \ + -pkgpath "gno.land/r/demo/teritori/social_feeds" \ -func "CreatePost" \ -gas-fee 1000000ugnot \ -gas-wanted 2000000 \ - -send "" \ + -remote="https://rpc.gno.land:443" \ + -chainid="portal-loop" \ -broadcast \ -args "1" \ -args "0" \ -args "2" \ -args '{"gifs": [], "files": [], "title": "", "message": "Hello world 2 !", "hashtags": [], "mentions": [], "createdAt": "2023-08-03T01:39:45.522Z", "updatedAt": "2023-08-03T01:39:45.522Z"}' \ - test1 + mykey2 gnokey maketx call \ - -pkgpath "gno.land/r/demo/social_feeds" \ + -pkgpath "gno.land/r/demo/teritori/social_feeds" \ -func "TipPost" \ -gas-fee 1000000ugnot \ -gas-wanted 3000000 \ - -send "1000000ugnot" \ + -send "1000ugnot" \ + -remote="https://rpc.gno.land:443" \ + -chainid="portal-loop" \ -broadcast \ -args "1" \ -args "1" \ - test1 + mykey2 gnokey maketx call \ - -pkgpath "gno.land/r/demo/social_feeds" \ + -pkgpath "gno.land/r/demo/teritori/social_feeds" \ -func "HidePostForMe" \ -gas-fee 1000000ugnot \ -gas-wanted 3000000 \ @@ -41,27 +56,10 @@ gnokey maketx call \ -broadcast \ -args "1" \ -args "1" \ - test1 + mykey2 // Query posts -gnokey query vm/qeval --data 'gno.land/r/demo/social_feeds +gnokey query vm/qeval --data 'gno.land/r/demo/teritori/social_feeds GetPosts(1, "", []uint64{}, 0, 10)' -gnokey maketx addpkg \ - -deposit="1ugnot" \ - -gas-fee="1ugnot" \ - -gas-wanted="5000000" \ - -broadcast="true" \ - -pkgdir="." \ - -pkgpath="gno.land/r/demo/social_feeds_v2" \ - test1 - -gnokey maketx call \ - -pkgpath "gno.land/r/demo/social_feeds" \ - -func "MigrateFromPreviousFeed" \ - -gas-fee 1000000ugnot \ - -gas-wanted 2000000 \ - -send "" \ - -broadcast \ - test1 diff --git a/gno/r/social_feeds/feeds.gno b/gno/r/social_feeds/feeds.gno index ae0f4ed8ce..352a9555cd 100644 --- a/gno/r/social_feeds/feeds.gno +++ b/gno/r/social_feeds/feeds.gno @@ -10,10 +10,9 @@ import ( // Realm (package) state var ( - gFeeds avl.Tree // id -> *Feed - gFeedsCtr int // increments Feed.id - gFeedsByName avl.Tree // name -> *Feed - gDefaultAnonFee = 100000000 // minimum fee required if anonymous + gFeeds avl.Tree // id -> *Feed + gFeedsCtr int // increments Feed.id + gFeedsByName avl.Tree // name -> *Feed ) //---------------------------------------- diff --git a/gno/r/social_feeds/feeds_test.gno b/gno/r/social_feeds/feeds_test.gno index 3c76f878c1..8a6b1b338c 100644 --- a/gno/r/social_feeds/feeds_test.gno +++ b/gno/r/social_feeds/feeds_test.gno @@ -11,10 +11,8 @@ import ( "gno.land/p/demo/avl" ujson "gno.land/p/demo/teritori/ujson" "gno.land/p/demo/testutils" - "gno.land/r/demo/boards" // Fake previous version for testing feedsV7 "gno.land/r/demo/teritori/social_feeds" - "gno.land/r/demo/users" ) var ( diff --git a/gno/r/social_feeds/flags.gno b/gno/r/social_feeds/flags.gno index 26018d15f3..4bd0e6cc47 100644 --- a/gno/r/social_feeds/flags.gno +++ b/gno/r/social_feeds/flags.gno @@ -7,14 +7,14 @@ import ( "gno.land/p/demo/teritori/flags_index" ) -var SEPARATOR = "/" +var seperator = "/" func getFlagID(fid FeedID, pid PostID) flags_index.FlagID { - return flags_index.FlagID(fid.String() + SEPARATOR + pid.String()) + return flags_index.FlagID(fid.String() + seperator + pid.String()) } func parseFlagID(flagID flags_index.FlagID) (FeedID, PostID) { - parts := strings.Split(string(flagID), SEPARATOR) + parts := strings.Split(string(flagID), seperator) if len(parts) != 2 { panic("invalid flag ID '" + string(flagID) + "'") } diff --git a/gno/r/social_feeds/gno.mod b/gno/r/social_feeds/gno.mod index 45e9eb93f4..34b3e573e4 100644 --- a/gno/r/social_feeds/gno.mod +++ b/gno/r/social_feeds/gno.mod @@ -7,6 +7,4 @@ require ( gno.land/p/demo/teritori/ujson v0.0.0-latest gno.land/p/demo/testutils v0.0.0-latest gno.land/p/demo/ufmt v0.0.0-latest - gno.land/r/demo/boards v0.0.0-latest - gno.land/r/demo/users v0.0.0-latest ) diff --git a/gno/r/social_feeds/messages.gno b/gno/r/social_feeds/messages.gno index edc3399520..49dc24dd80 100644 --- a/gno/r/social_feeds/messages.gno +++ b/gno/r/social_feeds/messages.gno @@ -7,8 +7,6 @@ import ( "gno.land/p/demo/teritori/ujson" ) -var PKG_PATH = "gno.land/r/demo/teritori/social_feeds" - // Ban a post type ExecutableMessageBanPost struct { dao_interfaces.ExecutableMessage diff --git a/gno/r/social_feeds/misc.gno b/gno/r/social_feeds/misc.gno index 00d7ec8811..3ec86ea2e7 100644 --- a/gno/r/social_feeds/misc.gno +++ b/gno/r/social_feeds/misc.gno @@ -1,12 +1,8 @@ package social_feeds import ( - "encoding/base64" - "std" "strconv" "strings" - - "gno.land/r/demo/users" ) func getFeed(fid FeedID) *Feed { @@ -32,15 +28,6 @@ func incGetFeedID() FeedID { return FeedID(gFeedsCtr) } -func usernameOf(addr std.Address) string { - user := users.GetUserByAddress(addr) - if user == nil { - return "" - } else { - return user.Name - } -} - func feedIDKey(fid FeedID) string { return padZero(uint64(fid), 10) } @@ -66,10 +53,6 @@ func padZero(u64 uint64, length int) string { } } -func bytesToString(b []byte) string { - return base64.RawURLEncoding.EncodeToString(b) -} - func intToString(val int) string { return strconv.Itoa(val) } From 118d6935e9310f459c25aab1ead793bdad139e83 Mon Sep 17 00:00:00 2001 From: Omar Sy Date: Sat, 29 Jun 2024 17:50:10 +0200 Subject: [PATCH 04/10] featl add gno ci --- .github/workflows/gno-lint.yml | 29 + .github/workflows/gno-test.yml | 30 + .gitignore | 2 + Makefile | 10 +- gno/p/binutils/binutils.gno | 34 + gno/p/binutils/gno.mod | 1 + gno/p/dao_core/dao_core.gno | 183 ++++++ gno/p/dao_core/dao_core_test.gno | 180 ++++++ gno/p/dao_core/errors.gno | 15 + gno/p/dao_core/gno.mod | 7 + gno/p/dao_core/messages.gno | 93 +++ gno/p/dao_interfaces/core.gno | 18 + gno/p/dao_interfaces/core_testing.gno | 35 ++ gno/p/dao_interfaces/gno.mod | 6 + gno/p/dao_interfaces/messages.gno | 21 + gno/p/dao_interfaces/messages_registry.gno | 140 +++++ .../dao_interfaces/messages_registry_test.gno | 52 ++ gno/p/dao_interfaces/messages_testing.gno | 54 ++ gno/p/dao_interfaces/modules.gno | 36 ++ .../dao_proposal_single.gno | 409 ++++++++++++ gno/p/dao_proposal_single/gno.mod | 8 + gno/p/dao_proposal_single/proposal_test.gno | 90 +++ gno/p/dao_proposal_single/threshold.gno | 131 ++++ gno/p/dao_proposal_single/types.gno | 191 ++++++ gno/p/dao_proposal_single/update_settings.gno | 78 +++ gno/p/dao_utils/expiration.gno | 94 +++ gno/p/dao_utils/expiration_test.gno | 15 + gno/p/dao_utils/gno.mod | 3 + gno/p/flags_index/flags_index.gno | 163 +++++ gno/p/flags_index/gno.mod | 3 + gno/p/havl/gno.mod | 3 + gno/p/havl/havl.gno | 128 ++++ gno/p/markdown_utils/gno.mod | 1 + gno/p/markdown_utils/markdown_utils.gno | 28 + gno/p/ujson/format.gno | 146 +++++ gno/p/ujson/gno.mod | 7 + gno/p/ujson/parse.gno | 594 ++++++++++++++++++ gno/p/ujson/strings.gno | 233 +++++++ gno/p/ujson/tables.gno | 216 +++++++ gno/p/ujson/ujson_test.gno | 188 ++++++ gno/p/utf16/gno.mod | 1 + gno/p/utf16/utf16.gno | 108 ++++ gno/r/social_feeds/CMD.md | 2 +- gno/r/social_feeds/feeds_test.gno | 16 +- 44 files changed, 3788 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/gno-lint.yml create mode 100644 .github/workflows/gno-test.yml create mode 100644 gno/p/binutils/binutils.gno create mode 100644 gno/p/binutils/gno.mod create mode 100644 gno/p/dao_core/dao_core.gno create mode 100644 gno/p/dao_core/dao_core_test.gno create mode 100644 gno/p/dao_core/errors.gno create mode 100644 gno/p/dao_core/gno.mod create mode 100644 gno/p/dao_core/messages.gno create mode 100644 gno/p/dao_interfaces/core.gno create mode 100644 gno/p/dao_interfaces/core_testing.gno create mode 100644 gno/p/dao_interfaces/gno.mod create mode 100644 gno/p/dao_interfaces/messages.gno create mode 100644 gno/p/dao_interfaces/messages_registry.gno create mode 100644 gno/p/dao_interfaces/messages_registry_test.gno create mode 100644 gno/p/dao_interfaces/messages_testing.gno create mode 100644 gno/p/dao_interfaces/modules.gno create mode 100644 gno/p/dao_proposal_single/dao_proposal_single.gno create mode 100644 gno/p/dao_proposal_single/gno.mod create mode 100644 gno/p/dao_proposal_single/proposal_test.gno create mode 100644 gno/p/dao_proposal_single/threshold.gno create mode 100644 gno/p/dao_proposal_single/types.gno create mode 100644 gno/p/dao_proposal_single/update_settings.gno create mode 100644 gno/p/dao_utils/expiration.gno create mode 100644 gno/p/dao_utils/expiration_test.gno create mode 100644 gno/p/dao_utils/gno.mod create mode 100644 gno/p/flags_index/flags_index.gno create mode 100644 gno/p/flags_index/gno.mod create mode 100644 gno/p/havl/gno.mod create mode 100644 gno/p/havl/havl.gno create mode 100644 gno/p/markdown_utils/gno.mod create mode 100644 gno/p/markdown_utils/markdown_utils.gno create mode 100644 gno/p/ujson/format.gno create mode 100644 gno/p/ujson/gno.mod create mode 100644 gno/p/ujson/parse.gno create mode 100644 gno/p/ujson/strings.gno create mode 100644 gno/p/ujson/tables.gno create mode 100644 gno/p/ujson/ujson_test.gno create mode 100644 gno/p/utf16/gno.mod create mode 100644 gno/p/utf16/utf16.gno diff --git a/.github/workflows/gno-lint.yml b/.github/workflows/gno-lint.yml new file mode 100644 index 0000000000..608ddcbd2b --- /dev/null +++ b/.github/workflows/gno-lint.yml @@ -0,0 +1,29 @@ +name: Gno Lint + +on: + push: + branches: + - main + pull_request: + merge_group: + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + with: + go-version: "1.22" + - name: Clean gno + run: make clean-gno + + - name: Clone gno + run: make clone-gno + + - name: Build GnoVM + run: make build-gno + + - name: Lint gno + run: make lint-gno diff --git a/.github/workflows/gno-test.yml b/.github/workflows/gno-test.yml new file mode 100644 index 0000000000..f3451f4c2f --- /dev/null +++ b/.github/workflows/gno-test.yml @@ -0,0 +1,30 @@ +name: Gno Test + +on: + push: + branches: + - main + pull_request: + merge_group: + +jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v3 + with: + go-version: "1.22" + + - name: Clean gno + run: make clean-gno + + - name: Clone gno + run: make clone-gno + + - name: Build GnoVM + run: make build-gno + + - name: Test gno + run: make test-gno diff --git a/.gitignore b/.gitignore index 954cff38aa..5bf854f099 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ web-build/ /android /app-build/ +gnobuild/ + # macOS .DS_Store diff --git a/Makefile b/Makefile index 9f38542d35..52702f77da 100644 --- a/Makefile +++ b/Makefile @@ -417,16 +417,18 @@ generate.internal-contracts-clients: node_modules npx tsx packages/scripts/makeTypescriptIndex $${outdir} || exit 1 ; \ done -all-gno: clean-gno clone-gno bin-gno test-gno - clone-gno: cd gnobuild && git clone https://github.com/gnolang/gno.git + cp -r ./gno/p ./gnobuild/gno/examples/gno.land/p/demo/teritori -bin-gno: +build-gno: cd gnobuild/gno/gnovm && make build +lint-gno: + ./gnobuild/gno/gnovm/build/gno lint ./gno/. -v + test-gno: - ./gnobuild/gno/gnovm/build/gno lint ./gno/... + ./gnobuild/gno/gnovm/build/gno test ./gno/... -v clean-gno: rm -rf gnobuild diff --git a/gno/p/binutils/binutils.gno b/gno/p/binutils/binutils.gno new file mode 100644 index 0000000000..bc76dd3d3b --- /dev/null +++ b/gno/p/binutils/binutils.gno @@ -0,0 +1,34 @@ +package binutils + +import ( + "encoding/binary" + "errors" +) + +var ErrInvalidLengthPrefixedString = errors.New("invalid length-prefixed string") + +func EncodeLengthPrefixedStringUint16BE(s string) []byte { + b := make([]byte, 2+len(s)) + binary.BigEndian.PutUint16(b, uint16(len(s))) + copy(b[2:], s) + return b +} + +func DecodeLengthPrefixedStringUint16BE(b []byte) (string, []byte, error) { + if len(b) < 2 { + return "", nil, ErrInvalidLengthPrefixedString + } + l := binary.BigEndian.Uint16(b) + if len(b) < 2+int(l) { + return "", nil, ErrInvalidLengthPrefixedString + } + return string(b[2 : 2+l]), b[l+2:], nil +} + +func MustDecodeLengthPrefixedStringUint16BE(b []byte) (string, []byte) { + s, r, err := DecodeLengthPrefixedStringUint16BE(b) + if err != nil { + panic(err) + } + return s, r +} diff --git a/gno/p/binutils/gno.mod b/gno/p/binutils/gno.mod new file mode 100644 index 0000000000..5ebff7eba6 --- /dev/null +++ b/gno/p/binutils/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/teritori/binutils \ No newline at end of file diff --git a/gno/p/dao_core/dao_core.gno b/gno/p/dao_core/dao_core.gno new file mode 100644 index 0000000000..2466b773f1 --- /dev/null +++ b/gno/p/dao_core/dao_core.gno @@ -0,0 +1,183 @@ +package core + +import ( + "std" + + dao_interfaces "gno.land/p/demo/teritori/dao_interfaces" + "gno.land/p/demo/teritori/markdown_utils" +) + +// TODO: add wrapper message handler to handle multiple proposal modules messages + +type daoCore struct { + dao_interfaces.IDAOCore + + votingModule dao_interfaces.IVotingModule + proposalModules []dao_interfaces.ActivableProposalModule + activeProposalModuleCount int + realm std.Realm + registry *dao_interfaces.MessagesRegistry +} + +func NewDAOCore( + votingModuleFactory dao_interfaces.VotingModuleFactory, + proposalModulesFactories []dao_interfaces.ProposalModuleFactory, + messageHandlersFactories []dao_interfaces.MessageHandlerFactory, +) dao_interfaces.IDAOCore { + if votingModuleFactory == nil { + panic("Missing voting module factory") + } + + if len(proposalModulesFactories) == 0 { + panic("No proposal modules factories") + } + + core := &daoCore{ + realm: std.CurrentRealm(), + activeProposalModuleCount: len(proposalModulesFactories), + registry: dao_interfaces.NewMessagesRegistry(), + proposalModules: make([]dao_interfaces.ActivableProposalModule, len(proposalModulesFactories)), + } + + core.votingModule = votingModuleFactory(core) + if core.votingModule == nil { + panic("voting module factory returned nil") + } + + for i, modFactory := range proposalModulesFactories { + mod := modFactory(core) + if mod == nil { + panic("proposal module factory returned nil") + } + core.proposalModules[i] = dao_interfaces.ActivableProposalModule{ + Enabled: true, + Module: mod, + } + } + + // this registry is specific to gno since we can't do dynamic calls + core.registry.Register(NewUpdateVotingModuleMessageHandler(core)) + core.registry.Register(NewUpdateProposalModulesMessageHandler(core)) + for _, handlerFactory := range messageHandlersFactories { + handler := handlerFactory(core) + if handler == nil { + panic("message handler factory returned nil") + } + core.registry.Register(handler) + } + + return core +} + +// mutations + +func (d *daoCore) UpdateVotingModule(newVotingModule dao_interfaces.IVotingModule) { + if std.CurrentRealm().Addr() != d.realm.Addr() { // not sure this check necessary since the ownership system should protect against mutation from other realms + panic(ErrUnauthorized) + } + + // FIXME: check da0-da0 implem + d.votingModule = newVotingModule +} + +func (d *daoCore) UpdateProposalModules(toAdd []dao_interfaces.IProposalModule, toDisable []int) { + if std.CurrentRealm().Addr() != d.realm.Addr() { // not sure this check necessary since the ownership system should protect against mutation from other realms + panic(ErrUnauthorized) + } + + for _, module := range toAdd { + d.addProposalModule(module) + } + + for _, moduleIndex := range toDisable { + module := GetProposalModule(d, moduleIndex) + + if !module.Enabled { + panic(ErrModuleAlreadyDisabled) + } + module.Enabled = false + + d.activeProposalModuleCount-- + if d.activeProposalModuleCount == 0 { + panic("no active proposal modules") // this -> `panic(ErrNoActiveProposalModules)` triggers `panic: reflect: reflect.Value.SetString using value obtained using unexported field` + } + } +} + +// queries + +func (d *daoCore) ProposalModules() []dao_interfaces.ActivableProposalModule { + return d.proposalModules +} + +func (d *daoCore) VotingModule() dao_interfaces.IVotingModule { + return d.votingModule +} + +func (d *daoCore) VotingPowerAtHeight(address std.Address, height int64) uint64 { + return d.VotingModule().VotingPowerAtHeight(address, height) +} + +func (d *daoCore) ActiveProposalModuleCount() int { + return d.activeProposalModuleCount +} + +func (d *daoCore) Render(path string) string { + s := "# DAO Core\n" + s += "This is an attempt at porting [DA0-DA0 contracts](https://github.com/DA0-DA0/dao-contracts)\n" + s += markdown_utils.Indent(d.votingModule.Render(path)) + "\n" + for _, propMod := range d.proposalModules { + if !propMod.Enabled { + continue + } + s += markdown_utils.Indent(propMod.Module.Render(path)) + "\n" + } + return s +} + +func (d *daoCore) Registry() *dao_interfaces.MessagesRegistry { + return d.registry +} + +// TODO: move this helper in dao interfaces + +func GetProposalModule(core dao_interfaces.IDAOCore, moduleIndex int) *dao_interfaces.ActivableProposalModule { + if moduleIndex < 0 { + panic("module index must be >= 0") + } + mods := core.ProposalModules() + if moduleIndex >= len(mods) { + panic("invalid module index") + } + return &mods[moduleIndex] +} + +// internal + +func (d *daoCore) executeMsgs(msgs []dao_interfaces.ExecutableMessage) { + for _, msg := range msgs { + d.registry.Execute(msg) + } +} + +func (d *daoCore) addProposalModule(proposalMod dao_interfaces.IProposalModule) { + for _, mod := range d.proposalModules { + if mod.Module != proposalMod { + continue + } + + if mod.Enabled { + panic(ErrModuleAlreadyAdded) + } + mod.Enabled = true + d.activeProposalModuleCount++ + return + } + + d.proposalModules = append(d.proposalModules, dao_interfaces.ActivableProposalModule{ + Enabled: true, + Module: proposalMod, + }) + + d.activeProposalModuleCount++ +} diff --git a/gno/p/dao_core/dao_core_test.gno b/gno/p/dao_core/dao_core_test.gno new file mode 100644 index 0000000000..576ffc94ec --- /dev/null +++ b/gno/p/dao_core/dao_core_test.gno @@ -0,0 +1,180 @@ +package core + +import ( + "std" + "testing" + + dao_interfaces "gno.land/p/demo/teritori/dao_interfaces" +) + +type votingModule struct { + core dao_interfaces.IDAOCore +} + +func votingModuleFactory(core dao_interfaces.IDAOCore) dao_interfaces.IVotingModule { + return &votingModule{core: core} +} + +func (vm *votingModule) Core() dao_interfaces.IDAOCore { + return vm.core +} + +func (vm *votingModule) Info() dao_interfaces.ModuleInfo { + return dao_interfaces.ModuleInfo{ + Kind: "TestVoting", + Version: "21.42", + } +} + +func (vm *votingModule) ConfigJSON() string { + return "{}" +} + +func (vm *votingModule) Render(path string) string { + return "# Test Voting Module" +} + +func (vm *votingModule) VotingPowerAtHeight(address std.Address, height int64) uint64 { + return 0 +} + +func (vm *votingModule) TotalPowerAtHeight(height int64) uint64 { + return 0 +} + +type proposalModule struct { + core dao_interfaces.IDAOCore +} + +func proposalModuleFactory(core dao_interfaces.IDAOCore) dao_interfaces.IProposalModule { + return &proposalModule{core: core} +} + +func (pm *proposalModule) Core() dao_interfaces.IDAOCore { + return pm.core +} + +func (pm *proposalModule) Info() dao_interfaces.ModuleInfo { + return dao_interfaces.ModuleInfo{ + Kind: "TestProposal", + Version: "42.21", + } +} + +func (pm *proposalModule) ConfigJSON() string { + return "{}" +} + +func (pm *proposalModule) VoteJSON(proposalID int, voteJSON string) { + panic("not implemented") +} + +func (pm *proposalModule) Render(path string) string { + return "# Test Proposal Module" +} + +func (pm *proposalModule) Execute(proposalId int) { + panic("not implemented") +} + +func (pm *proposalModule) ProposeJSON(proposalJSON string) int { + panic("not implemented") +} + +func (pm *proposalModule) ProposalsJSON(limit int, startAfter string, reverse bool) string { + panic("not implemented") +} + +func (pm *proposalModule) ProposalJSON(proposalID int) string { + panic("not implemented") +} + +func TestDAOCore(t *testing.T) { + var testValue string + handler := dao_interfaces.NewCopyMessageHandler(&testValue) + handlerFactory := func(core dao_interfaces.IDAOCore) dao_interfaces.MessageHandler { + return handler + } + + core := NewDAOCore(votingModuleFactory, []dao_interfaces.ProposalModuleFactory{proposalModuleFactory}, []dao_interfaces.MessageHandlerFactory{handlerFactory}) + if core == nil { + t.Fatal("core is nil") + } + if core.ActiveProposalModuleCount() != 1 { + t.Fatal("expected 1 active proposal module") + } + + votingMod := core.VotingModule() + if votingMod == nil { + t.Fatal("voting module is nil") + } + if votingMod.Info().Kind != "TestVoting" { + t.Fatal("voting module has wrong kind") + } + + propMods := core.ProposalModules() + if len(propMods) != 1 { + t.Fatal("expected 1 proposal module") + } + + propMod := propMods[0] + if !propMod.Enabled { + t.Fatal("proposal module is not enabled") + } + if propMod.Module == nil { + t.Fatal("proposal module is nil") + } + if propMod.Module.Info().Kind != "TestProposal" { + t.Fatal("proposal module has wrong kind") + } + + registry := core.Registry() + if registry == nil { + t.Fatal("registry is nil") + } + msg := &dao_interfaces.CopyMessage{Value: "test"} + registry.Execute(msg) + if testValue != "test" { + t.Errorf("expected testValue to be 'test', got '%s'", testValue) + } + + newProposalModule := &proposalModule{core: core} + updatePropModsMsg := &UpdateProposalModulesExecutableMessage{ + ToAdd: []dao_interfaces.IProposalModule{newProposalModule}, + ToDisable: []int{0}, + } + registry.Execute(updatePropModsMsg) + + if core.ActiveProposalModuleCount() != 1 { + t.Fatal("expected 1 active proposal module") + } + + propMods = core.ProposalModules() + if len(propMods) != 2 { + t.Fatal("expected 2 proposal modules") + } + + propMod = propMods[0] + if propMod.Enabled { + t.Errorf("old proposal module is still enabled") + } + + propMod = propMods[1] + if !propMod.Enabled { + t.Errorf("new proposal module is not enabled") + } + if propMod.Module != newProposalModule { + t.Errorf("new proposal module is not the same as the one added") + } + + newVotingModule := &votingModule{core: core} + updateVotingModMsg := &UpdateVotingModuleExecutableMessage{ + Module: newVotingModule, + } + registry.Execute(updateVotingModMsg) + + votingMod = core.VotingModule() + if votingMod != newVotingModule { + t.Errorf("new voting module is not the same as the one added") + } +} diff --git a/gno/p/dao_core/errors.gno b/gno/p/dao_core/errors.gno new file mode 100644 index 0000000000..a7299585a0 --- /dev/null +++ b/gno/p/dao_core/errors.gno @@ -0,0 +1,15 @@ +package core + +import ( + "errors" +) + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrModuleDisabledCannotExecute = errors.New("module disabled, cannot execute") + ErrNotImplemented = errors.New("not implemented") + ErrModuleAlreadyDisabled = errors.New("module already disabled") + ErrNoActiveProposalModules = errors.New("no active proposal modules") + ErrModuleAlreadyAdded = errors.New("module already added") + ErrNotSupported = errors.New("not supported") +) diff --git a/gno/p/dao_core/gno.mod b/gno/p/dao_core/gno.mod new file mode 100644 index 0000000000..d3ed739d2c --- /dev/null +++ b/gno/p/dao_core/gno.mod @@ -0,0 +1,7 @@ +module gno.land/p/demo/teritori/dao_core + +require ( + gno.land/p/demo/teritori/dao_interfaces v0.0.0-latest + gno.land/p/demo/teritori/markdown_utils v0.0.0-latest + gno.land/p/demo/teritori/ujson v0.0.0-latest +) diff --git a/gno/p/dao_core/messages.gno b/gno/p/dao_core/messages.gno new file mode 100644 index 0000000000..60493d2766 --- /dev/null +++ b/gno/p/dao_core/messages.gno @@ -0,0 +1,93 @@ +package core + +import ( + dao_interfaces "gno.land/p/demo/teritori/dao_interfaces" + "gno.land/p/demo/teritori/ujson" +) + +// UpdateProposalModules + +type UpdateProposalModulesExecutableMessage struct { + ToAdd []dao_interfaces.IProposalModule + ToDisable []int +} + +func (msg UpdateProposalModulesExecutableMessage) Type() string { + return "gno.land/p/demo/teritori/dao_core.UpdateProposalModules" +} + +func (msg *UpdateProposalModulesExecutableMessage) String() string { + panic(ErrNotImplemented) +} + +func (msg *UpdateProposalModulesExecutableMessage) ToJSON() string { + panic(ErrNotImplemented) +} + +func (msg *UpdateProposalModulesExecutableMessage) FromJSON(ast *ujson.JSONASTNode) { + panic(ErrNotImplemented) +} + +type UpdateProposalModulesMessageHandler struct { + dao dao_interfaces.IDAOCore +} + +func NewUpdateProposalModulesMessageHandler(dao dao_interfaces.IDAOCore) *UpdateProposalModulesMessageHandler { + return &UpdateProposalModulesMessageHandler{dao: dao} +} + +func (handler UpdateProposalModulesMessageHandler) Type() string { + return UpdateProposalModulesExecutableMessage{}.Type() +} + +func (handler *UpdateProposalModulesMessageHandler) Execute(message dao_interfaces.ExecutableMessage) { + msg := message.(*UpdateProposalModulesExecutableMessage) + handler.dao.UpdateProposalModules(msg.ToAdd, msg.ToDisable) +} + +func (handler *UpdateProposalModulesMessageHandler) MessageFromJSON(ast *ujson.JSONASTNode) dao_interfaces.ExecutableMessage { + panic(ErrNotSupported) +} + +// UpdateVotingModule + +type UpdateVotingModuleExecutableMessage struct { + Module dao_interfaces.IVotingModule +} + +func (msg UpdateVotingModuleExecutableMessage) Type() string { + return "gno.land/p/demo/teritori/dao_core.UpdateVotingModule" +} + +func (msg *UpdateVotingModuleExecutableMessage) String() string { + panic(ErrNotImplemented) +} + +func (msg *UpdateVotingModuleExecutableMessage) ToJSON() string { + panic(ErrNotImplemented) +} + +func (msg *UpdateVotingModuleExecutableMessage) FromJSON(ast *ujson.JSONASTNode) { + panic(ErrNotImplemented) +} + +type UpdateVotingModuleMessageHandler struct { + dao dao_interfaces.IDAOCore +} + +func NewUpdateVotingModuleMessageHandler(dao dao_interfaces.IDAOCore) *UpdateVotingModuleMessageHandler { + return &UpdateVotingModuleMessageHandler{dao: dao} +} + +func (handler UpdateVotingModuleMessageHandler) Type() string { + return UpdateVotingModuleExecutableMessage{}.Type() +} + +func (handler *UpdateVotingModuleMessageHandler) Execute(message dao_interfaces.ExecutableMessage) { + msg := message.(*UpdateVotingModuleExecutableMessage) + handler.dao.UpdateVotingModule(msg.Module) +} + +func (handler *UpdateVotingModuleMessageHandler) MessageFromJSON(ast *ujson.JSONASTNode) dao_interfaces.ExecutableMessage { + panic(ErrNotSupported) +} diff --git a/gno/p/dao_interfaces/core.gno b/gno/p/dao_interfaces/core.gno new file mode 100644 index 0000000000..eed2eda002 --- /dev/null +++ b/gno/p/dao_interfaces/core.gno @@ -0,0 +1,18 @@ +package dao_interfaces + +type ActivableProposalModule struct { + Enabled bool + Module IProposalModule +} + +type IDAOCore interface { + Render(path string) string + + VotingModule() IVotingModule + ProposalModules() []ActivableProposalModule + ActiveProposalModuleCount() int + Registry() *MessagesRegistry + + UpdateVotingModule(newVotingModule IVotingModule) + UpdateProposalModules(toAdd []IProposalModule, toDisable []int) +} diff --git a/gno/p/dao_interfaces/core_testing.gno b/gno/p/dao_interfaces/core_testing.gno new file mode 100644 index 0000000000..76e1cec0c7 --- /dev/null +++ b/gno/p/dao_interfaces/core_testing.gno @@ -0,0 +1,35 @@ +package dao_interfaces + +type dummyCore struct{} + +func NewDummyCore() IDAOCore { + return &dummyCore{} +} + +func (d *dummyCore) Render(path string) string { + panic("not implemented") +} + +func (d *dummyCore) VotingModule() IVotingModule { + panic("not implemented") +} + +func (d *dummyCore) ProposalModules() []ActivableProposalModule { + panic("not implemented") +} + +func (d *dummyCore) ActiveProposalModuleCount() int { + panic("not implemented") +} + +func (d *dummyCore) Registry() *MessagesRegistry { + panic("not implemented") +} + +func (d *dummyCore) UpdateVotingModule(newVotingModule IVotingModule) { + panic("not implemented") +} + +func (d *dummyCore) UpdateProposalModules(toAdd []IProposalModule, toDisable []int) { + panic("not implemented") +} diff --git a/gno/p/dao_interfaces/gno.mod b/gno/p/dao_interfaces/gno.mod new file mode 100644 index 0000000000..5d57f81771 --- /dev/null +++ b/gno/p/dao_interfaces/gno.mod @@ -0,0 +1,6 @@ +module gno.land/p/demo/teritori/dao_interfaces + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/teritori/ujson v0.0.0-latest +) diff --git a/gno/p/dao_interfaces/messages.gno b/gno/p/dao_interfaces/messages.gno new file mode 100644 index 0000000000..083156656f --- /dev/null +++ b/gno/p/dao_interfaces/messages.gno @@ -0,0 +1,21 @@ +package dao_interfaces + +import ( + "gno.land/p/demo/teritori/ujson" +) + +type ExecutableMessage interface { + ujson.JSONAble + ujson.FromJSONAble + + String() string + Type() string +} + +type MessageHandler interface { + Execute(message ExecutableMessage) + MessageFromJSON(ast *ujson.JSONASTNode) ExecutableMessage + Type() string +} + +type MessageHandlerFactory func(core IDAOCore) MessageHandler diff --git a/gno/p/dao_interfaces/messages_registry.gno b/gno/p/dao_interfaces/messages_registry.gno new file mode 100644 index 0000000000..8372cb8d8f --- /dev/null +++ b/gno/p/dao_interfaces/messages_registry.gno @@ -0,0 +1,140 @@ +package dao_interfaces + +import ( + "gno.land/p/demo/avl" + "gno.land/p/demo/teritori/ujson" +) + +type MessagesRegistry struct { + handlers *avl.Tree +} + +func NewMessagesRegistry() *MessagesRegistry { + registry := &MessagesRegistry{handlers: avl.NewTree()} + registry.Register(NewRegisterHandlerExecutableMessageHandler(registry)) + registry.Register(NewRemoveHandlerExecutableMessageHandler(registry)) + return registry +} + +func (r *MessagesRegistry) Register(handler MessageHandler) { + r.handlers.Set(handler.Type(), handler) +} + +func (r *MessagesRegistry) Remove(t string) { + r.handlers.Remove(t) +} + +func (r *MessagesRegistry) MessagesFromJSON(messagesJSON string) []ExecutableMessage { + slice := ujson.ParseSlice(messagesJSON) + msgs := make([]ExecutableMessage, 0, len(slice)) + for _, child := range slice { + var messageType string + var payload *ujson.JSONASTNode + child.ParseObject([]*ujson.ParseKV{ + {Key: "type", Value: &messageType}, + {Key: "payload", Value: &payload}, + }) + h, ok := r.handlers.Get(messageType) + if !ok { + panic("invalid ExecutableMessage: invalid message type") + } + msgs = append(msgs, h.(MessageHandler).MessageFromJSON(payload)) + } + return msgs +} + +func (r *MessagesRegistry) Execute(msg ExecutableMessage) { + h, ok := r.handlers.Get(msg.Type()) + if !ok { + panic("invalid ExecutableMessage: invalid message type") + } + h.(MessageHandler).Execute(msg) +} + +func (r *MessagesRegistry) ExecuteMessages(msgs []ExecutableMessage) { + for _, msg := range msgs { + r.Execute(msg) + } +} + +type RegisterHandlerExecutableMessage struct { + Handler MessageHandler +} + +func (m RegisterHandlerExecutableMessage) Type() string { + return "gno.land/p/demo/teritori/dao_interfaces.RegisterHandler" +} + +func (m *RegisterHandlerExecutableMessage) FromJSON(ast *ujson.JSONASTNode) { + panic("not implemented") +} + +func (m *RegisterHandlerExecutableMessage) ToJSON() string { + panic("not implemented") +} + +func (m *RegisterHandlerExecutableMessage) String() string { + return m.Handler.Type() +} + +type RegisterHandlerExecutableMessageHandler struct { + registry *MessagesRegistry +} + +func NewRegisterHandlerExecutableMessageHandler(registry *MessagesRegistry) *RegisterHandlerExecutableMessageHandler { + return &RegisterHandlerExecutableMessageHandler{registry: registry} +} + +func (h RegisterHandlerExecutableMessageHandler) Type() string { + return RegisterHandlerExecutableMessage{}.Type() +} + +func (h *RegisterHandlerExecutableMessageHandler) MessageFromJSON(ast *ujson.JSONASTNode) ExecutableMessage { + panic("not implemented") +} + +func (h *RegisterHandlerExecutableMessageHandler) Execute(msg ExecutableMessage) { + h.registry.Register(msg.(*RegisterHandlerExecutableMessage).Handler) +} + +type RemoveHandlerExecutableMessage struct { + HandlerType string +} + +func (m RemoveHandlerExecutableMessage) Type() string { + return "gno.land/p/demo/teritori/dao_interfaces.RemoveHandler" +} + +func (m *RemoveHandlerExecutableMessage) FromJSON(ast *ujson.JSONASTNode) { + ast.ParseAny(&m.HandlerType) +} + +func (m *RemoveHandlerExecutableMessage) ToJSON() string { + return ujson.FormatAny(m.HandlerType) +} + +func (m *RemoveHandlerExecutableMessage) String() string { + return m.HandlerType +} + +type RemoveHandlerExecutableMessageHandler struct { + registry *MessagesRegistry +} + +func NewRemoveHandlerExecutableMessageHandler(registry *MessagesRegistry) *RemoveHandlerExecutableMessageHandler { + return &RemoveHandlerExecutableMessageHandler{registry: registry} +} + +func (h RemoveHandlerExecutableMessageHandler) Type() string { + return RemoveHandlerExecutableMessage{}.Type() +} + +func (h *RemoveHandlerExecutableMessageHandler) MessageFromJSON(ast *ujson.JSONASTNode) ExecutableMessage { + msg := &RemoveHandlerExecutableMessage{} + ast.ParseAny(msg) + return msg +} + +func (h *RemoveHandlerExecutableMessageHandler) Execute(msg ExecutableMessage) { + h.registry.Remove(msg.(*RemoveHandlerExecutableMessage).HandlerType) +} diff --git a/gno/p/dao_interfaces/messages_registry_test.gno b/gno/p/dao_interfaces/messages_registry_test.gno new file mode 100644 index 0000000000..e9b4306c7a --- /dev/null +++ b/gno/p/dao_interfaces/messages_registry_test.gno @@ -0,0 +1,52 @@ +package dao_interfaces + +import ( + "testing" +) + +func TestRegistry(t *testing.T) { + registry := NewMessagesRegistry() + + var value string + msgHandler := NewCopyMessageHandler(&value) + + // Test register handler via message + registerMsg := &RegisterHandlerExecutableMessage{Handler: msgHandler} + registry.Execute(registerMsg) + + // Test messages execution + msgs := registry.MessagesFromJSON(`[{"type":"CopyMessage","payload":"Hello"}]`) + if len(msgs) != 1 { + t.Errorf("Expected 1 message, got %d", len(msgs)) + } + registry.Execute(msgs[0]) + if value != "Hello" { + t.Errorf("Expected value to be 'Hello', got '%s'", value) + } + + msg2 := &CopyMessage{Value: "World"} + registry.Execute(msg2) + if value != "World" { + t.Errorf("Expected value to be 'World', got '%s'", value) + } + + // Test handler removal + removeMsg := &RemoveHandlerExecutableMessage{HandlerType: msgHandler.Type()} + registry.Execute(removeMsg) + func() { + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic, got none") + } + }() + registry.Execute(msg2) + }() + + // Test direct register + registry.Register(msgHandler) + msg3 := &CopyMessage{Value: "!"} + registry.Execute(msg3) + if value != "!" { + t.Errorf("Expected value to be '!', got '%s'", value) + } +} diff --git a/gno/p/dao_interfaces/messages_testing.gno b/gno/p/dao_interfaces/messages_testing.gno new file mode 100644 index 0000000000..286e2c20d8 --- /dev/null +++ b/gno/p/dao_interfaces/messages_testing.gno @@ -0,0 +1,54 @@ +package dao_interfaces + +import ( + "gno.land/p/demo/teritori/ujson" +) + +type CopyMessage struct { + Value string +} + +func (m CopyMessage) Type() string { + return "CopyMessage" +} + +func (m *CopyMessage) String() string { + return m.Value +} + +func (m *CopyMessage) FromJSON(ast *ujson.JSONASTNode) { + ast.ParseAny(&m.Value) +} + +func (m *CopyMessage) ToJSON() string { + return ujson.FormatString(m.Value) +} + +type CopyMessageHandler struct { + ptr *string +} + +func NewCopyMessageHandler(ptr *string) *CopyMessageHandler { + if ptr == nil { + panic("ptr cannot be nil") + } + return &CopyMessageHandler{ptr} +} + +func (h *CopyMessageHandler) Execute(imsg ExecutableMessage) { + msg, ok := imsg.(*CopyMessage) + if !ok { + panic("Wrong message type") + } + *h.ptr = msg.Value +} + +func (h CopyMessageHandler) Type() string { + return "CopyMessage" +} + +func (h *CopyMessageHandler) MessageFromJSON(ast *ujson.JSONASTNode) ExecutableMessage { + var msg CopyMessage + ast.ParseAny(&msg) + return &msg +} diff --git a/gno/p/dao_interfaces/modules.gno b/gno/p/dao_interfaces/modules.gno new file mode 100644 index 0000000000..1055968f57 --- /dev/null +++ b/gno/p/dao_interfaces/modules.gno @@ -0,0 +1,36 @@ +package dao_interfaces + +import ( + "std" +) + +type ModuleInfo struct { + Kind string + Version string +} + +// NOTE: Some queries take a height param in DA0-DA0 contracts, but since gno seem to aim to support queries at any height, we shouldn't need it + +type IVotingModule interface { + Info() ModuleInfo + ConfigJSON() string + Render(path string) string + VotingPowerAtHeight(address std.Address, height int64) (power uint64) + TotalPowerAtHeight(height int64) uint64 +} + +type VotingModuleFactory func(core IDAOCore) IVotingModule + +type IProposalModule interface { + Core() IDAOCore + Info() ModuleInfo + ConfigJSON() string + Render(path string) string + Execute(proposalID int) + VoteJSON(proposalID int, voteJSON string) + ProposeJSON(proposalJSON string) int + ProposalsJSON(limit int, startAfter string, reverse bool) string + ProposalJSON(proposalID int) string +} + +type ProposalModuleFactory func(core IDAOCore) IProposalModule diff --git a/gno/p/dao_proposal_single/dao_proposal_single.gno b/gno/p/dao_proposal_single/dao_proposal_single.gno new file mode 100644 index 0000000000..e6c47ac62d --- /dev/null +++ b/gno/p/dao_proposal_single/dao_proposal_single.gno @@ -0,0 +1,409 @@ +package dao_proposal_single + +import ( + "std" + "strconv" + + "gno.land/p/demo/avl" + "gno.land/p/demo/teritori/dao_interfaces" + "gno.land/p/demo/teritori/dao_utils" + "gno.land/p/demo/teritori/ujson" +) + +type DAOProposalSingleOpts struct { + /// The threshold a proposal must reach to complete. + Threshold Threshold + /// The default maximum amount of time a proposal may be voted on + /// before expiring. + MaxVotingPeriod dao_utils.Duration + /// The minimum amount of time a proposal must be open before + /// passing. A proposal may fail before this amount of time has + /// elapsed, but it will not pass. This can be useful for + /// preventing governance attacks wherein an attacker aquires a + /// large number of tokens and forces a proposal through. + MinVotingPeriod dao_utils.Duration // 0 means no minimum + /// If set to true only members may execute passed + /// proposals. Otherwise, any address may execute a passed + /// proposal. + OnlyMembersExecute bool + /// Allows changing votes before the proposal expires. If this is + /// enabled proposals will not be able to complete early as final + /// vote information is not known until the time of proposal + /// expiration. + AllowRevoting bool + /// Information about what addresses may create proposals. + // preProposeInfo PreProposeInfo + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + CloseProposalOnExecutionFailure bool +} + +func (opts DAOProposalSingleOpts) ToJSON() string { + return ujson.FormatObject([]ujson.FormatKV{ + {Key: "threshold", Value: opts.Threshold}, + {Key: "maxVotingPeriod", Value: opts.MaxVotingPeriod}, + {Key: "minVotingPeriod", Value: opts.MinVotingPeriod}, + {Key: "onlyMembersExecute", Value: opts.OnlyMembersExecute}, + {Key: "allowRevoting", Value: opts.AllowRevoting}, + {Key: "closeProposalOnExecutionFailure", Value: opts.CloseProposalOnExecutionFailure}, + }) +} + +type DAOProposalSingle struct { + dao_interfaces.IProposalModule + + core dao_interfaces.IDAOCore + opts *DAOProposalSingleOpts + proposals []*Proposal +} + +func NewDAOProposalSingle(core dao_interfaces.IDAOCore, opts *DAOProposalSingleOpts) *DAOProposalSingle { + if core == nil { + panic("core cannot be nil") + } + + if opts == nil { + panic("opts cannot be nil") + } + + if opts.AllowRevoting { + panic("allow revoting not implemented") + } + + if opts.OnlyMembersExecute { + panic("only members execute not implemented") + } + + if opts.CloseProposalOnExecutionFailure { + panic("close proposal on execution failure not implemented") + } + + if opts.MaxVotingPeriod == nil { + panic("max voting period cannot be nil") + } + + // TODO: support other threshold types + switch opts.Threshold.(type) { + case *ThresholdThresholdQuorum: + threshold := opts.Threshold.(*ThresholdThresholdQuorum) + switch threshold.Threshold.(type) { + case *PercentageThresholdMajority: + panic("not implemented") + case *PercentageThresholdPercent: + if *threshold.Threshold.(*PercentageThresholdPercent) > 10000 { + panic("opts.Threshold.Threshold must be <= 100%") + } + default: + panic("unknown Threshold type") + } + switch threshold.Quorum.(type) { + case *PercentageThresholdMajority: + panic("not implemented") + case *PercentageThresholdPercent: + if *threshold.Quorum.(*PercentageThresholdPercent) > 10000 { + panic("opts.Threshold.Quorum must be <= 100%") + } + default: + panic("unknown PercentageThreshold type") + } + default: + panic("unsupported Threshold type") + } + + return &DAOProposalSingle{core: core, opts: opts} +} + +func (d *DAOProposalSingle) Render(path string) string { + minVotingPeriodStr := "No minimum voting period" + if d.opts.MinVotingPeriod != nil { + minVotingPeriodStr = "Min voting period: " + d.opts.MinVotingPeriod.String() + } + + executeStr := "Any address may execute passed proposals" + if d.opts.OnlyMembersExecute { + executeStr = "Only members may execute passed proposals" + } + + revotingStr := "Revoting is not allowed" + if d.opts.AllowRevoting { + revotingStr = "Revoting is allowed" + } + + closeOnExecFailureStr := "Proposals will remain open after execution failure" + if d.opts.CloseProposalOnExecutionFailure { + closeOnExecFailureStr = "Proposals will be closed if their execution fails" + } + + thresholdStr := "" + switch d.opts.Threshold.(type) { + case *ThresholdThresholdQuorum: + threshold := d.opts.Threshold.(*ThresholdThresholdQuorum) + thresholdStr = "Threshold: " + threshold.Threshold.String() + "\n\n" + + "Quorum: " + threshold.Quorum.String() + default: + panic("unsupported Threshold type") + } + + proposalsStr := "## Proposals\n" + for _, p := range d.proposals { + messagesStr := "" + for _, m := range p.Messages { + messagesStr += "- " + m.(dao_interfaces.ExecutableMessage).String() + "\n" + } + + proposalsStr += "### #" + strconv.Itoa(p.ID) + " " + p.Title + "\n" + + "Status: " + p.Status.String() + "\n\n" + + "Proposed by " + p.Proposer.String() + "\n\n" + + p.Description + "\n\n" + + "Votes summary:" + "\n\n" + + "- Yes: " + strconv.FormatUint(p.Votes.Yes, 10) + "\n" + + "- No: " + strconv.FormatUint(p.Votes.No, 10) + "\n" + + "- Abstain: " + strconv.FormatUint(p.Votes.Abstain, 10) + "\n\n" + + "Total: " + strconv.FormatUint(p.Votes.Total(), 10) + "\n" + + "#### Messages\n" + + messagesStr + + "#### Votes\n" + + p.Ballots.Iterate("", "", func(k string, v interface{}) bool { + ballot := v.(Ballot) + proposalsStr += "- " + k + " voted " + ballot.Vote.String() + "\n" + return false + }) + + proposalsStr += "\n" + } + + return "# Single choice proposals module" + "\n" + + "## Summary" + "\n" + + "Max voting period: " + d.opts.MaxVotingPeriod.String() + "\n\n" + + minVotingPeriodStr + "\n\n" + + executeStr + "\n\n" + + revotingStr + "\n\n" + + closeOnExecFailureStr + "\n\n" + + thresholdStr + "\n\n" + + proposalsStr +} + +func (d *DAOProposalSingle) Core() dao_interfaces.IDAOCore { + return d.core +} + +func (d *DAOProposalSingle) Info() dao_interfaces.ModuleInfo { + return dao_interfaces.ModuleInfo{ + Kind: "SingleChoiceProposal", + Version: "0.1.0", + } +} + +func (d *DAOProposalSingle) ConfigJSON() string { + return ujson.FormatAny(d.opts) +} + +func (d *DAOProposalSingle) Propose(title string, description string, messages []dao_interfaces.ExecutableMessage) int { + // TODO: creation policy + + totalPower := d.core.VotingModule().TotalPowerAtHeight(0) + + expiration := d.opts.MaxVotingPeriod.AfterCurrentBlock() + minVotingPeriod := dao_utils.Expiration(nil) + if d.opts.MinVotingPeriod != nil { + minVotingPeriod = d.opts.MinVotingPeriod.AfterCurrentBlock() + } + + id := len(d.proposals) + + prop := Proposal{ + ID: id, + Title: title, + Description: description, + Proposer: std.PrevRealm().Addr(), + StartHeight: std.GetHeight(), + MinVotingPeriod: minVotingPeriod, + Expiration: expiration, + Threshold: d.opts.Threshold.Clone(), + TotalPower: totalPower, + Messages: messages, + Status: ProposalStatusOpen, + Ballots: avl.NewTree(), + AllowRevoting: d.opts.AllowRevoting, + } + prop.updateStatus() + d.proposals = append(d.proposals, &prop) + return id +} + +func (d *DAOProposalSingle) GetBallot(proposalID int, memberAddress std.Address) Ballot { + if len(d.proposals) <= proposalID || proposalID < 0 { + panic("proposal does not exist") + } + proposal := d.proposals[proposalID] + ballot, has := proposal.Ballots.Get(memberAddress.String()) + if !has { + panic("ballot does not exist") + } + return ballot.(Ballot) +} + +type VoteWithRationale struct { + Vote Vote + Rationale string +} + +func (v *VoteWithRationale) FromJSON(ast *ujson.JSONASTNode) { + ast.ParseObject([]*ujson.ParseKV{ + {Key: "vote", Value: &v.Vote}, + {Key: "rationale", Value: &v.Rationale}, + }) +} + +func (d *DAOProposalSingle) VoteJSON(proposalID int, voteJSON string) { + var v VoteWithRationale + ujson.ParseAny(voteJSON, &v) + + voter := std.PrevRealm().Addr() + + if len(d.proposals) <= proposalID || proposalID < 0 { + panic("proposal does not exist") + } + proposal := d.proposals[proposalID] + + if proposal.Expiration.IsExpired() { + panic("proposal is expired") + } + + votePower := d.core.VotingModule().VotingPowerAtHeight(voter, proposal.StartHeight) + if votePower == 0 { + panic("not registered") + } + + // TODO: handle revoting + if ok := proposal.Ballots.Has(voter.String()); ok { + panic("already voted") + } + proposal.Ballots.Set(voter.String(), Ballot{ + Vote: v.Vote, + Power: votePower, + Rationale: v.Rationale, + }) + + proposal.Votes.Add(v.Vote, votePower) + + proposal.updateStatus() +} + +func (d *DAOProposalSingle) Execute(proposalID int) { + if len(d.proposals) <= proposalID || proposalID < 0 { + panic("proposal does not exist") + } + prop := d.proposals[proposalID] + + prop.updateStatus() + if prop.Status != ProposalStatusPassed { + panic("proposal is not passed") + } + + for _, m := range prop.Messages { + d.core.Registry().Execute(m) + } + + prop.Status = ProposalStatusExecuted +} + +type ProposalRequest struct { + Title string + Description string + Messages *ujson.JSONASTNode +} + +func (pr *ProposalRequest) FromJSON(ast *ujson.JSONASTNode) { + ast.ParseObject([]*ujson.ParseKV{ + {Key: "title", Value: &pr.Title}, + {Key: "description", Value: &pr.Description}, + {Key: "messages", Value: &pr.Messages}, + }) +} + +func (d *DAOProposalSingle) ProposeJSON(proposalJSON string) int { + var req ProposalRequest + ujson.ParseAny(proposalJSON, &req) + msgs := d.core.Registry().MessagesFromJSON(req.Messages.String()) // TODO: optimize + return d.Propose(req.Title, req.Description, msgs) +} + +func (d *DAOProposalSingle) Proposals() []*Proposal { + return d.proposals +} + +func (d *DAOProposalSingle) ProposalsJSON(limit int, startAfter string, reverse bool) string { + iSlice := make([]interface{}, len(d.proposals)) + for i, p := range d.proposals { + iSlice[i] = p + } + return ujson.FormatSlice(iSlice) +} + +func (d *DAOProposalSingle) ProposalJSON(proposalID int) string { + if proposalID < 0 || proposalID >= len(d.proposals) { + panic("proposal does not exist") + } + return ujson.FormatAny(d.proposals[proposalID]) +} + +func (d *DAOProposalSingle) Threshold() Threshold { + return d.opts.Threshold +} + +func (proposal *Proposal) updateStatus() { + if proposal.Status == ProposalStatusOpen && proposal.isPassed() { + proposal.Status = ProposalStatusPassed + return + } +} + +func (proposal *Proposal) isPassed() bool { + switch proposal.Threshold.(interface{}).(type) { + case *ThresholdAbsolutePercentage: + panic("'isPassed' not implemented for 'ThresholdAbsolutePercentage'") + case *ThresholdThresholdQuorum: + thresholdObj := proposal.Threshold.(*ThresholdThresholdQuorum) + + threshold := thresholdObj.Threshold + quorum := thresholdObj.Quorum + + totalPower := proposal.TotalPower + + if !doesVoteCountPass(proposal.Votes.Total(), totalPower, quorum) { + return false + } + + // TODO: handle expiration + options := totalPower - proposal.Votes.Abstain + return doesVoteCountPass(proposal.Votes.Yes, options, threshold) + case *ThresholdAbsoluteCount: + panic("'isPassed' not implemented for 'ThresholdAbsoluteCount'") + default: + panic("unknown Threshold type") + } +} + +func doesVoteCountPass(yesVotes uint64, options uint64, percent PercentageThreshold) bool { + switch percent.(type) { + case *PercentageThresholdMajority: + panic("'doesVoteCountPass' not implemented for 'PercentageThresholdMajority'") + case *PercentageThresholdPercent: + if options == 0 { + return false + } + percentValue := uint64(*percent.(*PercentageThresholdPercent)) + votes := yesVotes * 10000 + threshold := options * percentValue + return votes >= threshold + default: + panic("unknown PercentageThreshold type") + } +} diff --git a/gno/p/dao_proposal_single/gno.mod b/gno/p/dao_proposal_single/gno.mod new file mode 100644 index 0000000000..69255f7cf0 --- /dev/null +++ b/gno/p/dao_proposal_single/gno.mod @@ -0,0 +1,8 @@ +module gno.land/p/demo/teritori/dao_proposal_single + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/teritori/dao_interfaces v0.0.0-latest + gno.land/p/demo/teritori/dao_utils v0.0.0-latest + gno.land/p/demo/teritori/ujson v0.0.0-latest +) diff --git a/gno/p/dao_proposal_single/proposal_test.gno b/gno/p/dao_proposal_single/proposal_test.gno new file mode 100644 index 0000000000..5d1a13a898 --- /dev/null +++ b/gno/p/dao_proposal_single/proposal_test.gno @@ -0,0 +1,90 @@ +package dao_proposal_single + +import ( + "testing" + + "gno.land/p/demo/avl" + dao_interfaces "gno.land/p/demo/teritori/dao_interfaces" + "gno.land/p/demo/teritori/dao_utils" + "gno.land/p/demo/teritori/ujson" +) + +type NoopMessage struct{} + +var _ dao_interfaces.ExecutableMessage = (*NoopMessage)(nil) + +func (m NoopMessage) String() string { + return "noop" +} + +func (m NoopMessage) Type() string { + return "noop-type" +} + +func (m NoopMessage) ToJSON() string { + return ujson.FormatString(m.String()) +} + +func (m NoopMessage) FromJSON(ast *ujson.JSONASTNode) { + var val string + ast.ParseAny(&val) + if val != m.String() { + panic("invalid noop message") + } +} + +func TestProposalJSON(t *testing.T) { + props := []Proposal{ + { + ID: 0, + Title: "Prop #0", + Description: "Wolol0\n\t\r", + Proposer: "0x1234567890", + Votes: Votes{ + Yes: 7, + No: 21, + Abstain: 42, + }, + Expiration: dao_utils.ExpirationAtHeight(1000), + Ballots: avl.NewTree(), + }, + { + ID: 1, + Title: "Prop #1", + Description: `Wolol1\"`, + Proposer: "0x1234567890", + Status: ProposalStatusExecuted, + Expiration: dao_utils.ExpirationAtHeight(2000), + Messages: []dao_interfaces.ExecutableMessage{NoopMessage{}, NoopMessage{}, NoopMessage{}}, + }, + } + props[0].Ballots.Set("0x1234567890", Ballot{Power: 1, Vote: VoteYes, Rationale: "test"}) + iSlice := make([]interface{}, len(props)) + for i, p := range props { + iSlice[i] = p + } + str := ujson.FormatSlice(iSlice) + expected := `[{"id":0,"title":"Prop #0","description":"Wolol0\n\t\r","proposer":"0x1234567890","startHeight":0,"minVotingPeriod":null,"expiration":{"atHeight":1000},"threshold":null,"totalPower":0,"messages":[],"status":"Open","votes":{"yes":7,"no":21,"abstain":42},"allowRevoting":false,"ballots":{"0x1234567890":{"power":1,"vote":"Yes","rationale":"test"}}},{"id":1,"title":"Prop #1","description":"Wolol1\\\"","proposer":"0x1234567890","startHeight":0,"minVotingPeriod":null,"expiration":{"atHeight":2000},"threshold":null,"totalPower":0,"messages":[{"type":"noop-type","payload":"noop"},{"type":"noop-type","payload":"noop"},{"type":"noop-type","payload":"noop"}],"status":"Executed","votes":{"yes":0,"no":0,"abstain":0},"allowRevoting":false,"ballots":{}}]` + if expected != str { + t.Fatalf("JSON does not match, expected %s, got %s", expected, str) + } +} + +func TestConfig(t *testing.T) { + core := dao_interfaces.NewDummyCore() + tt := PercentageThresholdPercent(1) + tq := PercentageThresholdPercent(1) + mod := NewDAOProposalSingle(core, &DAOProposalSingleOpts{ + MaxVotingPeriod: dao_utils.DurationHeight(42), + MinVotingPeriod: dao_utils.DurationHeight(21), + Threshold: &ThresholdThresholdQuorum{ + Threshold: &tt, + Quorum: &tq, + }, + }) + conf := mod.ConfigJSON() + expected := `{"threshold":{"thresholdQuorum":{"threshold":{"percent":1},"quorum":{"percent":1}}},"maxVotingPeriod":{"height":42},"minVotingPeriod":{"height":21},"onlyMembersExecute":false,"allowRevoting":false,"closeProposalOnExecutionFailure":false}` + if expected != conf { + t.Fatalf("Config JSON does not match, expected %s, got %s", expected, conf) + } +} diff --git a/gno/p/dao_proposal_single/threshold.gno b/gno/p/dao_proposal_single/threshold.gno new file mode 100644 index 0000000000..5705da8a85 --- /dev/null +++ b/gno/p/dao_proposal_single/threshold.gno @@ -0,0 +1,131 @@ +package dao_proposal_single + +import ( + "strconv" + + "gno.land/p/demo/teritori/ujson" +) + +// ported from https://github.com/DA0-DA0/dao-contracts/blob/7776858e780f1ce9f038a3b06cce341dd41d2189/packages/dao-voting/src/threshold.rs + +type PercentageThreshold interface { + String() string + Clone() PercentageThreshold + ToJSON() string +} + +func PercentageThresholdFromJSON(ast *ujson.JSONASTNode) PercentageThreshold { + p := PercentageThresholdPercent(0) + return ast.ParseUnion([]*ujson.ParseKV{ + {Key: "majority", Value: &PercentageThresholdMajority{}}, + {Key: "percent", Value: &p}, + }).(PercentageThreshold) +} + +type PercentageThresholdMajority struct{} + +func (p *PercentageThresholdMajority) String() string { + return "Majority" +} + +func (p *PercentageThresholdMajority) Clone() PercentageThreshold { + return &PercentageThresholdMajority{} +} + +func (p *PercentageThresholdMajority) ToJSON() string { + return ujson.FormatUnionMember("majority", "{}", true) +} + +type PercentageThresholdPercent uint16 // 4 decimals fixed point + +func (p *PercentageThresholdPercent) String() string { + s := strconv.FormatUint(uint64(*p)/100, 10) + decPart := uint64(*p) % 100 + if decPart != 0 { + s += "." + strconv.FormatUint(decPart, 10) + } + return s + "%" +} + +func (p *PercentageThresholdPercent) FromJSON(ast *ujson.JSONASTNode) { + var val uint32 + ujson.ParseAny(ast.Value, &val) + *p = PercentageThresholdPercent(val) +} + +func (p *PercentageThresholdPercent) Clone() PercentageThreshold { + c := *p + return &c +} + +func (p *PercentageThresholdPercent) ToJSON() string { + return ujson.FormatUnionMember("percent", uint64(*p), false) +} + +type Threshold interface { + Clone() Threshold + ToJSON() string +} + +func ThresholdFromJSON(ast *ujson.JSONASTNode) Threshold { + ac := ThresholdAbsoluteCount(0) + return ast.ParseUnion([]*ujson.ParseKV{ + // TODO: {Key: "absolutePercentage"}, + {Key: "thresholdQuorum", Value: &ThresholdThresholdQuorum{}}, + {Key: "absoluteCount", Value: &ac}, + }).(Threshold) +} + +type ThresholdAbsolutePercentage struct { + Value PercentageThreshold +} + +func (t ThresholdAbsolutePercentage) Clone() Threshold { + c := t.Value.Clone() + return &ThresholdAbsolutePercentage{Value: c} +} + +func (t ThresholdAbsolutePercentage) ToJSON() string { + return ujson.FormatUnionMember("absolutePercentage", t.Value, false) +} + +type ThresholdThresholdQuorum struct { + Threshold PercentageThreshold + Quorum PercentageThreshold +} + +func (t *ThresholdThresholdQuorum) Clone() Threshold { + return &ThresholdThresholdQuorum{ + Threshold: t.Threshold.Clone(), + Quorum: t.Quorum.Clone(), + } +} + +func (t *ThresholdThresholdQuorum) FromJSON(ast *ujson.JSONASTNode) { + ast.ParseObject([]*ujson.ParseKV{ + {Key: "threshold", CustomParser: func(ast *ujson.JSONASTNode) { + t.Threshold = PercentageThresholdFromJSON(ast) + }}, + {Key: "quorum", CustomParser: func(ast *ujson.JSONASTNode) { + t.Quorum = PercentageThresholdFromJSON(ast) + }}, + }) +} + +func (t *ThresholdThresholdQuorum) ToJSON() string { + return ujson.FormatUnionMember("thresholdQuorum", ujson.FormatObject([]ujson.FormatKV{ + {Key: "threshold", Value: t.Threshold}, + {Key: "quorum", Value: t.Quorum}, + }), true) +} + +type ThresholdAbsoluteCount uint64 + +func (t *ThresholdAbsoluteCount) Clone() Threshold { + val := *t + return &val +} + +func (t *ThresholdAbsoluteCount) ToJSON() string { + return ujson.FormatUnionMember("absoluteCount", uint64(*t), false) +} diff --git a/gno/p/dao_proposal_single/types.gno b/gno/p/dao_proposal_single/types.gno new file mode 100644 index 0000000000..49db078ace --- /dev/null +++ b/gno/p/dao_proposal_single/types.gno @@ -0,0 +1,191 @@ +package dao_proposal_single + +import ( + "std" + "strconv" + + "gno.land/p/demo/avl" + dao_interfaces "gno.land/p/demo/teritori/dao_interfaces" + "gno.land/p/demo/teritori/dao_utils" + "gno.land/p/demo/teritori/ujson" +) + +type Ballot struct { + Power uint64 + Vote Vote + Rationale string +} + +func (b Ballot) ToJSON() string { + return ujson.FormatObject([]ujson.FormatKV{ + {Key: "power", Value: b.Power}, + {Key: "vote", Value: b.Vote}, + {Key: "rationale", Value: b.Rationale}, + }) +} + +type Votes struct { + Yes uint64 + No uint64 + Abstain uint64 +} + +func (v *Votes) Add(vote Vote, power uint64) { + switch vote { + case VoteYes: + v.Yes += power + case VoteNo: + v.No += power + case VoteAbstain: + v.Abstain += power + default: + panic("unknown vote kind") + } +} + +func (v *Votes) Remove(vote Vote, power uint64) { + switch vote { + case VoteYes: + v.Yes -= power + case VoteNo: + v.No -= power + case VoteAbstain: + v.Abstain -= power + default: + panic("unknown vote kind") + } +} + +func (v *Votes) Total() uint64 { + return v.Yes + v.No + v.Abstain +} + +func (v Votes) ToJSON() string { + return ujson.FormatObject([]ujson.FormatKV{ + {Key: "yes", Value: v.Yes}, + {Key: "no", Value: v.No}, + {Key: "abstain", Value: v.Abstain}, + }) +} + +type Proposal struct { + ID int + Title string + Description string + Proposer std.Address + StartHeight int64 + MinVotingPeriod dao_utils.Expiration + Expiration dao_utils.Expiration + Threshold Threshold + TotalPower uint64 + Messages []dao_interfaces.ExecutableMessage + Status ProposalStatus + Votes Votes + AllowRevoting bool + + // not in DA0-DA0 implementation: + + Ballots *avl.Tree +} + +var _ ujson.JSONAble = (*Proposal)(nil) + +type messageWithType struct { + Type string + Message dao_interfaces.ExecutableMessage +} + +func (m *messageWithType) ToJSON() string { + return ujson.FormatObject([]ujson.FormatKV{ + {Key: "type", Value: m.Type}, + {Key: "payload", Value: m.Message}, + }) +} + +func formatMessages(messages []dao_interfaces.ExecutableMessage) string { + var out []interface{} + for _, m := range messages { + out = append(out, &messageWithType{ + Type: m.Type(), + Message: m, + }) + } + return ujson.FormatSlice(out) +} + +func (p Proposal) ToJSON() string { + return ujson.FormatObject([]ujson.FormatKV{ + {Key: "id", Value: p.ID}, + {Key: "title", Value: p.Title}, + {Key: "description", Value: p.Description}, + {Key: "proposer", Value: p.Proposer}, + {Key: "startHeight", Value: p.StartHeight}, + {Key: "minVotingPeriod", Value: p.MinVotingPeriod}, + {Key: "expiration", Value: p.Expiration}, + {Key: "threshold", Value: p.Threshold}, + {Key: "totalPower", Value: p.TotalPower}, + {Key: "messages", Value: formatMessages(p.Messages), Raw: true}, + {Key: "status", Value: p.Status}, + {Key: "votes", Value: p.Votes}, + {Key: "allowRevoting", Value: p.AllowRevoting}, + + {Key: "ballots", Value: p.Ballots}, + }) +} + +type ProposalStatus int + +const ( + ProposalStatusOpen ProposalStatus = iota + ProposalStatusPassed + ProposalStatusExecuted +) + +func (p ProposalStatus) ToJSON() string { + return ujson.FormatString(p.String()) +} + +func (p ProposalStatus) String() string { + switch p { + case ProposalStatusOpen: + return "Open" + case ProposalStatusPassed: + return "Passed" + case ProposalStatusExecuted: + return "Executed" + default: + return "Unknown(" + strconv.Itoa(int(p)) + ")" + } +} + +type Vote int + +const ( + VoteYes Vote = iota + VoteNo + VoteAbstain +) + +func (v Vote) ToJSON() string { + return ujson.FormatString(v.String()) +} + +func (v *Vote) FromJSON(ast *ujson.JSONASTNode) { + var val int + ast.ParseAny(&val) + // FIXME: validate + *v = Vote(val) +} + +func (v Vote) String() string { + switch v { + case VoteYes: + return "Yes" + case VoteNo: + return "No" + case VoteAbstain: + return "Abstain" + default: + return "Unknown(" + strconv.Itoa(int(v)) + ")" + } +} diff --git a/gno/p/dao_proposal_single/update_settings.gno b/gno/p/dao_proposal_single/update_settings.gno new file mode 100644 index 0000000000..011e0b42f1 --- /dev/null +++ b/gno/p/dao_proposal_single/update_settings.gno @@ -0,0 +1,78 @@ +package dao_proposal_single + +import ( + "strings" + + "gno.land/p/demo/teritori/dao_interfaces" + "gno.land/p/demo/teritori/ujson" +) + +// TODO: convert to json + +type UpdateSettingsMessage struct { + dao_interfaces.ExecutableMessage + + Threshold Threshold +} + +func (usm UpdateSettingsMessage) Type() string { + return "gno.land/p/demo/teritori/dao_proposal_single.UpdateSettings" +} + +func (usm *UpdateSettingsMessage) String() string { + ss := []string{usm.Type()} + switch usm.Threshold.(type) { + case *ThresholdThresholdQuorum: + ss = append(ss, "Threshold type: ThresholdQuorum\n\nThreshold: "+usm.Threshold.(*ThresholdThresholdQuorum).Threshold.String()+"\n\nQuorum: "+usm.Threshold.(*ThresholdThresholdQuorum).Quorum.String()) + default: + ss = append(ss, "Threshold type: unknown") + } + return strings.Join(ss, "\n--\n") +} + +func (usm *UpdateSettingsMessage) ToJSON() string { + return ujson.FormatObject([]ujson.FormatKV{ + {Key: "threshold", Value: usm.Threshold}, + }) +} + +func (usm *UpdateSettingsMessage) FromJSON(ast *ujson.JSONASTNode) { + ast.ParseObject([]*ujson.ParseKV{ + {Key: "threshold", CustomParser: func(node *ujson.JSONASTNode) { + usm.Threshold = ThresholdFromJSON(node) + }}, + }) +} + +func NewUpdateSettingsHandler(mod *DAOProposalSingle) dao_interfaces.MessageHandler { + return &updateSettingsHandler{mod: mod} +} + +type updateSettingsHandler struct { + dao_interfaces.MessageHandler + + mod *DAOProposalSingle +} + +func (h *updateSettingsHandler) Execute(message dao_interfaces.ExecutableMessage) { + usm := message.(*UpdateSettingsMessage) + + switch usm.Threshold.(type) { + case *ThresholdThresholdQuorum: + // FIXME: validate better + h.mod.opts.Threshold = usm.Threshold.(*ThresholdThresholdQuorum) + return + default: + panic("unsupported threshold type") + } +} + +func (h updateSettingsHandler) Type() string { + return UpdateSettingsMessage{}.Type() +} + +func (h *updateSettingsHandler) MessageFromJSON(ast *ujson.JSONASTNode) dao_interfaces.ExecutableMessage { + var usm UpdateSettingsMessage + ast.ParseAny(&usm) + return &usm +} diff --git a/gno/p/dao_utils/expiration.gno b/gno/p/dao_utils/expiration.gno new file mode 100644 index 0000000000..855e9330d3 --- /dev/null +++ b/gno/p/dao_utils/expiration.gno @@ -0,0 +1,94 @@ +package dao_utils + +import ( + "std" + "strconv" + "time" + + "gno.land/p/demo/teritori/ujson" +) + +// loosely ported from https://github.com/CosmWasm/cw-utils/blob/7fce8a214f2f1e7763b8718dcbd2a6dd07f30988/src/expiration.rs + +type ( + Expiration interface { + IsExpired() bool + ToJSON() string + String() string + } + ExpirationAtHeight int64 + ExpirationAtTime time.Time + ExpirationNever struct{} +) + +func (e ExpirationAtHeight) IsExpired() bool { + return std.GetHeight() >= int64(e) +} + +func (e ExpirationAtHeight) ToJSON() string { + return ujson.FormatUnionMember("atHeight", int64(e), false) +} + +func (e ExpirationAtHeight) String() string { + return strconv.FormatInt(int64(e), 10) +} + +func (e ExpirationAtTime) IsExpired() bool { + t := time.Time(e) + now := time.Now() + return now.Equal(t) || now.After(t) +} + +func (e ExpirationAtTime) ToJSON() string { + return ujson.FormatUnionMember("atTime", time.Time(e), false) +} + +func (e ExpirationAtTime) String() string { + return time.Time(e).String() +} + +func (e ExpirationNever) IsExpired() bool { + return false +} + +func (e ExpirationNever) ToJSON() string { + return ujson.FormatUnionMember("never", "{}", true) +} + +func (e ExpirationNever) String() string { + return "Never" +} + +type ( + Duration interface { + AfterCurrentBlock() Expiration + ToJSON() string + String() string + } + DurationHeight int64 + DurationTime time.Duration +) + +func (d DurationHeight) AfterCurrentBlock() Expiration { + return ExpirationAtHeight(std.GetHeight() + int64(d)) +} + +func (d DurationHeight) ToJSON() string { + return ujson.FormatUnionMember("height", int64(d), false) +} + +func (d DurationHeight) String() string { + return strconv.FormatInt(int64(d), 10) +} + +func (d DurationTime) AfterCurrentBlock() Expiration { + return ExpirationAtTime(time.Now().Add(time.Duration(d))) +} + +func (d DurationTime) ToJSON() string { + return ujson.FormatUnionMember("time", time.Duration(d), false) +} + +func (d DurationTime) String() string { + return time.Duration(d).String() +} diff --git a/gno/p/dao_utils/expiration_test.gno b/gno/p/dao_utils/expiration_test.gno new file mode 100644 index 0000000000..26fad60f58 --- /dev/null +++ b/gno/p/dao_utils/expiration_test.gno @@ -0,0 +1,15 @@ +package dao_utils + +import ( + "testing" +) + +func TestMatch(t *testing.T) { + ex := ExpirationNever{} + switch Expiration(ex).(type) { + case ExpirationNever: + t.Log("ExpirationNever") + default: + t.Fatalf("expected a match") + } +} diff --git a/gno/p/dao_utils/gno.mod b/gno/p/dao_utils/gno.mod new file mode 100644 index 0000000000..547ab0360f --- /dev/null +++ b/gno/p/dao_utils/gno.mod @@ -0,0 +1,3 @@ +module gno.land/p/demo/teritori/dao_utils + +require gno.land/p/demo/teritori/ujson v0.0.0-latest diff --git a/gno/p/flags_index/flags_index.gno b/gno/p/flags_index/flags_index.gno new file mode 100644 index 0000000000..0bc603e0c9 --- /dev/null +++ b/gno/p/flags_index/flags_index.gno @@ -0,0 +1,163 @@ +package flags_index + +import ( + "strconv" + + "gno.land/p/demo/avl" +) + +type FlagID string + +type FlagCount struct { + FlagID FlagID + Count uint64 +} + +type FlagsIndex struct { + flagsCounts []*FlagCount // sorted by count descending; TODO: optimize using big brain datastructure + flagsCountsByID *avl.Tree // key: flagID -> FlagCount + flagsByFlaggerID *avl.Tree // key: flaggerID -> *avl.Tree key: flagID -> struct{} +} + +func NewFlagsIndex() *FlagsIndex { + return &FlagsIndex{ + flagsCountsByID: avl.NewTree(), + flagsByFlaggerID: avl.NewTree(), + } +} + +func (fi *FlagsIndex) HasFlagged(flagID FlagID, flaggerID string) bool { + if flagsByFlagID, ok := fi.flagsByFlaggerID.Get(flaggerID); ok { + if flagsByFlagID.(*avl.Tree).Has(string(flagID)) { + return true + } + } + return false +} + +func (fi *FlagsIndex) GetFlagCount(flagID FlagID) uint64 { + if flagCount, ok := fi.flagsCountsByID.Get(string(flagID)); ok { + return flagCount.(*FlagCount).Count + } + return 0 +} + +func (fi *FlagsIndex) GetFlags(limit uint64, offset uint64) []*FlagCount { + if limit == 0 { + return nil + } + if offset >= uint64(len(fi.flagsCounts)) { + return nil + } + if offset+limit > uint64(len(fi.flagsCounts)) { + limit = uint64(len(fi.flagsCounts)) - offset + } + return fi.flagsCounts[offset : offset+limit] +} + +func (fi *FlagsIndex) Flag(flagID FlagID, flaggerID string) { + // update flagsByFlaggerID + var flagsByFlagID *avl.Tree + if existingFlagsByFlagID, ok := fi.flagsByFlaggerID.Get(flaggerID); ok { + flagsByFlagID = existingFlagsByFlagID.(*avl.Tree) + if flagsByFlagID.Has(string(flagID)) { + panic("already flagged") + } + } else { + newFlagsByFlagID := avl.NewTree() + fi.flagsByFlaggerID.Set(flaggerID, newFlagsByFlagID) + flagsByFlagID = newFlagsByFlagID + } + flagsByFlagID.Set(string(flagID), struct{}{}) + + // update flagsCountsByID and flagsCounts + iFlagCount, ok := fi.flagsCountsByID.Get(string(flagID)) + if !ok { + flagCount := &FlagCount{FlagID: flagID, Count: 1} + fi.flagsCountsByID.Set(string(flagID), flagCount) + fi.flagsCounts = append(fi.flagsCounts, flagCount) // this is valid because 1 will always be the lowest count and we want the newest flags to be last + } else { + flagCount := iFlagCount.(*FlagCount) + flagCount.Count++ + // move flagCount to correct position in flagsCounts + for i := len(fi.flagsCounts) - 1; i > 0; i-- { + if fi.flagsCounts[i].Count > fi.flagsCounts[i-1].Count { + fi.flagsCounts[i], fi.flagsCounts[i-1] = fi.flagsCounts[i-1], fi.flagsCounts[i] + } else { + break + } + } + } +} + +func (fi *FlagsIndex) ClearFlagCount(flagID FlagID) { + // find flagCount in byID + if !fi.flagsCountsByID.Has(string(flagID)) { + // panic("flag ID not found") // why did you need this? + return + } + + // remove from byID + fi.flagsCountsByID.Remove(string(flagID)) + + // remove from byCount, we need to recreate the slice since splicing is broken + newByCount := []*FlagCount{} + for i := range fi.flagsCounts { + if fi.flagsCounts[i].FlagID == flagID { + continue + } + newByCount = append(newByCount, fi.flagsCounts[i]) + } + fi.flagsCounts = newByCount + + // update flagsByFlaggerID + var empty []string + fi.flagsByFlaggerID.Iterate("", "", func(key string, value interface{}) bool { + t := value.(*avl.Tree) + t.Remove(string(flagID)) + if t.Size() == 0 { + empty = append(empty, key) + } + return false + }) + for _, key := range empty { + fi.flagsByFlaggerID.Remove(key) + } +} + +func (fi *FlagsIndex) Dump() string { + str := "" + + str += "## flagsCounts:\n" + for i := range fi.flagsCounts { + str += "- " + if fi.flagsCounts[i] == nil { + str += "nil (" + strconv.Itoa(i) + ")\n" + continue + } + str += string(fi.flagsCounts[i].FlagID) + " " + strconv.FormatUint(fi.flagsCounts[i].Count, 10) + "\n" + } + + str += "\n## flagsCountsByID:\n" + fi.flagsCountsByID.Iterate("", "", func(key string, value interface{}) bool { + str += "- " + if value == nil { + str += "nil (" + key + ")\n" + return false + } + str += key + ": " + string(value.(*FlagCount).FlagID) + " " + strconv.FormatUint(value.(*FlagCount).Count, 10) + "\n" + return false + }) + + str += "\n## flagsByFlaggerID:\n" + fi.flagsByFlaggerID.Iterate("", "", func(key string, value interface{}) bool { + str += "- " + key + ":\n" + value.(*avl.Tree).Iterate("", "", func(key string, value interface{}) bool { + str += " - " + key + "\n" + return false + }) + return false + }) + + return str +} diff --git a/gno/p/flags_index/gno.mod b/gno/p/flags_index/gno.mod new file mode 100644 index 0000000000..1a8ac5b674 --- /dev/null +++ b/gno/p/flags_index/gno.mod @@ -0,0 +1,3 @@ +module gno.land/p/demo/teritori/flags_index + +require gno.land/p/demo/avl v0.0.0-latest diff --git a/gno/p/havl/gno.mod b/gno/p/havl/gno.mod new file mode 100644 index 0000000000..f0baadd8a5 --- /dev/null +++ b/gno/p/havl/gno.mod @@ -0,0 +1,3 @@ +module gno.land/p/demo/teritori/havl + +require gno.land/p/demo/avl v0.0.0-latest diff --git a/gno/p/havl/havl.gno b/gno/p/havl/havl.gno new file mode 100644 index 0000000000..c45dea30b0 --- /dev/null +++ b/gno/p/havl/havl.gno @@ -0,0 +1,128 @@ +package havl + +import ( + "std" + "strconv" + "strings" + + "gno.land/p/demo/avl" +) + +type Tree struct { + root avl.Tree // height -> *avl.Tree + initialHeight int64 +} + +// FIXME: don't cast height to int + +// this is not optimized at all, we make a full copy on write + +func NewTree() *Tree { + return &Tree{initialHeight: std.GetHeight()} +} + +func (t *Tree) Size(height int64) int { + snapshot, _ := t.GetSnapshot(height) + return snapshot.Size() +} + +func (t *Tree) Has(key string, height int64) (has bool) { + snapshot, _ := t.GetSnapshot(height) + return snapshot.Has(key) +} + +func (t *Tree) Get(key string, height int64) (value interface{}, exists bool) { + snapshot, _ := t.GetSnapshot(height) + return snapshot.Get(key) +} + +func (t *Tree) GetByIndex(index int, height int64) (key string, value interface{}) { + snapshot, _ := t.GetSnapshot(height) + return snapshot.GetByIndex(index) +} + +func (t *Tree) Set(key string, value interface{}) (updated bool) { + root := t.getOrCreateCurrentRoot() + return root.Set(key, value) +} + +func (t *Tree) Remove(key string) (value interface{}, removed bool) { + root := t.getOrCreateCurrentRoot() + return root.Remove(key) +} + +// Shortcut for TraverseInRange. +func (t *Tree) Iterate(start, end string, height int64, cb avl.IterCbFn) bool { + snapshot, _ := t.GetSnapshot(height) + return snapshot.Iterate(start, end, cb) +} + +// Shortcut for TraverseInRange. +func (t *Tree) ReverseIterate(start, end string, height int64, cb avl.IterCbFn) bool { + snapshot, _ := t.GetSnapshot(height) + return snapshot.ReverseIterate(start, end, cb) +} + +// Shortcut for TraverseByOffset. +func (t *Tree) IterateByOffset(offset int, count int, height int64, cb avl.IterCbFn) bool { + snapshot, _ := t.GetSnapshot(height) + return snapshot.IterateByOffset(offset, count, cb) +} + +// Shortcut for TraverseByOffset. +func (t *Tree) ReverseIterateByOffset(offset int, count int, height int64, cb avl.IterCbFn) bool { + snapshot, _ := t.GetSnapshot(height) + return snapshot.ReverseIterateByOffset(offset, count, cb) +} + +func (t *Tree) GetSnapshot(height int64) (*avl.Tree, int64) { + key := getPaddedKey(height) + var snapshot *avl.Tree + snapshotHeight := int(t.initialHeight) + t.root.ReverseIterate("", key, func(key string, value interface{}) bool { + snapshot = value.(*avl.Tree) + var err error + snapshotHeight, err = strconv.Atoi(key) + if err != nil { + panic("internal error: failed to unmarshal key") + } + return true + }) + if snapshot == nil { + snapshot = avl.NewTree() + } + return snapshot, int64(snapshotHeight) +} + +// utils + +func getPaddedKey(height int64) string { + if height <= 0 { + height = std.GetHeight() + } + val := strconv.Itoa(int(height)) + return strings.Repeat("0", len("9223372036854775807")-len(val)) + val +} + +func clone(t *avl.Tree) *avl.Tree { + r := avl.NewTree() + t.Iterate("", "", func(key string, value interface{}) bool { + r.Set(key, value) + return false + }) + return r +} + +func (t *Tree) getOrCreateCurrentRoot() *avl.Tree { + key := getPaddedKey(0) + iroot, ok := t.root.Get(key) + var root *avl.Tree + if ok { + root = iroot.(*avl.Tree) + } else { + snapshot, _ := t.GetSnapshot(0) + root = clone(snapshot) + t.root.Set(key, root) + } + return root +} diff --git a/gno/p/markdown_utils/gno.mod b/gno/p/markdown_utils/gno.mod new file mode 100644 index 0000000000..bcbb9b5873 --- /dev/null +++ b/gno/p/markdown_utils/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/teritori/markdown_utils diff --git a/gno/p/markdown_utils/markdown_utils.gno b/gno/p/markdown_utils/markdown_utils.gno new file mode 100644 index 0000000000..b593884a83 --- /dev/null +++ b/gno/p/markdown_utils/markdown_utils.gno @@ -0,0 +1,28 @@ +package markdown_utils + +import ( + "strings" +) + +// this function take as input a markdown string and add an indentation level to markdown titles +func Indent(markdown string) string { + // split the markdown string into lines + lines := strings.Split(markdown, "\n") + + // iterate over the lines + for i, line := range lines { + // if the line starts with a markdown title + if strings.HasPrefix(line, "#") { + // add an indentation level to the title + lines[i] = "#" + line + } + } + + // join the lines back into a string + return strings.Join(lines, "\n") +} + +// thanks copilot this is perfect xD +// I just renamed it, AddIndentationLevelToMarkdownTitles was too long + +// blockchain + ai, invest quick!!!! \ No newline at end of file diff --git a/gno/p/ujson/format.gno b/gno/p/ujson/format.gno new file mode 100644 index 0000000000..38c2a0def1 --- /dev/null +++ b/gno/p/ujson/format.gno @@ -0,0 +1,146 @@ +package ujson + +// This package strives to have the same behavior as json.Marshal but does not support all types and returns strings + +import ( + "errors" + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/users" +) + +type JSONAble interface { + ToJSON() string +} + +type FormatKV struct { + Key string + Value interface{} + Raw bool +} + +// does not work for slices, use FormatSlice instead +func FormatAny(p interface{}) string { + switch p.(type) { + case std.Address: + return FormatString(string(p.(std.Address))) + case *avl.Tree: + return FormatAVLTree(p.(*avl.Tree)) + case avl.Tree: + t := p.(avl.Tree) + return FormatAVLTree(&t) + case JSONAble: + return p.(JSONAble).ToJSON() + case string: + return FormatString(p.(string)) + case uint64: + return FormatUint64(p.(uint64)) + case uint32: + return FormatUint64(uint64(p.(uint32))) + case uint: + return FormatUint64(uint64(p.(uint))) + case int64: + return FormatInt64(p.(int64)) + case int32: + return FormatInt64(int64(p.(int32))) + case int: + return FormatInt64(int64(p.(int))) + case float32: + panic("float32 not implemented") + case float64: + panic("float64 not implemented") + case bool: + return FormatBool(p.(bool)) + case time.Time: + return FormatTime(p.(time.Time)) + case time.Duration: + return FormatInt64(int64(p.(time.Duration))) + case users.AddressOrName: + return FormatString(string(p.(users.AddressOrName))) + default: + return "null" + } +} + +// loosely ported from https://cs.opensource.google/go/go/+/master:src/time/time.go;l=1357?q=appendStrictRFC3339&ss=go%2Fgo +func FormatTime(t time.Time) string { + s := t.Format(time.RFC3339Nano) + b := []byte(s) + + // Not all valid Go timestamps can be serialized as valid RFC 3339. + // Explicitly check for these edge cases. + // See https://go.dev/issue/4556 and https://go.dev/issue/54580. + n0 := 0 + num2 := func(b []byte) byte { return 10*(b[0]-'0') + (b[1] - '0') } + switch { + case b[n0+len("9999")] != '-': // year must be exactly 4 digits wide + panic(errors.New("year outside of range [0,9999]")) + case b[len(b)-1] != 'Z': + c := b[len(b)-len("Z07:00")] + if ('0' <= c && c <= '9') || num2(b[len(b)-len("07:00"):]) >= 24 { + panic(errors.New("timezone hour outside of range [0,23]")) + } + } + return FormatString(string(b)) +} + +func FormatUint64(i uint64) string { + return strconv.FormatUint(i, 10) +} + +func FormatInt64(i int64) string { + return strconv.FormatInt(i, 10) +} + +func FormatSlice(s []interface{}) string { + elems := make([]string, len(s)) + for i, elem := range s { + elems[i] = FormatAny(elem) + } + return "[" + strings.Join(elems, ",") + "]" +} + +func FormatObject(kv []FormatKV) string { + elems := make([]string, len(kv)) + i := 0 + for _, elem := range kv { + var val string + if elem.Raw { + val = elem.Value.(string) + } else { + val = FormatAny(elem.Value) + } + elems[i] = FormatString(elem.Key) + ":" + val + i++ + } + return "{" + strings.Join(elems, ",") + "}" +} + +func FormatBool(b bool) string { + if b { + return "true" + } + return "false" +} + +func FormatAVLTree(t *avl.Tree) string { + if t == nil { + return "{}" + } + kv := make([]FormatKV, 0, t.Size()) + t.Iterate("", "", func(key string, value interface{}) bool { + kv = append(kv, FormatKV{key, value, false}) + return false + }) + return FormatObject(kv) +} + +func FormatUnionMember(name string, val interface{}, raw bool) string { + return FormatObject([]FormatKV{ + {Key: name, Value: val, Raw: raw}, + }) +} diff --git a/gno/p/ujson/gno.mod b/gno/p/ujson/gno.mod new file mode 100644 index 0000000000..d15229b487 --- /dev/null +++ b/gno/p/ujson/gno.mod @@ -0,0 +1,7 @@ +module gno.land/p/demo/teritori/ujson + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/teritori/utf16 v0.0.0-latest + gno.land/p/demo/users v0.0.0-latest +) diff --git a/gno/p/ujson/parse.gno b/gno/p/ujson/parse.gno new file mode 100644 index 0000000000..79ddd001f7 --- /dev/null +++ b/gno/p/ujson/parse.gno @@ -0,0 +1,594 @@ +package ujson + +import ( + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/users" +) + +// https://stackoverflow.com/a/4150626 +const whitespaces = " \t\n\r" + +type FromJSONAble interface { + FromJSON(ast *JSONASTNode) +} + +// does not work for slices, use ast exploration instead +func (ast *JSONASTNode) ParseAny(ptr interface{}) { + switch ptr.(type) { + case *std.Address: + *ptr.(*std.Address) = std.Address(ParseString(ast.Value)) + case **avl.Tree: + panic("*avl.Tree not implemented, there is no way to know the type of the tree values, use a custom parser instead") + case *avl.Tree: + panic("avl.Tree not implemented, there is no way to know the type of the tree values, use a custom parser instead") + case *string: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindString { + panic("not a string") + } + *ptr.(*string) = ParseString(ast.Value) + case *uint64: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindNumber { + panic("not a number") + } + *ptr.(*uint64) = ParseUint64(ast.Value) + case *uint32: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindNumber { + panic("not a number") + } + *ptr.(*uint32) = uint32(ParseUint64(ast.Value)) + case *uint: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindNumber { + panic("not a number") + } + *ptr.(*uint) = uint(ParseUint64(ast.Value)) + case *int64: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindNumber { + panic("not a number") + } + *ptr.(*int64) = ParseInt64(ast.Value) + case *int32: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindNumber { + panic("not a number") + } + *ptr.(*int32) = int32(ParseInt64(ast.Value)) + case *int: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindNumber { + panic("not a number") + } + *ptr.(*int) = int(ParseInt64(ast.Value)) + case *float64: + panic("float64 not implemented") + case *float32: + panic("float32 not implemented") + case *bool: + if ast.Kind != JSONKindValue { + panic("not a value") + } + if ast.ValueKind != JSONTokenKindTrue && ast.ValueKind != JSONTokenKindFalse { + panic("not a bool") + } + *ptr.(*bool) = ast.ValueKind == JSONTokenKindTrue + case *FromJSONAble: + (*(ptr.(*FromJSONAble))).FromJSON(ast) + case FromJSONAble: + ptr.(FromJSONAble).FromJSON(ast) + case **JSONASTNode: + *ptr.(**JSONASTNode) = ast + case *time.Time: + ast.ParseTime(ptr.(*time.Time)) + case *time.Duration: + *ptr.(*time.Duration) = time.Duration(ParseInt64(ast.Value)) + case *users.AddressOrName: + s := ParseString(ast.Value) + *ptr.(*users.AddressOrName) = users.AddressOrName(s) + default: + if ast.Kind == JSONKindValue && ast.ValueKind == JSONTokenKindNull { + // *ptr.(*interface{}) = nil // TODO: handle nil + return + } + panic("type not defined for `" + ast.String() + "`") + } +} + +// loosely ported from https://cs.opensource.google/go/go/+/master:src/time/time.go;l=1370?q=appendStrictRFC3339&ss=go%2Fgo +// it's not a full port since it would require copying lot of utils +func (ast *JSONASTNode) ParseTime(t *time.Time) { + if ast.Kind != JSONKindValue && ast.ValueKind != JSONTokenKindString { + panic("time is not a string") + } + s := ParseString(ast.Value) + var err error + *t, err = time.Parse(time.RFC3339Nano, s) + if err != nil { + panic(err) + } +} + +func ParseUint64(s string) uint64 { + val, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + return uint64(val) +} + +func ParseInt64(s string) int64 { + val, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + return int64(val) +} + +type ParseKV struct { + Key string + Value interface{} + ArrayParser func(children []*JSONASTNode) + ObjectParser func(children []*JSONASTKV) + CustomParser func(node *JSONASTNode) +} + +func ParseAny(s string, val interface{}) { + tokens := tokenize(s) + if len(tokens) == 0 { + panic("empty json") + } + remainingTokens, ast := parseAST(tokens) + if len(remainingTokens) > 0 { + panic("invalid json") + } + ast.ParseAny(val) +} + +func (ast *JSONASTNode) ParseObject(kv []*ParseKV) { + if ast.Kind != JSONKindObject { + panic("not an object") + } + for _, elem := range kv { + for i, child := range ast.ObjectChildren { + if child.Key == elem.Key { + if elem.ArrayParser != nil { + if child.Value.Kind != JSONKindArray { + panic("not an array") + } + elem.ArrayParser(child.Value.ArrayChildren) + } else if elem.ObjectParser != nil { + if child.Value.Kind != JSONKindObject { + panic("not an object") + } + elem.ObjectParser(child.Value.ObjectChildren) + } else if elem.CustomParser != nil { + elem.CustomParser(child.Value) + } else { + child.Value.ParseAny(elem.Value) + } + break + } + if i == (len(ast.ObjectChildren) - 1) { + panic("invalid key `" + elem.Key + "` in object `" + ast.String() + "`") + } + } + } +} + +func (ast *JSONASTNode) ParseUnion(kv []*ParseKV) interface{} { + if ast.Kind != JSONKindObject { + panic("union is not an object") + } + if len(ast.ObjectChildren) != 1 { + panic("union object does not have exactly one field") + } + k, node := ast.ObjectChildren[0].Key, ast.ObjectChildren[0].Value + for _, kv := range kv { + if kv.Key == k { + node.ParseAny(kv.Value) + return kv.Value + } + } + panic("unknown union type") // TODO: expected one of ... +} + +func ParseSlice(s string) []*JSONASTNode { + ast := TokenizeAndParse(s) + return ast.ParseSlice() +} + +func (ast *JSONASTNode) ParseSlice() []*JSONASTNode { + if ast.Kind != JSONKindArray { + panic("not an array") + } + return ast.ArrayChildren +} + +func countWhitespaces(s string) int { + i := 0 + for i < len(s) { + if strings.ContainsRune(whitespaces, int32(s[i])) { + i++ + } else { + break + } + } + return i +} + +func JSONTokensString(tokens []*JSONToken) string { + s := "" + for _, token := range tokens { + s += token.Raw + } + return s +} + +func (node *JSONASTNode) String() string { + if node == nil { + return "nil" + } + switch node.Kind { + case JSONKindValue: + return node.Value + case JSONKindArray: + s := "[" + for i, child := range node.ArrayChildren { + if i > 0 { + s += "," + } + s += child.String() + } + s += "]" + return s + case JSONKindObject: + s := "{" + for i, child := range node.ObjectChildren { + if i > 0 { + s += "," + } + s += `"` + child.Key + `":` + child.Value.String() + } + s += "}" + return s + default: + panic("invalid json") + } +} + +func TokenizeAndParse(s string) *JSONASTNode { + tokens := tokenize(s) + if len(tokens) == 0 { + panic("empty json") + } + remainingTokens, ast := parseAST(tokens) + if len(remainingTokens) > 0 { + panic("invalid json") + } + return ast +} + +func parseAST(tokens []*JSONToken) (tkn []*JSONToken, tree *JSONASTNode) { + if len(tokens) == 0 { + panic("empty json") + } + + switch tokens[0].Kind { + + case JSONTokenKindString: + return tokens[1:], &JSONASTNode{Kind: JSONKindValue, ValueKind: tokens[0].Kind, Value: tokens[0].Raw} + case JSONTokenKindNumber: + return tokens[1:], &JSONASTNode{Kind: JSONKindValue, ValueKind: tokens[0].Kind, Value: tokens[0].Raw} + case JSONTokenKindTrue: + return tokens[1:], &JSONASTNode{Kind: JSONKindValue, ValueKind: tokens[0].Kind, Value: tokens[0].Raw} + case JSONTokenKindFalse: + return tokens[1:], &JSONASTNode{Kind: JSONKindValue, ValueKind: tokens[0].Kind, Value: tokens[0].Raw} + case JSONTokenKindNull: + return tokens[1:], &JSONASTNode{Kind: JSONKindValue, ValueKind: tokens[0].Kind, Value: tokens[0].Raw} + + case JSONTokenKindOpenArray: + arrayChildren := []*JSONASTNode{} + tokens = tokens[1:] + for len(tokens) > 0 { + if tokens[0].Kind == JSONTokenKindCloseArray { + return tokens[1:], &JSONASTNode{Kind: JSONKindArray, ArrayChildren: arrayChildren} + } + var child *JSONASTNode + tokens, child = parseAST(tokens) + arrayChildren = append(arrayChildren, child) + if len(tokens) == 0 { + panic("exepected more tokens in array") + } + if tokens[0].Kind == JSONTokenKindComma { + tokens = tokens[1:] + } else if tokens[0].Kind == JSONTokenKindCloseArray { + return tokens[1:], &JSONASTNode{Kind: JSONKindArray, ArrayChildren: arrayChildren} + } else { + panic("unexpected token in array after value `" + tokens[0].Raw + "`") + } + } + + case JSONTokenKindOpenObject: + objectChildren := []*JSONASTKV{} + if len(tokens) < 2 { + panic("objects must have at least 2 tokens") + } + tokens = tokens[1:] + for len(tokens) > 0 { + if tokens[0].Kind == JSONTokenKindCloseObject { + return tokens[1:], &JSONASTNode{Kind: JSONKindObject, ObjectChildren: objectChildren} + } + if tokens[0].Kind != JSONTokenKindString { + panic("invalid json") + } + key := tokens[0].Raw + tokens = tokens[1:] + if len(tokens) == 0 { + panic("exepected more tokens in object") + } + if tokens[0].Kind != JSONTokenKindColon { + panic("expected :") + } + tokens = tokens[1:] + if len(tokens) == 0 { + panic("exepected more tokens in object after :") + } + var value *JSONASTNode + tokens, value = parseAST(tokens) + objectChildren = append(objectChildren, &JSONASTKV{Key: ParseString(key), Value: value}) + if len(tokens) == 0 { + panic("exepected more tokens in object after value") + } + if tokens[0].Kind == JSONTokenKindComma { + tokens = tokens[1:] + } else if tokens[0].Kind == JSONTokenKindCloseObject { + return tokens[1:], &JSONASTNode{Kind: JSONKindObject, ObjectChildren: objectChildren} + } else { + panic("unexpected token in object after value `" + tokens[0].Raw + "`") + } + } + + default: + panic("unexpected token `" + tokens[0].Raw + "`") + } + + return +} + +func tokenize(s string) []*JSONToken { + tokens := []*JSONToken{} + for len(s) > 0 { + var token *JSONToken + s, token = tokenizeOne(s) + if token.Kind != JSONTokenKindSpaces { + tokens = append(tokens, token) + } + } + return tokens +} + +func tokenizeOne(s string) (string, *JSONToken) { + if len(s) == 0 { + panic("invalid token") + } + spacesCount := countWhitespaces(s) + if spacesCount > 0 { + spaces := s[:spacesCount] + return s[spacesCount:], &JSONToken{Kind: JSONTokenKindSpaces, Raw: spaces} + } + switch s[0] { + case '"': + return parseStringToken(s) + case 't': + return parseKeyword(s, "true", JSONTokenKindTrue) + case 'f': + return parseKeyword(s, "false", JSONTokenKindFalse) + case 'n': + return parseKeyword(s, "null", JSONTokenKindNull) + case '{': + return s[1:], &JSONToken{Kind: JSONTokenKindOpenObject, Raw: "{"} + case '[': + return s[1:], &JSONToken{Kind: JSONTokenKindOpenArray, Raw: "["} + case ':': + return s[1:], &JSONToken{Kind: JSONTokenKindColon, Raw: ":"} + case ',': + return s[1:], &JSONToken{Kind: JSONTokenKindComma, Raw: ","} + case ']': + return s[1:], &JSONToken{Kind: JSONTokenKindCloseArray, Raw: "]"} + case '}': + return s[1:], &JSONToken{Kind: JSONTokenKindCloseObject, Raw: "}"} + default: + return parseNumber(s) + } +} + +func parseKeyword(s string, keyword string, kind JSONTokenKind) (string, *JSONToken) { + if len(s) < len(keyword) { + panic("invalid keyword") + } + if s[:len(keyword)] != keyword { + panic("invalid keyword") + } + return s[len(keyword):], &JSONToken{Kind: kind, Raw: keyword} +} + +func parseStringToken(s string) (string, *JSONToken) { + if (len(s) < 2) || (s[0] != '"') { + panic("invalid string") + } + quote := false + for i := 1; i < len(s); i++ { + if !quote && s[i] == '\\' { + quote = true + continue + } + if !quote && s[i] == '"' { + return s[i+1:], &JSONToken{Kind: JSONTokenKindString, Raw: s[:i+1]} + } + quote = false + } + panic("invalid string") +} + +// copiloted +func parseNumber(s string) (string, *JSONToken) { + if len(s) == 0 { + panic("invalid number") + } + i := 0 + if s[i] == '-' { + i++ + } + if i == len(s) { + panic("invalid number") + } + if s[i] == '0' { + i++ + } else if ('1' <= s[i]) && (s[i] <= '9') { + i++ + for (i < len(s)) && ('0' <= s[i]) && (s[i] <= '9') { + i++ + } + } else { + panic("invalid number") + } + if i == len(s) { + return s[i:], &JSONToken{Kind: JSONTokenKindNumber, Raw: s} + } + if s[i] == '.' { + i++ + if i == len(s) { + panic("invalid number") + } + if ('0' <= s[i]) && (s[i] <= '9') { + i++ + for (i < len(s)) && ('0' <= s[i]) && (s[i] <= '9') { + i++ + } + } else { + panic("invalid number") + } + } + if i == len(s) { + return s[i:], &JSONToken{Kind: JSONTokenKindNumber, Raw: s} + } + if (s[i] == 'e') || (s[i] == 'E') { + i++ + if i == len(s) { + panic("invalid number") + } + if (s[i] == '+') || (s[i] == '-') { + i++ + } + if i == len(s) { + panic("invalid number") + } + if ('0' <= s[i]) && (s[i] <= '9') { + i++ + for (i < len(s)) && ('0' <= s[i]) && (s[i] <= '9') { + i++ + } + } else { + panic("invalid number") + } + } + return s[i:], &JSONToken{Kind: JSONTokenKindNumber, Raw: s[:i]} +} + +type JSONTokenKind int + +type JSONKind int + +const ( + JSONKindUnknown JSONKind = iota + JSONKindValue + JSONKindObject + JSONKindArray +) + +type JSONASTNode struct { + Kind JSONKind + ArrayChildren []*JSONASTNode + ObjectChildren []*JSONASTKV + ValueKind JSONTokenKind + Value string +} + +type JSONASTKV struct { + Key string + Value *JSONASTNode +} + +const ( + JSONTokenKindUnknown JSONTokenKind = iota + JSONTokenKindString + JSONTokenKindNumber + JSONTokenKindTrue + JSONTokenKindFalse + JSONTokenKindSpaces + JSONTokenKindComma + JSONTokenKindColon + JSONTokenKindOpenArray + JSONTokenKindCloseArray + JSONTokenKindOpenObject + JSONTokenKindCloseObject + JSONTokenKindNull +) + +func (k JSONTokenKind) String() string { + switch k { + case JSONTokenKindString: + return "string" + case JSONTokenKindNumber: + return "number" + case JSONTokenKindTrue: + return "true" + case JSONTokenKindFalse: + return "false" + case JSONTokenKindSpaces: + return "spaces" + case JSONTokenKindComma: + return "comma" + case JSONTokenKindColon: + return "colon" + case JSONTokenKindOpenArray: + return "open-array" + case JSONTokenKindCloseArray: + return "close-array" + case JSONTokenKindOpenObject: + return "open-object" + case JSONTokenKindCloseObject: + return "close-object" + case JSONTokenKindNull: + return "null" + default: + return "unknown" + } +} + +type JSONToken struct { + Kind JSONTokenKind + Raw string +} diff --git a/gno/p/ujson/strings.gno b/gno/p/ujson/strings.gno new file mode 100644 index 0000000000..760b94d9a6 --- /dev/null +++ b/gno/p/ujson/strings.gno @@ -0,0 +1,233 @@ +package ujson + +import ( + "unicode/utf8" + + "gno.land/p/demo/teritori/utf16" +) + +const ( + ReplacementChar = '\uFFFD' // Represents invalid code points. +) + +// Ported from https://cs.opensource.google/go/go/+/refs/tags/go1.20.6:src/encoding/json/encode.go +func FormatString(s string) string { + const escapeHTML = true + e := `"` // e.WriteByte('"') + start := 0 + for i := 0; i < len(s); { + if b := s[i]; b < utf8.RuneSelf { + if htmlSafeSet[b] || (!escapeHTML && safeSet[b]) { + i++ + continue + } + if start < i { + e += s[start:i] // e.WriteString(s[start:i]) + } + e += "\\" // e.WriteByte('\\') + switch b { + case '\\', '"': + e += string(b) // e.WriteByte(b) + case '\n': + e += "n" // e.WriteByte('n') + case '\r': + e += "r" // e.WriteByte('r') + case '\t': + e += "t" // e.WriteByte('t') + default: + // This encodes bytes < 0x20 except for \t, \n and \r. + // If escapeHTML is set, it also escapes <, >, and & + // because they can lead to security holes when + // user-controlled strings are rendered into JSON + // and served to some browsers. + e += `u00` // e.WriteString(`u00`) + e += string(hex[b>>4]) // e.WriteByte(hex[b>>4]) + e += string(hex[b&0xF]) // e.WriteByte(hex[b&0xF]) + } + i++ + start = i + continue + } + c, size := utf8.DecodeRuneInString(s[i:]) + if c == utf8.RuneError && size == 1 { + if start < i { + e += s[start:i] // e.WriteString(s[start:i]) + } + e += `\ufffd` // e.WriteString(`\ufffd`) + i += size + start = i + continue + } + // U+2028 is LINE SEPARATOR. + // U+2029 is PARAGRAPH SEPARATOR. + // They are both technically valid characters in JSON strings, + // but don't work in JSONP, which has to be evaluated as JavaScript, + // and can lead to security holes there. It is valid JSON to + // escape them, so we do so unconditionally. + // See http://timelessrepo.com/json-isnt-a-javascript-subset for discussion. + if c == '\u2028' || c == '\u2029' { + if start < i { + e += s[start:i] // e.WriteString(s[start:i]) + } + e += `\u202` // e.WriteString(`\u202`) + e += string(hex[c&0xF]) // e.WriteByte(hex[c&0xF]) + i += size + start = i + continue + } + i += size + } + if start < len(s) { + e += s[start:] // e.WriteString(s[start:]) + } + e += `"` // e.WriteByte('"') + return e +} + +// Ported from https://cs.opensource.google/go/go/+/refs/tags/go1.20.6:src/encoding/json/decode.go +// unquote converts a quoted JSON string literal s into an actual string t. +// The rules are different than for Go, so cannot use strconv.Unquote. +func ParseString(s string) string { + o, ok := unquoteBytes([]byte(s)) + if !ok { + panic("invalid string") + } + return string(o) +} + +func unquoteBytes(s []byte) (t []byte, ok bool) { + if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { + return + } + s = s[1 : len(s)-1] + + // Check for unusual characters. If there are none, + // then no unquoting is needed, so return a slice of the + // original bytes. + r := 0 + for r < len(s) { + c := s[r] + if c == '\\' || c == '"' || c < ' ' { + break + } + if c < utf8.RuneSelf { + r++ + continue + } + rr, size := utf8.DecodeRune(s[r:]) + if rr == utf8.RuneError && size == 1 { + break + } + r += size + } + if r == len(s) { + return s, true + } + + b := make([]byte, len(s)+2*utf8.UTFMax) + w := copy(b, s[0:r]) + for r < len(s) { + // Out of room? Can only happen if s is full of + // malformed UTF-8 and we're replacing each + // byte with RuneError. + if w >= len(b)-2*utf8.UTFMax { + nb := make([]byte, (len(b)+utf8.UTFMax)*2) + copy(nb, b[0:w]) + b = nb + } + switch c := s[r]; { + case c == '\\': + r++ + if r >= len(s) { + return + } + switch s[r] { + default: + return + case '"', '\\', '/', '\'': + b[w] = s[r] + r++ + w++ + case 'b': + b[w] = '\b' + r++ + w++ + case 'f': + b[w] = '\f' + r++ + w++ + case 'n': + b[w] = '\n' + r++ + w++ + case 'r': + b[w] = '\r' + r++ + w++ + case 't': + b[w] = '\t' + r++ + w++ + case 'u': + r-- + rr := getu4(s[r:]) + if rr < 0 { + return + } + r += 6 + if utf16.IsSurrogate(rr) { + rr1 := getu4(s[r:]) + if dec := utf16.DecodeRune(rr, rr1); dec != ReplacementChar { + // A valid pair; consume. + r += 6 + w += utf8.EncodeRune(b[w:], dec) + break + } + // Invalid surrogate; fall back to replacement rune. + rr = ReplacementChar + } + + } + + // Quote, control characters are invalid. + case c == '"', c < ' ': + return + + // ASCII + case c < utf8.RuneSelf: + b[w] = c + r++ + w++ + + // Coerce to well-formed UTF-8. + default: + rr, size := utf8.DecodeRune(s[r:]) + r += size + w += utf8.EncodeRune(b[w:], rr) + } + } + return b[0:w], true +} + +// getu4 decodes \uXXXX from the beginning of s, returning the hex value, +// or it returns -1. +func getu4(s []byte) rune { + if len(s) < 6 || s[0] != '\\' || s[1] != 'u' { + return -1 + } + var r rune + for _, c := range s[2:6] { + switch { + case '0' <= c && c <= '9': + c = c - '0' + case 'a' <= c && c <= 'f': + c = c - 'a' + 10 + case 'A' <= c && c <= 'F': + c = c - 'A' + 10 + default: + return -1 + } + r = r*16 + rune(c) + } + return r +} diff --git a/gno/p/ujson/tables.gno b/gno/p/ujson/tables.gno new file mode 100644 index 0000000000..1ec2db8d91 --- /dev/null +++ b/gno/p/ujson/tables.gno @@ -0,0 +1,216 @@ +package ujson + +import "unicode/utf8" + +var hex = "0123456789abcdef" + +// safeSet holds the value true if the ASCII character with the given array +// position can be represented inside a JSON string without any further +// escaping. +// +// All values are true except for the ASCII control characters (0-31), the +// double quote ("), and the backslash character ("\"). +var safeSet = [utf8.RuneSelf]bool{ + ' ': true, + '!': true, + '"': false, + '#': true, + '$': true, + '%': true, + '&': true, + '\'': true, + '(': true, + ')': true, + '*': true, + '+': true, + ',': true, + '-': true, + '.': true, + '/': true, + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + ':': true, + ';': true, + '<': true, + '=': true, + '>': true, + '?': true, + '@': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'G': true, + 'H': true, + 'I': true, + 'J': true, + 'K': true, + 'L': true, + 'M': true, + 'N': true, + 'O': true, + 'P': true, + 'Q': true, + 'R': true, + 'S': true, + 'T': true, + 'U': true, + 'V': true, + 'W': true, + 'X': true, + 'Y': true, + 'Z': true, + '[': true, + '\\': false, + ']': true, + '^': true, + '_': true, + '`': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + 'g': true, + 'h': true, + 'i': true, + 'j': true, + 'k': true, + 'l': true, + 'm': true, + 'n': true, + 'o': true, + 'p': true, + 'q': true, + 'r': true, + 's': true, + 't': true, + 'u': true, + 'v': true, + 'w': true, + 'x': true, + 'y': true, + 'z': true, + '{': true, + '|': true, + '}': true, + '~': true, + '\u007f': true, +} + +// htmlSafeSet holds the value true if the ASCII character with the given +// array position can be safely represented inside a JSON string, embedded +// inside of HTML