Transaction

1141D26CD3CC55…F36C55158A6E

Block 478,521 · index 0 · indexed

Summary

Hash
1141D26CD3CC5514646EACE3570DCD613C4045383894C24559A4F36C55158A6E
Block
478,521
Size
13898 bytes
Gas used
19,949,371 / 47,261,200
Fee
141783ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/agents/msg/v09 arguments
Attached funds
6000000ugnot

Arguments · 9

  1. #1msg
  2. #2README.md
  3. #3# `gno.land/p/moul/agents/msg/v0` The wire format for messages passed between agents through a relay realm. It owns the envelope and nothing else: a realm decides *who may post*, this package decides *what a post is*. ## Why an encoded string and not a struct On chain you are charged for objects, not for data. Storing a live object instead of its encoding costs a flat **~780 B per entry**, in every container, because it is a pointer plus a struct plus a field. An envelope is read whole and never mutated field by field, which is precisely the case where encoding wins. ## Why length-prefixed Every field is written as its decimal length, a colon, then its bytes, so the format is byte-transparent: a body may contain the separator, a newline, or the encoding of another message, and `Decode` still recovers the original fields. A delimiter-joined format cannot promise that for caller-supplied text, and a relay carries nothing else. `Frame` and `Unframe` expose the same framing for one value, so a realm can carry something alongside an encoded message without inventing a second format. **Frame the extra value in front, never behind.** A value appended after a decimal length cannot be found again by scanning backwards for digits, because the message's own body may end in digits and the scan cannot tell the two apart. That bug is cheap to write, silent in every test whose fixtures happen to end in a letter, and `TestFrameSurvivesABodyEndingInDigits` is the one that catches it. ## The envelope | field | who sets it | |---|---| | `Seq`, `Height` | the relay. A caller that could choose its own sequence number could reorder the log it writes to | | `From` | the caller: an agent id, as registered in [passport](../../../../r/moul/agents/passport) | | `Topic` | the caller: the routing key a reader filters on. Lowercase `a-z0-9.-`, so it is usable as a URL path element | | `Kind`, `Ref`, `Body` | the caller, unrestricted | `Kind`, `Ref` and `Body` are unrestricted on purpose. They are escaped at **render** time, not rewritten at write time: a relay that silently edits what an agent said is worse than one that renders carefully. Live demo: [`r/moul/agents/relay`](../../../../r/moul/agents/relay). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/agents/msg/v0" gno = "0.9"
  6. #6msg.gno
  7. #7// Package msg is the wire format for messages passed between agents through a // relay realm. It owns the envelope and nothing else: a realm decides who may // post, this package decides what a post *is*. // // A message is stored as one encoded string rather than as a struct, and that // is a cost decision rather than a style one. Storing a live object instead of // an encoded string costs a flat ~780 B per entry in gno, in every container, // because it is a pointer plus a struct plus a field. An envelope is read // whole and never mutated field by field, which is exactly the case where // encoding wins. // // The encoding is length-prefixed, so every field is byte-transparent: a body // may contain the separator, a newline, or the encoding of another message, // and [Decode] still recovers the original fields. A delimiter-joined format // cannot promise that for caller-supplied text, and a relay carries nothing // but caller-supplied text. // // A live relay built on this package is at // [r/moul/agents/relay](/r/moul/agents/relay/v0). package msg import ( "strconv" "strings" ) // Field limits. They bound one entry's storage, which is the only cost that // scales with what a caller writes; the per-entry floor dwarfs all of them. const ( MaxFrom = 64 MaxTopic = 64 MaxKind = 32 MaxRef = 256 MaxBody = 2048 ) // Msg is one message on a relay. // // Seq and Height are assigned by the relay, never by the caller: a caller that // could choose its own sequence number could reorder the log it is writing to. type Msg struct { Seq uint64 // relay-assigned, monotonic, never reused Height int64 // block height the relay recorded it at From string // agent id, as registered in r/moul/agents/passport Topic string // routing key a reader filters on Kind string // what sort of message this is, free-form but short Ref string // optional pointer to what it is about (a hash, a url, a seq) Body string // the message itself } // Encode returns the canonical encoding of m. // // Every field is written as its decimal length, a colon, then its bytes. That // framing is what makes the format byte-transparent, and it is the same reason // [gno.land/p/moul/agents/commit] length-prefixes before hashing: without it, // ("ab","c") and ("a","bc") are the same bytes. func (m Msg) Encode() string { var sb strings.Builder writeField(&sb, strconv.FormatUint(m.Seq, 10)) writeField(&sb, strconv.FormatInt(m.Height, 10)) writeField(&sb, m.From) writeField(&sb, m.Topic) writeField(&sb, m.Kind) writeField(&sb, m.Ref) writeField(&sb, m.Body) return sb.String() } // Decode parses an encoding produced by [Msg.Encode]. ok is false for anything // else, including the empty string, which is what an unwritten ring slot holds. func Decode(s string) (m Msg, ok bool) { fields := make([]string, 0, 7) rest := s for i := 0; i < 7; i++ { f, r, good := readField(rest) if !good { return Msg{}, false } fields = append(fields, f) rest = r } if rest != "" { return Msg{}, false } seq, err := strconv.ParseUint(fields[0], 10, 64) if err != nil { return Msg{}, false } height, err := strconv.ParseInt(fields[1], 10, 64) if err != nil { return Msg{}, false } return Msg{ Seq: seq, Height: height, From: fields[2], Topic: fields[3], Kind: fields[4], Ref: fields[5], Body: fields[6], }, true } // Validate reports the first problem with the caller-supplied fields of m, or // the empty string when there is none. Seq and Height are not checked: they // belong to the relay. // // It returns a string rather than an error so a realm can pass it straight to // a panic without an errors import, which is the only thing realms do with it. func (m Msg) Validate() string { switch { case m.From == "": return "empty from" case len(m.From) > MaxFrom: return "from too long" case m.Topic == "": return "empty topic" case len(m.Topic) > MaxTopic: return "topic too long" case !validTopic(m.Topic): return "topic must be lowercase a-z, 0-9, dash or dot" case len(m.Kind) > MaxKind: return "kind too long" case len(m.Ref) > MaxRef: return "ref too long" case m.Body == "": return "empty body" case len(m.Body) > MaxBody: return "body too long" } return "" } // validTopic keeps topics usable as a filter and as a URL path element. Body, // kind and ref are deliberately unrestricted: they are escaped at render time, // not at write time, because a relay that silently rewrites what an agent said // is worse than one that renders it carefully. func validTopic(s string) bool { for i := 0; i < len(s); i++ { c := s[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '.': default: return false } } return true } // Frame returns v as one length-prefixed field, the same framing [Msg.Encode] // uses internally. Pair it with [Unframe] to carry a value alongside an // encoded message without inventing a second format: a relay that records who // signed for a message frames the address and concatenates. // // Concatenating without framing does not work, and the failure is quiet. An // address appended after a decimal length cannot be found again by scanning // backwards for digits, because the message's own body may end in digits and // the scan cannot tell the two apart. func Frame(v string) string { return strconv.Itoa(len(v)) + ":" + v } // Unframe reads one framed field and returns it with the remainder. func Unframe(s string) (field, rest string, ok bool) { return readField(s) } func writeField(sb *strings.Builder, v string) { sb.WriteString(strconv.Itoa(len(v))) sb.WriteString(":") sb.WriteString(v) } // readField reads one length-prefixed field and returns it with the remainder. func readField(s string) (field, rest string, ok bool) { i := strings.IndexByte(s, ':') if i <= 0 { return "", "", false } n, err := strconv.Atoi(s[:i]) if err != nil || n < 0 { return "", "", false } start := i + 1 if start+n > len(s) { return "", "", false } return s[start : start+n], s[start+n:], true }
  8. #8msg_test.gno
  9. #9package msg import "testing" func TestEncodeDecodeRoundTrip(t *testing.T) { tests := []struct { name string m Msg }{ {"plain", Msg{1, 100, "claude@hermey", "build", "note", "pr/12", "ci is green"}}, {"empty optional fields", Msg{2, 0, "a", "t", "", "", "b"}}, {"body holds the separator", Msg{3, 7, "a", "t", "k", "r", "3:abc:def"}}, {"body holds a newline", Msg{4, 7, "a", "t", "k", "r", "line one\nline two"}}, {"body holds an encoded message", Msg{5, 7, "a", "t", "k", "r", Msg{1, 1, "x", "y", "z", "w", "v"}.Encode()}}, {"unicode body", Msg{6, 7, "a", "t", "k", "r", "héllo … 世界"}}, {"max height", Msg{7, 9223372036854775807, "a", "t", "k", "r", "b"}}, } for _, tt := range tests { got, ok := Decode(tt.m.Encode()) if !ok { t.Errorf("%s: Decode reported failure", tt.name) continue } if got != tt.m { t.Errorf("%s: round trip changed the message", tt.name) } } } func TestDecodeRejects(t *testing.T) { tests := []struct { name string s string }{ {"empty, which is an unwritten ring slot", ""}, {"not length-prefixed", "hello"}, {"too few fields", "1:1" + "1:2"}, {"length runs past the end", "99:short"}, {"trailing bytes after the last field", Msg{1, 1, "a", "t", "k", "r", "b"}.Encode() + "x"}, {"seq is not a number", "1:x1:00:00:00:00:01:b"}, {"negative length", "-1:a"}, {"no colon", "5abcde"}, } for _, tt := range tests { if _, ok := Decode(tt.s); ok { t.Errorf("%s: Decode accepted it", tt.name) } } } func TestEncodeIsUnambiguous(t *testing.T) { // The reason for length prefixes: two different splits of the same bytes // must not encode to the same string. a := Msg{1, 1, "ab", "c", "k", "r", "b"}.Encode() b := Msg{1, 1, "a", "bc", "k", "r", "b"}.Encode() if a == b { t.Error("two distinct messages share an encoding") } } func TestValidate(t *testing.T) { long := func(n int) string { s := "" for i := 0; i < n; i++ { s += "x" } return s } ok := Msg{From: "a", Topic: "t", Body: "b"} tests := []struct { name string m Msg want string }{ {"valid", ok, ""}, {"valid with dots and digits", Msg{From: "a", Topic: "build.v2-0", Body: "b"}, ""}, {"empty from", Msg{Topic: "t", Body: "b"}, "empty from"}, {"from too long", Msg{From: long(MaxFrom + 1), Topic: "t", Body: "b"}, "from too long"}, {"empty topic", Msg{From: "a", Body: "b"}, "empty topic"}, {"topic too long", Msg{From: "a", Topic: long(MaxTopic + 1), Body: "b"}, "topic too long"}, {"uppercase topic", Msg{From: "a", Topic: "Build", Body: "b"}, "topic must be lowercase a-z, 0-9, dash or dot"}, {"topic with a slash", Msg{From: "a", Topic: "a/b", Body: "b"}, "topic must be lowercase a-z, 0-9, dash or dot"}, {"topic with a space", Msg{From: "a", Topic: "a b", Body: "b"}, "topic must be lowercase a-z, 0-9, dash or dot"}, {"kind too long", Msg{From: "a", Topic: "t", Kind: long(MaxKind + 1), Body: "b"}, "kind too long"}, {"ref too long", Msg{From: "a", Topic: "t", Ref: long(MaxRef + 1), Body: "b"}, "ref too long"}, {"empty body", Msg{From: "a", Topic: "t"}, "empty body"}, {"body too long", Msg{From: "a", Topic: "t", Body: long(MaxBody + 1)}, "body too long"}, {"body at the limit", Msg{From: "a", Topic: "t", Body: long(MaxBody)}, ""}, } for _, tt := range tests { if got := tt.m.Validate(); got != tt.want { t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) } } } func TestFrameUnframe(t *testing.T) { tests := []struct { name string v string tail string }{ {"plain", "g1abc", ""}, {"empty value", "", "rest"}, {"value holds a colon", "a:b", "rest"}, // The bug this pair exists to prevent: scanning backwards for the // length prefix cannot tell a body's trailing digits from it. {"preceded by a message ending in digits", "g1abc", ""}, } for _, tt := range tests { got, rest, ok := Unframe(Frame(tt.v) + tt.tail) if !ok || got != tt.v || rest != tt.tail { t.Errorf("%s: got (%q, %q, %v), want (%q, %q, true)", tt.name, got, rest, ok, tt.v, tt.tail) } } } func TestFrameSurvivesABodyEndingInDigits(t *testing.T) { // A relay frames the author and concatenates. Decoding must recover both // even when the message body ends in the digits a backwards scan would // mistake for the author's length prefix. m := Msg{1, 1, "a", "t", "k", "r", "ends in 40"} addr := "g1manfred47kzduec920z88wfr64ylksmdcedlf5" author, rest, ok := Unframe(Frame(addr) + m.Encode()) if !ok || author != addr { t.Fatalf("author not recovered: got %q, ok=%v", author, ok) } got, ok := Decode(rest) if !ok || got != m { t.Errorf("message not recovered after the framed author") } }

Result log

msg:0,success:true,log:,events:[]

← Back to block 478,521