Transaction
7B4CA1BC05C24D…97FFF87F6C03
Block 661,740 · index 0 · indexed
Summary
- Hash
- 7B4CA1BC05C24D065D00B0014D4D6115037C261AEECB46300AFF97FFF87F6C03
- Block
- 661,740
- Size
- 35349 bytes
- Gas used
- 46,980,241 / 80,000,000
- Fee
- 80000 ugnot
- Status
- success
Messages
- Attached funds
- 20000000 ugnot
Arguments · 13
- #1home
- #2content.gno
- #3package home // Default content, written once by init. Everything here stays editable // after deploy: SetProfile, SetBadges and SetPage replace it. func defaultProfile() profileData { return profileData{name: "6h057", tagline: "I turn coffee into code."} } func defaultBadges() []badge { return []badge{ {label: "Samouraï Coop", value: "member", url: "https://www.samourai.world"}, {label: "Greffon", value: "builder", url: "https://www.greffon.io/"}, {label: "ino.cash", value: "builder", url: "https://ino.cash/"}, {label: "ETHDenver 2023", value: "winner", url: "https://ethereumdenver.medium.com/ethdenver-2023-track-and-bounty-winners-1d9cbeda1dc8", row: true}, {label: "GitHub", value: "omarsy", url: "https://github.com/omarsy"}, {label: "X", value: "@omarsysy", url: "https://x.com/omarsysy"}, } } // defaultHome needs base, so init sets base first. func defaultHome() string { return `:banner: :badges: :now: <gno-columns> ## About I work on the GnoVM: gas metering, determinism and correctness fixes. I also build [Greffon](https://www.greffon.io/) and [ino.cash](https://ino.cash/). <gno-columns-sep> ## Work on Gno Contributor to gnolang/gno since April 2024, mostly in the GnoVM. I've also contributed to Go and React. [Highlights](` + base + `:work) </gno-columns> :posts: :waves: :tip: :sign: ` } const defaultWork = `## Work on Gno Contributor to gnolang/gno since April 2024 ([my merged PRs](https://github.com/gnolang/gno/pulls?q=is%3Apr+author%3Aomarsy+is%3Amerged)). Some highlights: | PR | What it did | | --- | --- | | [#3185](https://github.com/gnolang/gno/pull/3185) | Software floating point, so floats are deterministic | | [#4546](https://github.com/gnolang/gno/pull/4546) | Gas for memory allocation | | [#4705](https://github.com/gnolang/gno/pull/4705) | Gas for print and println CPU time | | [#2145](https://github.com/gnolang/gno/pull/2145) | Stack traces in panic messages | | [#5902](https://github.com/gnolang/gno/pull/5902) | Lazy loading of package file blocks | | [#6025](https://github.com/gnolang/gno/pull/6025) | Type-check only production files at AddPackage | ## Beyond Gno ### Greffon [Greffon](https://www.greffon.io/) turns any machine into a greffer: a VPS, a Mac mini, a Windows PC, a Raspberry Pi or a NAS that runs your own apps. You pick from 24 open-source apps (Plausible, Ghost, Nextcloud, Vaultwarden and more), and Greffon handles TLS, routing and config. Your data stays on your hardware, and self-hosting is free with no feature gating. I work across the stack: - [greffer](https://github.com/greffon/greffer): the worker node that deploys each app with Docker behind TLS (Python, FastAPI; AGPL v3). - The manager: the control plane and its web app (AGPL v3, source published at launch). - [greffon-catalog](https://github.com/greffon/greffon-catalog): the app catalog and the CI that validates it (Apache 2.0). ### ino.cash [ino.cash](https://ino.cash/) is an OTC desk for the CFA franc zone. People buy and sell USDT, USDC, BTC and ETH against CFA francs (XOF) with Wave, Orange Money or Pi (BCEAO), in all 8 UEMOA countries, at a rate locked for 10 minutes. Delivery takes under 10 minutes on average, on TRON, Solana, Base, Aptos, Ethereum or Bitcoin, and a white-label API for businesses is in private beta. ### Okay Relay [Okay Relay](https://web.archive.org/web/20250126155102/https://app.buidlbox.io/projects/okay-relay) is cross-chain message relaying as a service, built on Wormhole, so dApps don't have to run their own relayers. It won $4,000 in Moonbeam's "Use a GMP Protocol to Remotely Call a Contract" bounty at [ETHDenver 2023](https://ethereumdenver.medium.com/ethdenver-2023-track-and-bounty-winners-1d9cbeda1dc8). ### Also - Go runtime: [softfloat64 add/sub fix](https://github.com/golang/go/commit/a2fd613c3e3c1b1441047372230169f41419b773) (June 2026). The same fix landed in Gno as [#5818](https://github.com/gnolang/gno/pull/5818). - React: 4 merged PRs to React DevTools in 2020. Iframe support in the browser extension ([#19827](https://github.com/react/react/pull/19827), [#19854](https://github.com/react/react/pull/19854)), symbols shown as state keys ([#19786](https://github.com/react/react/pull/19786)), and a crash fix when inspecting ` + "`document.all`" + ` ([#19619](https://github.com/react/react/pull/19619)). - All in Bits: wallet UI for dither.chat (3 merged PRs, 2025). ` const defaultBlog = `## Blog :blog: ` const defaultGuestbook = `## Guestbook Leave a line. One entry per address. :guestbook: `
- #4gnomod.toml
- #5module = "gno.land/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/home" gno = "0.9"
- #6home.gno
- #7// Package home is a personal home realm. gno.land also shows it as the // profile page at /u/<name>, which always calls Render("") and reads its // links against /u/<name>: every link here is absolute. package home import ( "chain/runtime" "strconv" "strings" "gno.land/p/nt/avl/v0" ) const ( homeTab = "home" workTab = "work" blogTab = "blog" guestbookTab = "guestbook" maxTabs = 8 maxTabName = 20 maxPageBytes = 16 * 1024 // so all pages together stay under 128 KB maxLineRunes = 140 maxBadges = 6 maxSlugBytes = 60 maxTitleRunes = 100 pageSize = 20 latestPosts = 3 seqWidth = 10 errUnauthorized = "home: unauthorized" errMasterKey = "home: needs the master key, not a session key" errOwnerOnly = "home: owner only" errPendingOnly = "home: pending owner only" errInvalidInput = "home: invalid input: " errSigned = "home: already signed" errNoEntry = "home: no such entry" errNoTab = "home: no such tab" errNoPost = "home: no such post" errTabsRequired = "home: home, blog and guestbook tabs are required" errUgnotOnly = "home: send ugnot only" ) type profileData struct { name string tagline string } type badge struct { label string value string url string row bool // starts a new row } type nowLine struct { text string height int64 } type entry struct { msg string height int64 hidden bool } // post dates are UTC days of the block time, "2006-01-02". type post struct { title string body string published string edited string // "" until the first edit seq string // key in postOrder } type tipStats struct { count int total int64 } var ( owner address pendingOwner address // set by TransferOwnership, cleared by AcceptOwnership base string // "/r/<name>/home"; every generated link starts with it profile profileData badges []badge now nowLine tabs []string // tab order: home first, guestbook last pages map[string]string // markdown per tab waves int // wave count; anyone can wave again entries *avl.Tree // address -> *entry order *avl.Tree // zero-padded sequence -> address posts *avl.Tree // slug -> *post postOrder *avl.Tree // zero-padded sequence -> slug postSeq int // last post sequence; never reused tips tipStats updated int64 // block height of the last owner write ) // init records the deployer as owner. Under mainnet's approval flow it runs // as the submitter, not the approver. No AssertOriginCall: init isn't a call. func init(cur realm) { owner = callerOf(cur) base = strings.TrimPrefix(cur.PkgPath(), runtime.ChainDomain()) setDefaults() } // setDefaults writes the initial state (content.gno holds the personal part). func setDefaults() { profile = defaultProfile() badges = defaultBadges() now = nowLine{} pendingOwner = "" tabs = []string{homeTab, workTab, blogTab, guestbookTab} pages = map[string]string{ homeTab: defaultHome(), workTab: defaultWork, blogTab: defaultBlog, guestbookTab: defaultGuestbook, } waves = 0 entries = avl.NewTree() order = avl.NewTree() posts = avl.NewTree() postOrder = avl.NewTree() postSeq = 0 tips = tipStats{} updated = runtime.ChainHeight() } // callerOf is the account guard: a live cur and a direct call from an // account. It must not call AssertOriginCall, which refuses every call from // inside a named helper. func callerOf(cur realm) address { if !cur.IsCurrent() || !cur.Previous().IsUserCall() { panic(errUnauthorized) } return cur.Previous().Address() } // assertOwner is the owner guard. func assertOwner(cur realm) { if callerOf(cur) != owner { panic(errOwnerOnly) } } // Owner returns the current owner. func Owner() address { return owner } // Render shows one tab, or one post at blog/<slug>. An unknown path shows // Home and an unknown post shows Blog. The path is only compared against // tab names or looked up as a slug, and never written into the page. func Render(path string) string { tab, query, _ := strings.Cut(path, "?") if slug, ok := strings.CutPrefix(tab, blogTab+"/"); ok { if p, ok := posts.Get(slug).(*post); ok { return frame("", postMD(p)) // not a tab: no tab is bold } tab = blogTab } if _, ok := pages[tab]; !ok { tab = homeTab } return frame(tab, expand(pages[tab], tab, parsePage(query))) } // frame puts the tab bar above a page and the footer below it. func frame(tab, md string) string { return tabBar(tab) + "\n\n" + md + "\n\n" + footer() } func tabURL(t string) string { if t == homeTab { return base } return base + ":" + t } // tabLabel capitalizes a tab name; names are ASCII by validName. func tabLabel(t string) string { return strings.ToUpper(t[:1]) + t[1:] } // tabBar draws every tab as a linked pill, the current one in brand green. // The current tab is linked too, because gnoweb shows an unlinked image as // a block and the row would break. Blog stays out until there is a post. func tabBar(current string) string { parts := make([]string, 0, len(tabs)) for _, t := range tabs { s := segSoft(tabLabel(t)) switch { case t == current: s = segOn(tabLabel(t)) case t == blogTab && posts.Size() == 0: continue // no posts yet } parts = append(parts, pillLink(tabLabel(t), tabURL(t), tabH, 0, s)) } return strings.Join(parts, " ") } func footer() string { return "---\n\nOwner " + userLink(owner) + " · updated at block " + strconv.FormatInt(updated, 10) + " · [Source](" + base + "$source)\n" } // parsePage reads page=N from the query; anything invalid is page 1. func parsePage(query string) int { for _, kv := range strings.Split(query, "&") { k, v, _ := strings.Cut(kv, "=") if k != "page" { continue } n, err := strconv.Atoi(v) if err != nil || n < 1 { return 1 } return n } return 1 }
- #8widgets.gno
- #9package home import ( "chain/runtime" "encoding/base64" "html" "strconv" "strings" "gno.land/p/nt/markdown/sanitize/v0" ) // Suggested amount on the Tip button (1 GNOT); visitors can change it. const tipSuggestion = "1000000ugnot" // expand replaces every widget token in one pass. strings.Replacer does not // rescan its output, so visitor text inside a widget can never trigger a // token. Only the tokens present in md are built, found with a single scan // of md. func expand(md, tab string, page int) string { words := map[string]bool{} for _, w := range strings.Split(md, ":") { words[w] = true } var pairs []string add := func(tok string, build func() string) { if words[tok[1:len(tok)-1]] { pairs = append(pairs, tok, build()) } } add(":banner:", bannerMD) add(":badges:", badgesMD) add(":now:", nowMD) add(":waves:", wavesMD) add(":tip:", tipMD) add(":sign:", signMD) add(":guestbook:", func() string { if tab != guestbookTab { return "" // a form would target /u/<name> on the profile view } return guestbookMD(page) }) add(":blog:", func() string { if tab != blogTab { return "" // its page links lead to the Blog tab } return blogMD(page) }) add(":posts:", postsMD) add(":height:", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) }) add(":owner:", func() string { return owner.String() }) if len(pairs) == 0 { return md } return strings.NewReplacer(pairs...).Replace(md) } func itoa(n int) string { return strconv.Itoa(n) } // plural renders "1 wave", "2 waves". func plural(n int, word string) string { if n == 1 { return "1 " + word } return itoa(n) + " " + word + "s" } // svgImage embeds an SVG as a base64 data URI, the one inline image form // gnoweb keeps. func svgImage(alt, svg string) string { return ") + ")" } // ----- colors ----- // gno.land's own greens, from gnoweb's palette. An image can't follow // gnoweb's theme toggle, so each one reads on both its white and its // near-black background. const ( colBrand = "#226C57" // gnoweb's links and buttons colDeep = "#144134" // banner background, text on colSoft colAccent = "#60AB96" // banner accents, pill outlines colSoft = "#E7EFED" // pale surface colGhost = "#F0F0F0" fontUI = "system-ui, -apple-system, Segoe UI, Helvetica, Arial, sans-serif" ) // ----- banner ----- func bannerMD() string { return svgImage(profile.name+" banner", bannerSVG(runtime.ChainHeight())) } // bannerSVG draws the 720x160 banner: the ghost, a coffee cup, the name and // the tagline. The face depends on height % 4: calm, happy, wink, surprised. func bannerSVG(height int64) string { var sb strings.Builder sb.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="720" height="160" viewBox="0 0 720 160">`) sb.WriteString(`<rect width="720" height="160" rx="16" fill="` + colDeep + `"/>`) sb.WriteString(`<path d="M60 140 V72 A50 50 0 0 1 160 72 V140 Q147.5 124 135 140 Q122.5 124 110 140 Q97.5 124 85 140 Q72.5 124 60 140 Z" fill="` + colGhost + `"/>`) switch height % 4 { case 0: // calm sb.WriteString(`<ellipse cx="92" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`) sb.WriteString(`<ellipse cx="128" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`) sb.WriteString(`<path d="M100 100 Q110 106 120 100" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`) case 1: // happy sb.WriteString(`<path d="M84 78 Q92 66 100 78 M120 78 Q128 66 136 78" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`) sb.WriteString(`<path d="M96 96 Q110 116 124 96 Z" fill="` + colDeep + `"/>`) case 2: // wink sb.WriteString(`<ellipse cx="92" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`) sb.WriteString(`<path d="M120 76 H136" stroke="` + colDeep + `" stroke-width="4" stroke-linecap="round"/>`) sb.WriteString(`<path d="M98 98 Q110 110 122 98" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`) default: // surprised sb.WriteString(`<circle cx="92" cy="72" r="8" fill="` + colDeep + `"/>`) sb.WriteString(`<circle cx="128" cy="72" r="8" fill="` + colDeep + `"/>`) sb.WriteString(`<ellipse cx="110" cy="104" rx="7" ry="9" fill="` + colDeep + `"/>`) } // Coffee cup with steam: "I turn coffee into code". sb.WriteString(`<rect x="180" y="108" width="30" height="30" rx="5" fill="` + colAccent + `"/>`) sb.WriteString(`<path d="M210 114 h6 a7 7 0 0 1 0 14 h-6" stroke="` + colAccent + `" stroke-width="4" fill="none"/>`) sb.WriteString(`<path d="M188 100 q-5 -7 0 -14 M200 100 q-5 -7 0 -14" stroke="` + colAccent + `" stroke-width="3" fill="none" stroke-linecap="round"/>`) sb.WriteString(`<text x="260" y="78" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="` + fontSize(profile.name, 44, 26) + `" font-weight="700" fill="` + colGhost + `">` + html.EscapeString(profile.name) + `</text>`) sb.WriteString(`<text x="260" y="116" font-family="` + fontUI + `" font-size="` + fontSize(profile.tagline, 22, 12) + `" fill="` + colAccent + `">` + html.EscapeString(profile.tagline) + `</text>`) sb.WriteString(`</svg>`) return sb.String() } // fontSize shrinks size so text fits the 440 px between x=260 and the // banner's right edge. perChar is the rough glyph width at that size; runes // from U+1100 on (CJK, emoji and the like) count double. Scaling the font, // rather than setting textLength, never stretches text that already fits. func fontSize(s string, size, perChar int) string { w := 0 for _, r := range s { w += perChar if r >= 0x1100 { w += perChar } } if w > 440 { size = size * 440 / w } return itoa(size) } // ----- pills: tabs, badges, buttons ----- // Pills come in two heights: tabs, and chips (badges and buttons). gnoweb // draws a 16 px icon on a linked image, 8 px from its left edge: images in // a row get an 8 px margin, and the icon's center sits that far above the // image's bottom edge. It lands on the center line of a chip about 16 px // high, so chips are 17 px, with iconGap pixels left clear for it. // External and $help links get the icon; tabs and the guestbook button // link inside the realm's own namespace and don't. const ( tabH = 26 chipH = 17 iconGap = 30 ) // seg is one colored part of a pill. type seg struct{ text, fill, ink string } func segOn(s string) seg { return seg{s, colBrand, "#fff"} } func segSoft(s string) seg { return seg{s, colSoft, colDeep} } // pillSVG draws a pill h pixels high from segments, left to right, with // gap empty pixels before the first text. SVG can't measure text before // drawing, so widths come from the rune count, wide runes (U+1100 on) // counting double. The outline gives the pale parts an edge on a white // page. func pillSVG(h, gap int, segs ...seg) string { font, char := 11, 7 if h == tabH { font, char = 13, 8 } widths := make([]int, len(segs)) w := gap for i, s := range segs { widths[i] = h // half a pill's height of padding on each side for _, r := range s.text { widths[i] += char if r >= 0x1100 { widths[i] += char } } w += widths[i] } mid := half(h) var sb strings.Builder sb.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="` + itoa(w) + `" height="` + itoa(h) + `" viewBox="0 0 ` + itoa(w) + ` ` + itoa(h) + `">`) sb.WriteString(`<clipPath id="c"><rect width="` + itoa(w) + `" height="` + itoa(h) + `" rx="` + mid + `"/></clipPath>`) sb.WriteString(`<g clip-path="url(#c)" font-family="` + fontUI + `" font-size="` + itoa(font) + `" font-weight="600" text-anchor="middle" dominant-baseline="central">`) x := 0 for i, s := range segs { pad := 0 if i == 0 { pad = gap } sb.WriteString(`<rect x="` + itoa(x) + `" width="` + itoa(pad+widths[i]) + `" height="` + itoa(h) + `" fill="` + s.fill + `"/>`) sb.WriteString(`<text x="` + itoa(x+pad+widths[i]/2) + `" y="` + mid + `" fill="` + s.ink + `">` + html.EscapeString(s.text) + `</text>`) x += pad + widths[i] } sb.WriteString(`</g><rect x=".5" y=".5" width="` + itoa(w-1) + `" height="` + itoa(h-1) + `" rx="` + half(h-1) + `" fill="none" stroke="` + colAccent + `"/></svg>`) return sb.String() } // half writes n/2 with its .5, for SVG coordinates. func half(n int) string { if n%2 == 1 { return itoa(n/2) + ".5" } return itoa(n / 2) } // pillLink is a pill image linked to url, with alt text for screen readers. func pillLink(alt, url string, h, gap int, segs ...seg) string { return "[" + svgImage(alt, pillSVG(h, gap, segs...)) + "](" + url + ")" } // badgesMD draws the badges as one paragraph. A new row starts after a // hard line break (a backslash ending the line), so rows sit as close as // wrapped ones. func badgesMD() string { var sb strings.Builder for i, b := range badges { switch { case i > 0 && b.row: sb.WriteString("\\\n") case i > 0: sb.WriteString(" ") } sb.WriteString(pillLink(b.label+": "+b.value, b.url, chipH, iconGap, segSoft(b.label), segOn(b.value))) } return sb.String() } // ----- now, buttons ----- func nowMD() string { if now.text == "" { return "" } return "> [!SUCCESS] Now\n> " + now.text + " (block " + strconv.FormatInt(now.height, 10) + ")" } // button is a linked chip: the action, then its count. $help links get // gnoweb's transaction icon, hence the gap. func button(action, count, url string, gap int) string { return pillLink(action+", "+count, url, chipH, gap, segOn(action), segSoft(count)) } func wavesMD() string { return button("Wave", plural(waves, "wave"), base+"$help&func=Wave", iconGap) } func tipMD() string { return button("Tip 1 GNOT", plural(tips.count, "tip"), base+"$help&func=Tip&.send="+tipSuggestion, iconGap) } // signMD counts hidden entries too: their authors did sign. func signMD() string { return button("Sign the guestbook", plural(order.Size(), "signature"), tabURL(guestbookTab), 0) } // shortAddr keeps the ends of an address: g1jg8mtu…sqf5. func shortAddr(a address) string { s := a.String() if len(s) < 16 { return s } return s[:8] + "…" + s[len(s)-4:] } // userLink links an address to its profile page under its short form. The // address comes from the chain, never from visitor text. func userLink(a address) string { return "[" + shortAddr(a) + "](/u/" + a.String() + ")" } // ----- paging ----- // clampPage returns the page to show, at most the last one, and the last // page for total items. func clampPage(page, total int) (int, int) { last := (total + pageSize - 1) / pageSize if page > last { page = last } return page, last } // pageNav links the pages around page on tab: Newer, then Older. func pageNav(tab string, page, last int) string { var nav []string if page > 1 { nav = append(nav, "[Newer]("+tabURL(tab)+"?page="+itoa(page-1)+")") } if page < last { nav = append(nav, "[Older]("+tabURL(tab)+"?page="+itoa(page+1)+")") } if len(nav) == 0 { return "" } return "\n" + strings.Join(nav, " · ") + "\n" } // ----- guestbook ----- const signForm = `<gno-form exec="Sign"> <gno-input name="msg" type="text" placeholder="Say hi in 140 characters" required="true" /> </gno-form>` // visitorText makes a stored message safe as one markdown list item. // sanitize.InlineText escapes links, images, HTML and emphasis; the escaped // @ and the g entity for the g of g1 keep gnoweb from turning "@name" // or a typed g1 address into a profile link. Neither replacement produces // the other's pattern, so the order doesn't matter. func visitorText(msg string) string { s := strings.ReplaceAll(sanitize.InlineText(msg), "@", `\@`) return strings.ReplaceAll(s, "g1", "g1") } // guestbookMD shows the form and one page of entries, newest first. Hidden // entries still count toward the offset, so a page can show fewer than 20. func guestbookMD(page int) string { var sb strings.Builder sb.WriteString(signForm) sb.WriteString("\n\n") total := order.Size() if total == 0 { sb.WriteString("No entries yet.\n") return sb.String() } page, last := clampPage(page, total) order.ReverseIterateByOffset((page-1)*pageSize, pageSize, func(_ string, v any) bool { addr, _ := v.(address) e, ok := entries.Get(addr.String()).(*entry) if ok && !e.hidden { // Render never panics, even on a broken invariant sb.WriteString("- " + userLink(addr) + ": " + visitorText(e.msg) + "\n") } return false }) sb.WriteString(pageNav(guestbookTab, page, last)) return sb.String() } // ----- blog ----- // postList lists count posts from offset, newest first: one item each with // the publish date as a code chip and the linked title. func postList(offset, count int) string { var sb strings.Builder postOrder.ReverseIterateByOffset(offset, count, func(_ string, v any) bool { slug, _ := v.(string) if p, ok := posts.Get(slug).(*post); ok { // Render never panics, even on a broken invariant sb.WriteString("- `" + p.published + "` [" + sanitize.InlineText(p.title) + "](" + tabURL(blogTab) + "/" + slug + ")\n") } return false }) return sb.String() } // blogMD lists one page of posts. func blogMD(page int) string { total := postOrder.Size() if total == 0 { return "No posts yet.\n" } page, last := clampPage(page, total) return postList((page-1)*pageSize, pageSize) + pageNav(blogTab, page, last) } // postsMD is the Latest posts section, or nothing while there are no posts, // so no empty heading shows. func postsMD() string { if postOrder.Size() == 0 { return "" } return "## Latest posts\n\n" + postList(0, latestPosts) + "\n[All posts](" + tabURL(blogTab) + ")\n" } // postMD is a post's page. The body is shown as written: no tokens. func postMD(p *post) string { date := p.published if p.edited != "" && p.edited != p.published { date += " · edited " + p.edited } return "# " + sanitize.InlineText(p.title) + "\n\n*" + date + "*\n\n" + p.body + "\n\n[All posts](" + tabURL(blogTab) + ")" }
- #10writes.gno
- #11package home import ( "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" "time" "unicode" "unicode/utf8" ) // Every write function calls runtime.AssertOriginCall() first, in its own // body: from inside a helper it refuses every call. const maxSignBytes = 560 func invalid(field string) string { return errInvalidInput + field } // checkLine enforces valid UTF-8, a rune count in [min, max], and no control // characters (so no newline either): the text may end up inside an SVG, // where they would break the XML. func checkLine(field, s string, min, max int) { if !utf8.ValidString(s) { panic(invalid(field)) } n := 0 for _, r := range s { if r < 0x20 || r == 0x7f || r == 0xfffe || r == 0xffff { panic(invalid(field)) } n++ } if n < min || n > max { panic(invalid(field)) } } // assertMasterKey refuses a transaction signed by a session key. Delegated // keys may edit content, but not move ownership or hide an entry for good. func assertMasterKey() { if _, _, _, isSession := runtime.GetSessionInfo(); isSession { panic(errMasterKey) } } // checkBadgeURL wants a printable-ASCII https:// URL (non-ASCII must be // percent-encoded) with no quotes, backslashes, backticks, parentheses, // square brackets, < or >, so the markdown link can't end early or become // javascript:. func checkBadgeURL(u string) { if !strings.HasPrefix(u, "https://") { panic(invalid("url")) } for i := 0; i < len(u); i++ { c := u[i] if c <= 0x20 || c >= 0x7f || strings.IndexByte("\"'\\`()[]<>", c) >= 0 { panic(invalid("url")) } } } // parseBadges reads one badge per line, "label | value | url". Blank lines // between two badges start a new row; other blank lines are skipped. func parseBadges(spec string) []badge { var out []badge row := false for _, line := range strings.Split(spec, "\n") { line = strings.TrimSpace(line) if line == "" { row = len(out) > 0 continue } parts := strings.Split(line, "|") if len(parts) != 3 || len(out) == maxBadges { panic(invalid("spec")) } b := badge{ label: strings.TrimSpace(parts[0]), value: strings.TrimSpace(parts[1]), url: strings.TrimSpace(parts[2]), row: row, } row = false checkLine("label", b.label, 1, 24) checkLine("value", b.value, 1, 24) checkBadgeURL(b.url) out = append(out, b) } return out } // hasVisible reports whether s has a character that draws something: not a // space, a format character (zero-width, bidi), a combining mark, or one of // the blank-looking fillers (Hangul fillers, braille blank). func hasVisible(s string) bool { for _, r := range s { switch { case unicode.IsSpace(r), unicode.In(r, unicode.Cf, unicode.Mn, unicode.Me): case r == 0x115f, r == 0x1160, r == 0x3164, r == 0xffa0, r == 0x2800: default: return true } } return false } // validName wants 1 to max characters from a-z, 0-9 and "-": safe in a // link, a tab label, and a path gnoweb passes to Render. func validName(s string, max int) bool { if len(s) < 1 || len(s) > max { return false } for _, c := range s { if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { return false } } return true } // checkPage enforces the tab name, the page size, and the tab count. func checkPage(tab, md string) { if !validName(tab, maxTabName) { panic(invalid("tab")) } if len(md) > maxPageBytes || !utf8.ValidString(md) { panic(invalid("md")) } if _, exists := pages[tab]; !exists && len(tabs) >= maxTabs { panic(invalid("tab")) } } // checkPost enforces the slug, a one-line title, and the body size. Title // and body must each show something. func checkPost(slug, title, body string) { if !validName(slug, maxSlugBytes) { panic(invalid("slug")) } checkLine("title", title, 1, maxTitleRunes) if !hasVisible(title) { panic(invalid("title")) } if len(body) > maxPageBytes || !utf8.ValidString(body) || !hasVisible(body) { panic(invalid("body")) } } // seqKey zero-pads n so that keys sort in number order. func seqKey(n int) string { s := strconv.Itoa(n) return strings.Repeat("0", seqWidth-len(s)) + s } func touch() { updated = runtime.ChainHeight() } // ----- owner only ----- // SetNow sets the one-line status shown on Home; empty clears it. func SetNow(cur realm, text string) { runtime.AssertOriginCall() assertOwner(cur) checkLine("text", text, 0, maxLineRunes) now = nowLine{text: text, height: runtime.ChainHeight()} touch() chain.Emit("NowSet", "text", text) } // SetProfile sets the name and tagline drawn on the banner. func SetProfile(cur realm, name, tagline string) { runtime.AssertOriginCall() assertOwner(cur) checkLine("name", name, 1, 24) checkLine("tagline", tagline, 0, 60) profile = profileData{name: name, tagline: tagline} touch() chain.Emit("ProfileSet", "name", name, "tagline", tagline) } // SetBadges replaces every badge. One badge per line: "label | value | url"; // a blank line starts a new row. func SetBadges(cur realm, spec string) { runtime.AssertOriginCall() assertOwner(cur) badges = parseBadges(spec) touch() chain.Emit("BadgesSet", "count", strconv.Itoa(len(badges))) } // SetPage creates or replaces any tab's markdown, Blog and Guestbook // included. A new tab goes just before Guestbook, which stays last. func SetPage(cur realm, tab, md string) { runtime.AssertOriginCall() assertOwner(cur) checkPage(tab, md) if _, exists := pages[tab]; !exists { tabs = append(tabs[:len(tabs)-1], tab, guestbookTab) // guestbook stays last } pages[tab] = md touch() chain.Emit("PageSet", "tab", tab) } // RemovePage removes any tab except home, blog and guestbook. func RemovePage(cur realm, tab string) { runtime.AssertOriginCall() assertOwner(cur) if tab == homeTab || tab == blogTab || tab == guestbookTab { panic(errTabsRequired) } if _, ok := pages[tab]; !ok { panic(errNoTab) } delete(pages, tab) kept := make([]string, 0, len(tabs)-1) for _, t := range tabs { if t != tab { kept = append(kept, t) } } tabs = kept touch() chain.Emit("PageRemoved", "tab", tab) } // SetPost publishes a post, or edits the post at slug. An edit keeps the // post's place in the list and its publish date. func SetPost(cur realm, slug, title, body string) { runtime.AssertOriginCall() assertOwner(cur) checkPost(slug, title, body) today := time.Now().UTC().Format("2006-01-02") touch() if p, ok := posts.Get(slug).(*post); ok { p.title, p.body, p.edited = title, body, today chain.Emit("PostEdited", "slug", slug) return } postSeq++ seq := seqKey(postSeq) posts.Set(slug, &post{title: title, body: body, published: today, seq: seq}) postOrder.Set(seq, slug) chain.Emit("PostPublished", "slug", slug) } // RemovePost deletes a post for good; its slug can be used again. func RemovePost(cur realm, slug string) { runtime.AssertOriginCall() assertOwner(cur) v, ok := posts.Remove(slug) if !ok { panic(errNoPost) } postOrder.Remove(v.(*post).seq) touch() chain.Emit("PostRemoved", "slug", slug) } // Hide hides an author's guestbook entry for good. The entry stays stored, // so the author can't sign again, and nobody's deposit is refunded. Hiding // a hidden entry does nothing. func Hide(cur realm, author address) { runtime.AssertOriginCall() assertOwner(cur) assertMasterKey() e, ok := entries.Get(author.String()).(*entry) if !ok { panic(errNoEntry) } if e.hidden { return } e.hidden = true touch() chain.Emit("EntryHidden", "author", author.String()) } // TransferOwnership records newOwner as pending; nothing changes until that // address calls AcceptOwnership. Naming the current owner cancels. func TransferOwnership(cur realm, newOwner address) { runtime.AssertOriginCall() assertOwner(cur) assertMasterKey() if !newOwner.IsValid() { panic(invalid("newOwner")) } touch() if newOwner == owner { pendingOwner = "" chain.Emit("OwnershipTransferCanceled", "owner", owner.String()) return } pendingOwner = newOwner chain.Emit("OwnershipTransferStarted", "from", owner.String(), "to", newOwner.String()) } // AcceptOwnership completes a transfer. Only the pending owner may call it, // and a realm address can't pass the account guard, so it never can. func AcceptOwnership(cur realm) { runtime.AssertOriginCall() caller := callerOf(cur) if pendingOwner == "" || caller != pendingOwner { panic(errPendingOnly) } prev := owner owner = caller pendingOwner = "" touch() chain.Emit("OwnershipTransferred", "from", prev.String(), "to", caller.String()) } // ----- visitors ----- // Wave adds one to the wave count. It's a plain counter: anyone can wave // again, and a wave stores nothing but the count. func Wave(cur realm) { runtime.AssertOriginCall() from := callerOf(cur).String() waves++ chain.Emit("Waved", "from", from) } // Sign leaves one guestbook entry per address. The message is stored trimmed // and sanitized when rendered, and must contain a visible character. func Sign(cur realm, msg string) { runtime.AssertOriginCall() caller := callerOf(cur) if len(msg) > maxSignBytes { panic(invalid("msg")) } msg = strings.TrimSpace(msg) checkLine("msg", msg, 1, maxLineRunes) if !hasVisible(msg) { panic(invalid("msg")) // the entry would show up blank } key := caller.String() if entries.Has(key) { panic(errSigned) } entries.Set(key, &entry{msg: msg, height: runtime.ChainHeight()}) order.Set(seqKey(order.Size()+1), caller) chain.Emit("Signed", "from", key) } // Tip forwards exactly the ugnot sent with this call to the owner. The // account guard is inlined so the audit harness sees it next to OriginSend. // It forwards what was sent, never the realm's balance: the OriginSend // banker refuses to spend more than was sent. func Tip(cur realm) { runtime.AssertOriginCall() if !cur.IsCurrent() || !cur.Previous().IsUserCall() { panic(errUnauthorized) } sent := unsafe.OriginSend() if len(sent) != 1 || sent[0].Denom != "ugnot" || sent[0].Amount <= 0 { panic(errUgnotOnly) } banker.NewBanker(banker.BankerTypeOriginSend, cur).SendCoins(cur.Address(), owner, sent) tips.count++ tips.total += sent[0].Amount chain.Emit("Tipped", "from", cur.Previous().Address().String(), "amount", strconv.FormatInt(sent[0].Amount, 10)) }
- #12/gno.MemPackageType
- #13 MPUserAll
Result log
msg:0,success:true,log:,events:[]