Transaction
05EDEDE43FEE8D…64346B63A984
Block 271,004 · index 0 · indexed
Summary
- Hash
- 05EDEDE43FEE8DE45B9FBEC9B49CCFCB15621F2332D6F13931E164346B63A984
- Block
- 271,004
- Size
- 41999 bytes
- Gas used
- 55,422,216 / 150,000,000
- Fee
- 180000 ugnot
- Status
- success
Messages
- Attached funds
- 50000000 ugnot
Arguments · 17
- #1home
- #2banner.gno
- #3package home import "encoding/base64" // This file is shared, unchanged, by every Samouraï home realm, so the pages // keep one visual identity. What differs lives in content.gno. // toriiMask is the Samouraï Coop mark, redrawn as vectors: a torii gate cut // out of a full circle, in a 480x480 box. Everything painted through the mask // takes the shape of the mark. const toriiMask = `<mask id="torii"><circle cx="238" cy="240" r="240" fill="#fff"/><g fill="#000">` + `<path d="M-10 90 Q238 164 490 90 L490 140 Q238 190 -10 140 Z"/>` + `<path d="M102 140 L165 140 L162 206 L96 206 Z"/>` + `<path d="M302 140 L364 140 L370 206 L304 206 Z"/>` + `<rect x="-10" y="205" width="500" height="41"/>` + `<path d="M89 245 L159 245 L145 490 L55 490 Z"/>` + `<rect x="-10" y="274" width="100" height="32"/>` + `<path d="M307 245 L377 245 L411 490 L321 490 Z"/>` + `<path d="M382 296 L490 398 L490 455 L390 358 Z"/>` + `</g></mask>` type heroText struct { kicker string // small red caps line above the title title string subtitle string tagline string // red monospace line footline string // grey monospace line alt string } // heroSVG draws the page banner: the mark set in a red sun on the right, the // text stack on the left. func heroSVG(h heroText) string { return `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="300" viewBox="0 0 960 300">` + `<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0e0e0e"/><stop offset="1" stop-color="#2a0c10"/></linearGradient>` + toriiMask + `</defs>` + `<rect width="960" height="300" rx="18" fill="url(#bg)"/>` + `<g stroke="#ffffff" stroke-opacity=".05"><path d="M0 60H960M0 120H960M0 180H960M0 240H960"/></g>` + `<circle cx="790" cy="150" r="128" fill="#e63946"/>` + `<g transform="translate(686 46) scale(.4333)"><rect width="480" height="480" fill="#f4efe6" mask="url(#torii)"/></g>` + `<text x="56" y="78" font-family="ui-monospace,Menlo,monospace" font-size="13" letter-spacing="3" fill="#e63946">` + xmlText(h.kicker) + `</text>` + `<text x="52" y="160" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-weight="800" font-size="76" fill="#ffffff">` + xmlText(h.title) + `</text>` + `<text x="56" y="200" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-size="22" fill="#e8e2d8">` + xmlText(h.subtitle) + `</text>` + `<text x="56" y="236" font-family="ui-monospace,Menlo,monospace" font-size="16" fill="#e63946">` + xmlText(h.tagline) + `</text>` + `<text x="56" y="266" font-family="ui-monospace,Menlo,monospace" font-size="12" fill="#8a8a8a">` + xmlText(h.footline) + `</text>` + `</svg>` } func heroImage() string { return ")) + ")" }
- #4content.gno
- #5package home // defaultSlots and defaultGalleries seed the page at deploy time. After that // the chain is the source of truth: edit with Set and SetGallery, not by // redeploying. const home = "/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/home" const memba = "https://memba.samourai.app/mainnet" var hero = heroText{ kicker: "SAMOURAÏ COOP · GNO.LAND · PARIS", title: "zôÖma", subtitle: "Founder, CEO & CTO of Samouraï Coop", tagline: "<3 is the encryption key.", footline: "film crew · engineering studio · gno.land core contributors", alt: "zôÖma, Founder, CEO & CTO of Samouraï Coop", } var defaultSlots = []struct{ slug, body string }{ {"layout", `:hero: <gno-columns>  <gno-columns-sep /> :bio: :social: </gno-columns> ## 今 Now :now: ## 道 Four years in the gno.land core :core: ## 源 Open source :oss: :gallery.oss: ## 作 Products :products: :gallery.products: ## 組 Inside Memba :memba: :gallery.memba: ## 録 gno.land on film :gnofilm: :gallery.gno: ## 祭 Events :events: :gallery.events: ## 報 Contribution reports :reports: :gallery.reports: ## 学 Peer Dev: gno.land, taught on video :peerdev: :gallery.peerdev: ## 旅 WEB TRIP :webtrip: :gallery.webtrip: ## 映 Films by the crew :films: :gallery.films: ## 数 Numbers :numbers: ## ♥ <3 is the encryption key :hearts: --- *This page is a contract.* ` + "`:realm:`" + ` · rev :rev: · block :height: on :chainid: · every poster on it is drawn on chain · [slots](` + home + `:slots) `}, {"bio", `**Founder, CEO & CTO of [Samouraï Coop](https://www.samourai.world)**, a worker cooperative from Paris that is a film crew and an engineering studio in one company, building and promoting open-source culture since 2016. Co-founder of the [Paris P2P Festival](https://p2p.samourai.tv). For more than 12 years I've worked across the peer-to-peer, cybersecurity and open-source ecosystems, researching and building decentralized organizations. Alongside the coop, I lead **samcrew**, our gno.land team: core contributors to GnoVM and the gno.land chain, and one of its genesis validators. I also build [Memba](https://memba.samourai.app).`}, {"social", `[GitHub](https://github.com/zxxma) · [samourai.world](https://www.samourai.world) · [Samouraï TV](https://www.youtube.com/channel/UC0gExSSzyU8gTqUjoYerI8A) · [X](https://www.twitter.com/samouraicoop) · [the crew](/u/samcrew)`}, {"now", `- **Holding the line.** samcrew runs one of the four validators securing ` + "`gnoland-1`" + `, live since mainnet launch on 12 September 2026. - **Memba, on mainnet.** 14 samcrew packages are live, from the [App Store](/r/samcrew/memba_appstore_v3) to [escrow](/r/samcrew/escrow_v3) and an [arcade leaderboard](/r/samcrew/memba_arcade_leaderboard_v1). - **Filming the chain.** The crew keeps documenting gno.land: retreats, events, builders.`}, {"core", `Samouraï Coop has contributed to the gno.land core for more than four years. The crew has **[256 merged pull requests in gnolang/gno](https://github.com/gnolang/gno/pulls?q=is%3Apr+is%3Amerged+author%3Aomarsy+author%3AVillaquiranm+author%3Amvallenet+author%3Adavd-gzl+author%3An0izn0iz+author%3Ahthieu1110+author%3Amoonia+author%3AWaDadidou+author%3Aomniwired+author%3AAmozPay)**, about one in eleven of every PR ever merged there, and more each year: 48 in 2024, 79 in 2025, 128 so far in 2026. | | what we do | | --- | --- | | **GnoVM** | The interpreter, its type checker, stdlibs and test suite: most of our upstream work. | | **Security** | Hardening the VM, consensus and p2p against crashes, overflows and malformed input. | | **Chain & tooling** | gno.land node, tm2, genesis tools, ` + "`gnodev`" + `, docs. | | **Validators** | A genesis validator, [gnomonitoring](https://github.com/samouraiworld/gnomonitoring) and the [katana](https://github.com/samouraiworld/katana) GovDAO daemon. | | **Governance research** | Our [research lab](https://samouraiworld.github.io/gov-research-wiki/) studies how cooperatives, DAOs and open-source projects govern themselves. [gnodaokit](https://github.com/samouraiworld/gnodaokit) and Memba are its output. | | **Design partner** | We build our own products on gno.land and send what they teach us back upstream. | | **Documentation** | Retreat and event reports for the core team, the Peer Dev tutorials, the WEB TRIP interviews. |`}, {"oss", `Everything we can open, we open. Our public gno repositories are on [GitHub](https://github.com/samouraiworld); the whole team page is at [/u/samcrew](/u/samcrew).`}, {"gnofilm", `The crew films gno.land from the inside: brand films, core-team retreats, portraits of the teams building on it.`}, {"products", `Products built and run by the coop, closest to gno.land first.`}, {"memba", `[Memba](https://memba.samourai.app) is an experimental operating system for decentralised organisations, and the direct output of our governance research. It is MIT-licensed and [open source](https://github.com/samouraiworld/memba). Each of its parts is a product in its own right. Hover a card for details. Every game uses one daily seed for everyone, drawn from a gno.land block, and needs no wallet.`}, {"events", `We produce the events we believe in, and we film the rest of the ecosystem's. **Paris P2P Festival**: five days of peer-to-peer at Ground Control, free, for 5,000 people. The Édition #1 talks are [all online](https://p2p.samourai.tv). **Hyper Hacktive Festival**: a film festival for independent documentaries funded by Samouraï Grants. It has had three editions, two at mk2 Quai de Seine, and sells its tickets on Zenao.`}, {"reports", `Each year, one film shows everything the coop shipped.`}, {"peerdev", `A 17-lesson curriculum, produced end to end by the coop, that takes a developer from an empty machine to GovDAO and the interrealm spec. [Watch the playlist](https://www.youtube.com/playlist?list=PLJZrQikyfMc-kBojXgAojOz4UQPuq4DiY).`}, {"webtrip", `Eight conversations with gno.land and web3 builders, filmed by the crew in 2024.`}, {"films", `40 festival selections, 3 wins. [The Underground Sistine Chapel](https://www.youtube.com/watch?v=fsRFHmdiyGE) took Best Documentary at the London Independent Film Awards and was released free on both IPFS and YouTube.`}, {"numbers", `| since day one | | | --- | ---: | | Crew PRs merged in gnolang/gno | 256 | | My merged pull requests, all repos | 2,941 | | …of which into Memba | 1,018 | | samcrew packages live on ` + "`gnoland-1`" + ` | 14 | | Projects delivered by the coop since 2016 | 500+ | | Festival selections · wins | 40 · 3 | | Peer Dev tutorials | 17 | | Contributing on Gno since | 2022 | *Counted from GitHub, the chain and samourai.world on 23 September 2026.*`}, } const ( yt = "https://www.youtube.com/watch?v=" gh = "https://github.com/samouraiworld/" ) var defaultGalleries = []struct{ name, spec string }{ {"gno", ` gno | Gno: The Power To Build What Matters | gno.land · brand film | ` + yt + `M920PO3yrHk gno | Alignment Retreat, Turin | gno.land core team · retreat report | ` + yt + `3czMK3s30KQ gno | Inside Onbloc | gno.land · team portrait · Nov 2024 | ` + yt + `7umxHYDJ4Bk `}, {"memba", ` memba | Multisig Wallet | For organizations · Adena & gnokey | ` + memba + `/multisig | Create or import a multisig, propose sends and contract calls, collect signatures from Adena or gnokey, then broadcast. memba | DAO Management | Create, vote, execute · GovDAO | ` + memba + `/dao | Browse GovDAO and community DAOs, vote, execute, create your own and talk in DAO channels. memba | Validator Monitoring | Reliability scores · hacker view | ` + memba + `/validators | Every validator scored for reliability, plus a live consensus hacker view. memba | Alerting | GovDAO & validators · 3 channels | ` + memba + `/alerts | GovDAO and validator alerts on Discord, Slack and Telegram. memba | On-chain App Store | Registry · submissions · curation | ` + memba + `/apps | The ecosystem's apps, with an on-chain registry, submissions and curation. memba | Directory | DAOs · realms · packages · users | ` + memba + `/directory | Search DAOs, realms, packages, tokens and users, with source and render side by side. memba | Dev Reports | gnolove scoreboard · weekly reports | ` + memba + `/gnolove | The gnolove contributor scoreboard, weekly reports and team analytics. memba | Token Launcher | GRC20 · browse & launch | ` + memba + `/tokens | Browse GRC20 tokens and launch your own. memba | Social Feed | On-chain posts · open moderation | ` + memba + `/feed | An on-chain social feed with a public moderation log. memba | Quests & Badges | GnoBuilders · 85 quests · 8 ranks | ` + memba + `/quests | GnoBuilders: 85 quests, 8 ranks, a leaderboard and on-chain badges. game | Space Invaders | Daily or free play · no wallet | ` + memba + `/game/space-invaders | Marching waves, combos, a mystery UFO and destructible bunkers. Daily or free play, keyboard or touch. game | Block Party | Daily 4×4 merge · seeded by a block | ` + memba + `/game | A daily tile-merge puzzle. Everyone gets the same board, seeded from a real gno.land block. Streaks and a daily leaderboard. game | BARRICADE | 90-second lane defence · 8 waves | ` + memba + `/game/barricade | Hold the barricade against The Order for 8 waves up to the Broadcast Tower, then an endless siege. Runs can be certified on chain. `}, {"oss", ` oss | memba | Multisig & DAO governance for gno.land | ` + gh + `memba oss | gnolove | The gno.land contributor scoreboard | ` + gh + `gnolove oss | zenao | Event ticketing & communities | ` + gh + `zenao oss | gnodaokit | A framework for DAOs in Gno | ` + gh + `gnodaokit oss | gnomonitoring | Monitoring for gno validators | ` + gh + `gnomonitoring oss | katana | GovDAO validator-ejection daemon | ` + gh + `katana oss | gno-validator-tools | Validators & sentries as code | ` + gh + `gno-validator-tools oss | gov-research-wiki | The governance research lab | ` + gh + `gov-research-wiki oss | peerdev | The Peer Dev channel, in the open | ` + gh + `peerdev oss | validator-diagnostics | The validator fire-drill portal | ` + gh + `validator-diagnostics oss | gno-onboarding-bot | Validator onboarding, on Discord | ` + gh + `gno-onboarding-bot oss | gno-govdao-activity-parser | GovDAO voter activity & test env | ` + gh + `gno-govdao-activity-parser `}, {"products", ` product | Memba | Organizations OS for gno.land | https://memba.samourai.app product | Zenao | Event ticketing & community coordination | https://zenao.io product | gnolove | Who builds gno.land, ranked | https://gnolove.world product | Visio | Free video meetings, no account | https://visio.samourai.app product | Greffon | Self-host sovereign apps in one click | https://greffon.io product | Reportz | The digital commons, live from GitHub | https://reportz.dev `}, {"events", ` festival | Paris P2P Festival | Co-founded by zôÖma · Ground Control | ` + yt + `PQ9I73tJobU festival | Hyper Hacktive Festival | 3 editions · mk2 Quai de Seine | https://hyperhacktive.io event | Web3 Summit | Web3 Foundation · Berlin 2024-25 | ` + yt + `PRG0cnV7YG8 event | dWeb Camp | Internet Archive · California 2022 | ` + yt + `zx7Dduv0Hd4 event | Funding the Commons | San Francisco · Buenos Aires | ` + yt + `bOZrUha9aQ8 `}, {"reports", ` report | Contribution Report 2025 | Samouraï Coop · Welcome to 2026 | ` + yt + `aG4M37FX4II report | Contribution Report 2024 | Samouraï Coop · Welcome to 2025 | ` + yt + `37L4vULszLQ report | Contribution Report 2023 | Samouraï Coop · Welcome to 2024 | ` + yt + `tj0imGVJHQc `}, {"peerdev", ` peerdev | Setup your local environment | Lesson 1 | ` + yt + `C8RuA_uU3Fs peerdev | Build and test your first dApp | Lesson 2 | ` + yt + `nzo6QgxQHgw peerdev | Register your User Space | Lesson 3 | ` + yt + `R5t1GmBRUVk peerdev | Transaction types | call · run · addpkg · send | ` + yt + `-Z3M1XVxWU8 peerdev | Coins and GRC20 | Lesson 5 | ` + yt + `4twoUyjo4rA peerdev | AVL tree vs map | Lesson 6 | ` + yt + `Qh73MqxxKTM peerdev | Deploying Gno packages | Lesson 7 | ` + yt + `B7bSreCwIls peerdev | Interactive documentation | Lesson 8 | ` + yt + `zpGxMQo3V-o peerdev | Effective programming in Gno | Lesson 9 | ` + yt + `UNJSuMdjTFA peerdev | Interrealm specification | Lesson 10 | ` + yt + `JJju7-GeRbA peerdev | DAOs and modular governance | Lesson 11 | ` + yt + `SphPgsjKQyQ peerdev | Governance DAO (GovDAO) | Lesson 12 | ` + yt + `jM-6ezyn_v4 peerdev | Index your transactions | tx-indexer | ` + yt + `8ld0nsV37SA peerdev | Monitor your validator | gnomonitoring | ` + yt + `ujwbKSFS_wg peerdev | Extend your IDE | VS Code · Neovim · Emacs · JetBrains | ` + yt + `Ssk1m-YGnyo peerdev | Secure your wallet | Lesson 16 | ` + yt + `q2jlN8KXBMA peerdev | Gno vs Solidity | Lesson 17 | ` + yt + `Oi77hvJCVbU `}, {"webtrip", ` webtrip | #1 Creating a New Era with Gno | Manfred Touron · gno.land | ` + yt + `Hv2QSIjz_U8 webtrip | #2 Building Trust in Web3 | Isaac Patka · Shield3 | ` + yt + `hKc9eWU2_CI webtrip | #3 Digital Art Disruptors | Distributed Gallery | ` + yt + `4nvVak97eik webtrip | #4 Decentralizing Digital Communities | Morgan Bazalgette · gno.land | ` + yt + `Hprl2WkMYCA webtrip | #5 Bridging Web2 and Web3 | Dongwon Shin · Onbloc | ` + yt + `-kb_N4Y7cm0 webtrip | #6 The Future of Staking and NFTs | Adrien Faguet · StakeLab | ` + yt + `0cOdIoQ1npI webtrip | #7 Personal Development and Blockchain | Dragos Roua | ` + yt + `aw8SJajzRdA webtrip | #8 Work-Life Balance & Product Design | Diane | ` + yt + `r_S9YbwF9UM `}, {"films", ` film | The Underground Sistine Chapel | Feature documentary · 2021 | ` + yt + `fsRFHmdiyGE film | Hashiriya | Documentary · 2025 | ` + yt + `OqMTmSEbqqY film | Soy Yo | Documentary · 2025 | ` + yt + `fWE2fpZuDEM film | Mañana cuesta más | Documentary · 2026 | ` + yt + `PEAR7l8dGEk film | Community Networks | Documentary · 2024 | ` + yt + `JuHVteEhNzo film | I'm Just a Lost Guy | Short film · 2025 | ` + yt + `84RgQxdI3n8 `}, }
- #6gallery.gno
- #7package home import ( "encoding/base64" "strings" "gno.land/p/nt/avl/v0" ) // This file is shared, unchanged, by every Samouraï home realm. // // A gallery is a named list of cards. gnoweb's CSP only paints images from a // few hosts (YouTube thumbnails are not among them) but it allows // data:image/svg+xml, so every card is a poster drawn here, on chain, and // linked to its video or site. // // A gallery is written in one Set-like call as lines of // // kind | title | meta | url [| description] // // The optional description becomes the card's hover text. // and rendered wherever the layout says :gallery.<name>:. const ( galleryPrefix = "gallery." cardsPerRow = 3 maxCards = 48 maxTitleLen = 80 maxMetaLen = 80 maxDescLen = 200 ) type card struct { kind, title, meta, url string desc string // hover text, optional label string // overrides the kind's label when set } var galleries = avl.NewTree() // name -> []card type style struct { label, glyph, accent, shade string video bool } var styles = map[string]style{ "report": {"CONTRIBUTION REPORT", "報", "#e63946", "#3a1014", true}, "gno": {"GNO.LAND · FILMED BY SAMOURAÏ", "道", "#2ec4b6", "#0d2e2b", true}, "webtrip": {"WEB TRIP · SERIES", "旅", "#ff7aa2", "#3a1422", true}, "peerdev": {"PEER DEV · GNO TUTORIAL", "学", "#4ea8de", "#0f2536", true}, "event": {"EVENT", "祭", "#f4a261", "#3a2412", true}, "festival": {"FESTIVAL · BY SAMOURAÏ", "祭", "#f4a261", "#3a2412", false}, "film": {"FILM · DIRECTED BY THE CREW", "映", "#b388eb", "#261a36", true}, "product": {"PRODUCT", "作", "#e9c46a", "#352b10", false}, "memba": {"MEMBA · PRODUCT", "組", "#e9c46a", "#352b10", false}, "game": {"ARCADE · PLAY IN MEMBA", "遊", "#8ac926", "#1c2a0b", true}, "oss": {"OPEN SOURCE · GITHUB", "源", "#a8b3bd", "#1b2026", false}, "member": {"SAMOURAÏ · CREW", "侍", "#e63946", "#2a1012", false}, "member-media": {"SAMOURAÏ · MEDIA", "映", "#b388eb", "#261a36", false}, "member-alumni": {"SAMOURAÏ · ALUMNI", "侍", "#9aa0a6", "#1d1f22", false}, } func styleOf(kind string) style { if s, ok := styles[kind]; ok { return s } return style{strings.ToUpper(kind), "侍", "#e63946", "#2a1012", false} } // SetGallery replaces a whole gallery from its line format. func SetGallery(cur realm, name, spec string) { assertAdmin(cur) if !validSlug(name) { panic("invalid gallery name: " + name) } cards := parseGallery(spec) rev++ galleries.Set(name, cards) } // DeleteGallery removes a gallery. func DeleteGallery(cur realm, name string) { assertAdmin(cur) if _, removed := galleries.Remove(name); !removed { panic("no such gallery: " + name) } rev++ } func parseGallery(spec string) []card { var cards []card for _, line := range strings.Split(spec, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } f := strings.Split(line, "|") if len(f) != 4 && len(f) != 5 { panic("gallery line needs 4 or 5 fields (kind | title | meta | url [| description]): " + line) } c := card{ kind: strings.TrimSpace(f[0]), title: strings.TrimSpace(f[1]), meta: strings.TrimSpace(f[2]), url: strings.TrimSpace(f[3]), } if len(f) == 5 { c.desc = strings.TrimSpace(f[4]) if len(c.desc) > maxDescLen || strings.ContainsAny(c.desc, "\"\\") { panic("gallery description must be at most 200 bytes, without quotes or backslashes: " + line) } } if !validSlug(c.kind) || c.title == "" || len(c.title) > maxTitleLen || len(c.meta) > maxMetaLen { panic("bad gallery line: " + line) } if !strings.HasPrefix(c.url, "https://") && !strings.HasPrefix(c.url, "/") { panic("gallery url must be https:// or a /path: " + c.url) } if strings.ContainsAny(c.url, " ()<>\"") { panic("gallery url has characters markdown would break on: " + c.url) } cards = append(cards, c) } if len(cards) == 0 || len(cards) > maxCards { panic("a gallery holds 1 to 48 cards") } return cards } // renderGallery lays cards out cardsPerRow to a row, each one a poster that // links to its target. func renderGallery(name string) (string, bool) { v := galleries.Get(name) if v == nil { return "", false } cards := v.([]card) var b strings.Builder for i := 0; i < len(cards); i += cardsPerRow { b.WriteString("<gno-columns>\n") for j := i; j < i+cardsPerRow; j++ { if j > i { b.WriteString("<gno-columns-sep />\n") } if j < len(cards) { c := cards[j] b.WriteString("[ + ")](" + c.url + linkTitle(c.desc) + ")\n") } } b.WriteString("</gno-columns>\n\n") } return strings.TrimSuffix(b.String(), "\n\n"), true } // linkTitle renders an optional markdown link title, which gnoweb emits as // the link's hover text. func linkTitle(desc string) string { if desc == "" { return "" } return ` "` + desc + `"` } // mdText keeps a title from closing the markdown image alt text early. func mdText(s string) string { return strings.NewReplacer("[", "(", "]", ")").Replace(s) } func xmlText(s string) string { return strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """, "'", "'").Replace(s) } // wrap splits s into at most two lines of about width runes; the second line // is cut with an ellipsis when the title is longer. func wrap(s string, width int) []string { words := strings.Fields(s) var lines []string cur := "" for _, w := range words { if cur == "" { cur = w } else if runeLen(cur)+1+runeLen(w) <= width { cur += " " + w } else { lines = append(lines, cur) cur = w } } if cur != "" { lines = append(lines, cur) } if len(lines) > 2 { second := []rune(strings.Join(lines[1:], " ")) if len(second) > width { second = append(second[:width-1], '…') } lines = []string{lines[0], string(second)} } return lines } func runeLen(s string) int { return len([]rune(s)) } // clip cuts s to at most n runes, ending with an ellipsis when it had to cut. func clip(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(append(r[:n-1], '…')) } func posterURI(c card) string { return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(poster(c))) } // poster draws a 320x180 card: a dark gradient, a rising sun in the kind's // accent (a play button for videos), a large kanji watermark, the kind label, // the title on up to two lines and a meta line. func poster(c card) string { st := styleOf(c.kind) if c.label != "" { st.label = c.label } var b strings.Builder b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">`) b.WriteString(`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#121212"/><stop offset="1" stop-color="` + st.shade + `"/></linearGradient></defs>`) b.WriteString(`<rect width="320" height="180" rx="14" fill="url(#g)"/>`) b.WriteString(`<text x="306" y="170" text-anchor="end" font-family="serif" font-size="120" fill="#ffffff" fill-opacity=".06">` + st.glyph + `</text>`) b.WriteString(`<circle cx="268" cy="52" r="30" fill="` + st.accent + `"/>`) if st.video { b.WriteString(`<path d="M260 38 L282 52 L260 66 Z" fill="#121212"/>`) } else { b.WriteString(`<path d="M256 52 H280 M272 44 L280 52 L272 60" stroke="#121212" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`) } b.WriteString(`<rect x="18" y="20" width="3" height="12" fill="` + st.accent + `"/>`) b.WriteString(`<text x="27" y="31" font-family="ui-monospace,Menlo,monospace" font-size="10" letter-spacing="1.5" fill="` + st.accent + `">` + xmlText(st.label) + `</text>`) lines := wrap(c.title, 24) y := 108 if len(lines) == 1 { y = 126 } for _, l := range lines { b.WriteString(`<text x="18" y="` + itoa(y) + `" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-weight="700" font-size="20" fill="#ffffff">` + xmlText(l) + `</text>`) y += 25 } // The meta line hugs the bottom-right corner: gnoweb draws its // external-link badge over the bottom-left one. b.WriteString(`<text x="304" y="164" text-anchor="end" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bdbdbd">` + xmlText(clip(c.meta, 40)) + `</text>`) b.WriteString(`</svg>`) return b.String() } func itoa(n int) string { if n == 0 { return "0" } var d []byte for n > 0 { d = append([]byte{byte('0' + n%10)}, d...) n /= 10 } return string(d) }
- #8gnomod.toml
- #9module = "gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/home" gno = "0.9" private = true
- #10home.gno
- #11// Package home is zôÖma's profile page on gno.land. // // gnoweb renders a profile by calling Render("") on the realm at exactly // /r/<namespace>/home, so this path carries no /vN suffix. // // The page is assembled from SLOTS: named markdown fragments, one Set // transaction each. The slot named "layout" is the template; every :slug: // placeholder in it is replaced by the slot of that name, in a single pass. // init seeds every slot from content.gno, so the page is complete the block // it goes live and later edits are data, not redeploys. // // One thing on this page is not written by its owner: visitors can send a // heart (<3), one per address, and the page counts them. package home import ( "strconv" "strings" "chain/runtime" "gno.land/p/nt/avl/v0" ) // admin is the only address allowed to edit slots. const admin = address("g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c") const ( layoutSlug = "layout" maxSlugLen = 64 maxRecent = 7 ) type slot struct { body string rev int updated int64 } var ( slots = avl.NewTree() // slug -> *slot rev int // total slot writes hearts = avl.NewTree() // address string -> int64 height of the heart recent []address // newest first, at most maxRecent ) // reservedSlugs are placeholders computed at render time. A slot may not take // one of these names, so nothing a slot says can shadow chain state. var reservedSlugs = []string{"chainid", "height", "hearts", "hero", "realm", "rev"} func init() { for _, s := range defaultSlots { rev++ slots.Set(s.slug, &slot{body: s.body, rev: rev, updated: runtime.ChainHeight()}) } for _, g := range defaultGalleries { galleries.Set(g.name, parseGallery(g.spec)) } } func assertAdmin(cur realm) { if cur.Previous().Address() != admin { panic("restricted to admin") } } func validSlug(slug string) bool { if len(slug) == 0 || len(slug) > maxSlugLen { return false } for i := 0; i < len(slug); i++ { c := slug[i] if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.') { return false } } return true } func assertWritableSlug(slug string) { if !validSlug(slug) { panic("invalid slug: want 1-" + strconv.Itoa(maxSlugLen) + " bytes of [a-z0-9._-], got " + strconv.Quote(slug)) } for _, r := range reservedSlugs { if slug == r { panic("reserved slug: " + slug) } } if strings.HasPrefix(slug, galleryPrefix) { panic("reserved slug: " + galleryPrefix + "* renders a gallery, use SetGallery") } } // Set creates or replaces one slot. func Set(cur realm, slug, body string) { assertAdmin(cur) assertWritableSlug(slug) rev++ slots.Set(slug, &slot{body: body, rev: rev, updated: runtime.ChainHeight()}) } // Append adds to the end of a slot, creating it when absent, for bodies too // large for one transaction. func Append(cur realm, slug, body string) { assertAdmin(cur) assertWritableSlug(slug) prev := "" if v := slots.Get(slug); v != nil { prev = v.(*slot).body } rev++ slots.Set(slug, &slot{body: prev + body, rev: rev, updated: runtime.ChainHeight()}) } // Delete removes a slot. Deleting "layout" falls back to the built-in layout. func Delete(cur realm, slug string) { assertAdmin(cur) if _, removed := slots.Remove(slug); !removed { panic("no such slot: " + slug) } rev++ } // Get returns a slot body, or "" when the slot does not exist. func Get(slug string) string { if v := slots.Get(slug); v != nil { return v.(*slot).body } return "" } // Revision is the total number of slot writes accepted. func Revision() int { return rev } // Heart records one <3 from the caller. Each address can send exactly one. func Heart(cur realm) { from := cur.Previous().Address() if hearts.Has(from.String()) { panic("already sent: one <3 per address") } hearts.Set(from.String(), runtime.ChainHeight()) recent = append([]address{from}, recent...) if len(recent) > maxRecent { recent = recent[:maxRecent] } } // Hearts returns how many distinct addresses sent a <3. func Hearts() int { return hearts.Size() } // HasHeart reports whether addr already sent a <3. func HasHeart(addr address) bool { return hearts.Has(addr.String()) }
- #12home_test.gno
- #13package home import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") mallory = testutils.TestAddress("mallory") ) func reset() { galleries = avl.NewTree() slots = avl.NewTree() rev = 0 hearts = avl.NewTree() recent = nil } func seed(slug, body string) { rev++ slots.Set(slug, &slot{body: body, rev: rev}) } func TestDefaultPageRendersEverySection(t *testing.T) { // State as left by init: every default slot, no heart yet. page := Render("") for _, want := range []string{"data:image/svg+xml;base64,", "Founder, CEO & CTO", "## 今 Now", "## 組 Inside Memba", "BARRICADE", "256 merged pull requests", "Paris P2P Festival", "Contributing on Gno since | 2022", "## 源 Open source", "(https://www.youtube.com/watch?v=aG4M37FX4II)", "**0** hearts so far.", "gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/home"} { uassert.True(t, strings.Contains(page, want), "missing: "+want) } for _, s := range defaultSlots { uassert.False(t, strings.Contains(page, ":"+s.slug+":"), "unfilled placeholder :"+s.slug+":") } for _, g := range defaultGalleries { uassert.False(t, strings.Contains(page, ":gallery."+g.name+":"), "unfilled gallery :gallery."+g.name+":") } uassert.False(t, strings.Contains(page, ":hero:")) } func TestSetGetDelete(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) Set(cross(cur), "bio", "Building on gno.") uassert.Equal(t, "Building on gno.", Get("bio")) Append(cross(cur), "bio", " Still.") uassert.Equal(t, "Building on gno. Still.", Get("bio")) uassert.Equal(t, 2, Revision()) Delete(cross(cur), "bio") uassert.Equal(t, "", Get("bio")) uassert.AbortsWithMessage(t, cur, "no such slot: bio", func() { Delete(cross(cur), "bio") }) } func TestWritesAreAdminOnly(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "restricted to admin", func() { Set(cross(cur), "bio", "pwned") }) uassert.AbortsWithMessage(t, cur, "restricted to admin", func() { Append(cross(cur), "bio", "pwned") }) uassert.AbortsWithMessage(t, cur, "restricted to admin", func() { Delete(cross(cur), "bio") }) } func TestSlugRules(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) uassert.AbortsContains(t, cur, "invalid slug", func() { Set(cross(cur), "Bad", "x") }) uassert.AbortsContains(t, cur, "invalid slug", func() { Set(cross(cur), "a:b", "x") }) uassert.AbortsContains(t, cur, "invalid slug", func() { Set(cross(cur), "", "x") }) uassert.AbortsWithMessage(t, cur, "reserved slug: hearts", func() { Set(cross(cur), "hearts", "fake 9999") }) uassert.AbortsContains(t, cur, "reserved slug: gallery.", func() { Set(cross(cur), "gallery.x", "fake") }) } func TestFillIsSinglePass(t *testing.T) { reset() seed("layout", "A :a: B :missing: C 10:30 :b:") seed("a", "[:b:]") seed("b", "bee") uassert.Equal(t, "A [:b:] B :missing: C 10:30 bee", Render("")) } func TestFallbackLayout(t *testing.T) { reset() seed("bio", "hi") uassert.True(t, strings.HasPrefix(Render(""), "# zôÖma\n\nhi\n\n**0** hearts")) } func TestHeartOncePerAddress(cur realm, t *testing.T) { reset() seed("layout", ":hearts:") testing.SetRealm(testing.NewUserRealm(alice)) Heart(cross(cur)) uassert.Equal(t, 1, Hearts()) uassert.True(t, HasHeart(alice)) uassert.AbortsWithMessage(t, cur, "already sent: one <3 per address", func() { Heart(cross(cur)) }) testing.SetRealm(testing.NewUserRealm(bob)) Heart(cross(cur)) uassert.Equal(t, 2, Hearts()) uassert.Equal(t, bob, recent[0], "newest first") page := Render("") uassert.True(t, strings.Contains(page, "**2** hearts so far.")) uassert.True(t, strings.Contains(page, "/u/"+bob.String())) uassert.True(t, strings.Contains(Render("hearts"), "/u/"+alice.String())) } func TestRecentIsBounded(cur realm, t *testing.T) { reset() for i := 0; i < maxRecent+3; i++ { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("fan" + string(rune('a'+i))))) Heart(cross(cur)) } uassert.Equal(t, maxRecent+3, Hearts()) uassert.Equal(t, maxRecent, len(recent)) } func TestOtherViews(t *testing.T) { reset() seed("bio", "hello") uassert.True(t, strings.Contains(Render("slots"), "[bio]")) uassert.True(t, strings.Contains(Render("slots/bio"), "hello")) uassert.True(t, strings.Contains(Render("slots/nope"), "Not found")) uassert.True(t, strings.Contains(Render("nope"), "Not found")) } func TestGallery(cur realm, t *testing.T) { reset() seed("layout", ":gallery.demo:") testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "restricted to admin", func() { SetGallery(cross(cur), "demo", "film | A | b | https://x.y") }) testing.SetRealm(testing.NewUserRealm(admin)) uassert.AbortsContains(t, cur, "needs 4 or 5 fields", func() { SetGallery(cross(cur), "demo", "film | A | b") }) uassert.AbortsContains(t, cur, "must be https://", func() { SetGallery(cross(cur), "demo", "film | A | b | javascript:alert(1)") }) uassert.AbortsContains(t, cur, "markdown would break", func() { SetGallery(cross(cur), "demo", "film | A | b | https://x.y/a)b") }) SetGallery(cross(cur), "demo", "# comment\nfilm | One <&> | meta | https://a.b/1\nproduct | Two | m | /r/x\nevent | Three | m | https://a.b/3\nreport | Four | m | https://a.b/4") page := Render("") uassert.Equal(t, 2, strings.Count(page, "<gno-columns>"), "3 per row") uassert.Equal(t, 4, strings.Count(page, "data:image/svg+xml;base64,")) uassert.True(t, strings.Contains(page, "](https://a.b/1)")) uassert.True(t, strings.Contains(page, "](/r/x)")) // Titles are XML-escaped inside the poster. svg := poster(card{kind: "film", title: "One <&>", meta: "m", url: "https://a.b"}) uassert.True(t, strings.Contains(svg, "One <&>")) // An optional fifth field becomes the link's hover text. SetGallery(cross(cur), "demo", "memba | DAO Management | m | https://a.b/dao | Vote, execute, create.") uassert.True(t, strings.Contains(Render(""), `](https://a.b/dao "Vote, execute, create.")`)) uassert.Equal(t, "MEMBA · PRODUCT", styleOf("memba").label, "every seeded kind has its own style") uassert.AbortsContains(t, cur, "without quotes", func() { SetGallery(cross(cur), "demo", "memba | A | m | https://a.b | say \"hi\"") }) DeleteGallery(cross(cur), "demo") uassert.Equal(t, ":gallery.demo:", Render(""), "a missing gallery stays visible") } func TestWrap(t *testing.T) { uassert.Equal(t, 1, len(wrap("Short title", 24))) lines := wrap("The Underground Sistine Chapel is a very long title indeed", 24) uassert.Equal(t, 2, len(lines)) uassert.True(t, strings.HasSuffix(lines[1], "…")) }
- #14render.gno
- #15package home import ( "strconv" "strings" "chain/runtime" ) const ( realmPath = "gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/home" webPath = "/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/home" ) // fallbackLayout renders when the layout slot is deleted. const fallbackLayout = "# zôÖma\n\n:bio:\n\n:hearts:\n" // Render serves: // // "" the profile page // "slots" the slot index // "slots/<slug>" one slot's raw markdown // "hearts" everyone who sent a <3 func Render(path string) string { switch { case path == "": return renderPage() case path == "slots": return renderIndex() case strings.HasPrefix(path, "slots/"): return renderSlot(strings.TrimPrefix(path, "slots/")) case path == "hearts": return renderHearts() } return "# Not found\n\nNo such path: " + strconv.Quote(path) + "\n\n[Back home](" + webPath + ")\n" } func renderPage() string { layout := Get(layoutSlug) if layout == "" { layout = fallbackLayout } return fill(layout) } // fill replaces every :slug: in s, in one left-to-right pass. Replacement text // is never rescanned, so a slot cannot expand into another slot. A placeholder // with no value stays verbatim: a missing section should be visible. func fill(s string) string { var b strings.Builder for { i := strings.IndexByte(s, ':') if i < 0 { b.WriteString(s) return b.String() } b.WriteString(s[:i]) s = s[i:] j := strings.IndexByte(s[1:], ':') if j < 0 { b.WriteString(s) return b.String() } slug := s[1 : j+1] if v, ok := lookup(slug); ok { b.WriteString(v) s = s[j+2:] continue } // Not a placeholder: emit the first ':' and rescan from the next one, // so "a:b :bio:" still finds :bio:. b.WriteString(":") s = s[1:] } } func lookup(slug string) (string, bool) { if !validSlug(slug) { return "", false } switch slug { case "chainid": return runtime.ChainID(), true case "height": return strconv.FormatInt(runtime.ChainHeight(), 10), true case "hearts": return heartsBlock(), true case "hero": return heroImage(), true case "realm": return realmPath, true case "rev": return strconv.Itoa(rev), true } if strings.HasPrefix(slug, galleryPrefix) { return renderGallery(strings.TrimPrefix(slug, galleryPrefix)) } if v := slots.Get(slug); v != nil { return v.(*slot).body, true } return "", false } func heartsBlock() string { n := hearts.Size() var b strings.Builder b.WriteString("**") b.WriteString(strconv.Itoa(n)) if n == 1 { b.WriteString("** heart so far.") } else { b.WriteString("** hearts so far.") } b.WriteString(" [Send yours](" + webPath + "$help&func=Heart): one transaction, one per address, forever on chain.") if len(recent) > 0 { b.WriteString("\n\nLatest: ") for i, a := range recent { if i > 0 { b.WriteString(" · ") } b.WriteString(userLink(a)) } b.WriteString(" · [all](" + webPath + ":hearts)") } return b.String() } // userLink renders an address as a short link to its gno.land profile. func userLink(a address) string { s := a.String() short := s if len(s) > 12 { short = s[:7] + "…" + s[len(s)-4:] } return "[" + short + "](/u/" + s + ")" } func renderHearts() string { var b strings.Builder b.WriteString("# <3\n\n") b.WriteString(strconv.Itoa(hearts.Size()) + " address(es) sent a heart. [Send yours](" + webPath + "$help&func=Heart).\n\n") if hearts.Size() == 0 { b.WriteString("_Be the first._\n") return b.String() } b.WriteString("| from | block |\n| --- | ---: |\n") hearts.Iterate("", "", func(key string, value any) bool { b.WriteString("| " + userLink(address(key)) + " | " + strconv.FormatInt(value.(int64), 10) + " |\n") return false }) b.WriteString("\n[Back home](" + webPath + ")\n") return b.String() } func renderIndex() string { var b strings.Builder b.WriteString("# Slots\n\nrev " + strconv.Itoa(rev) + " · " + strconv.Itoa(slots.Size()) + " slot(s)\n\n") b.WriteString("| slot | bytes | rev | height |\n| --- | ---: | ---: | ---: |\n") slots.Iterate("", "", func(key string, value any) bool { s := value.(*slot) b.WriteString("| [" + key + "](" + webPath + ":slots/" + key + ") | " + strconv.Itoa(len(s.body)) + " | " + strconv.Itoa(s.rev) + " | " + strconv.FormatInt(s.updated, 10) + " |\n") return false }) return b.String() } func renderSlot(slug string) string { v := slots.Get(slug) if v == nil { return "# Not found\n\nNo slot named " + strconv.Quote(slug) + ".\n\n[Slot index](" + webPath + ":slots)\n" } s := v.(*slot) body := s.body if !strings.HasSuffix(body, "\n") { body += "\n" } return "# " + slug + "\n\n" + strconv.Itoa(len(s.body)) + " bytes · rev " + strconv.Itoa(s.rev) + " · written at height " + strconv.FormatInt(s.updated, 10) + "\n\n````\n" + body + "````\n" }
- #16/gno.MemPackageType
- #17 MPUserAll
Result log
msg:0,success:true,log:,events:[]