Transaction
979F5956B01030…9CCBDA44CE05
Block 424,886 · index 0 · indexed
Summary
- Hash
- 979F5956B01030C3FA974456E6CE2BD96208083DC4580CC0FD4E9CCBDA44CE05
- Block
- 424,886
- Size
- 125410 bytes
- Gas used
- 150,390,337 / 360,121,200
- Fee
- 1080363ugnot
- Status
- success
Messages
- Attached funds
- 41000000ugnot
- Package
- gno.land/p/moul/forge/v1
Arguments · 24
- #1forge
- #2README.md
- #3# forge The domain engine of an on-chain software forge: repos, roles, an append-only reference log, issues, change requests and reviews. Pure gno, no chain imports, no realm globals. The realm that wires it to gno.land is [`gno.land/r/moul/forge/v0`](../../../../r/moul/forge/v0). ## Packages and releases: the bridge to a deployed path A repo id is `<namespace>/<name>`, exactly two parts, so `r/moul/home` can never be one. Without a mapping there is no way to ask *which repo publishes this package path*, which is the first question anyone arriving from an explorer has. `PublishPackage` records the claim and indexes it forge-wide, so `PackageRepo` answers from the path alone without scanning every repo. One repo publishes many paths (moul/gno-contracts publishes 193 of them), and a path may be claimed by exactly one repo: silently letting a second repo take it would be a capture of another project's package in every explorer reading this index. **The claim is not verified, and cannot be.** A realm cannot ask the chain who deployed a path. What is guaranteed is that someone with write access to the repo said it, under a namespace `r/sys/users` governs. Weigh it the way you weigh a `go.mod`: it says where the author says the source lives. A `Release` is a tag, notes, and the object the tag names. A tag cannot be re-cut, because a consumer who pinned `v1.2.0` and later received different bytes has been lied to. Withdrawing one is `YankRelease`, which leaves it visible: a version that vanished reads as "never published" rather than "do not use this". Releases iterate in **cut order, never tag order** — tags are not required to be semver here (gno.land's own packages are `v0` and `v1`), so any version sort would present `v10` before `v2` and call it newest. ## What it stores, and what it refuses to store It does not store code. Git objects stay in git, behind whatever mirror a repo declares (an https remote, an IPFS CID, a peer). On gno.land a realm write locks a storage deposit of 100ugnot per byte, so a 1 MB repository would cost about 100 GNOT to park on chain and more on every push. Anchoring is the only shape that survives contact with real repositories. What it does store is the part a forge is actually trusted for, and that git alone does not authenticate: 1. **Which object a ref points at**, in what order it got there, and who said so: an append-only, hash-chained reference log. 2. **Who may move which ref**, and under what review policy. 3. **The social layer**: issues, change requests and reviews bound to addresses rather than to platform accounts. 4. **The merge decision**, recorded as one more entry in the same log. 5. **Which deployed package paths a repo publishes**, and **which releases it has cut**. See below: these two are what let an explorer start from a `gno.land/...` path and find the repo behind it. ## The reference log Every ref move appends a `LogEntry`: ref name, old object, new object, actor, block height, kind (`create`, `update`, `force`, `delete`, `merge`), an optional note, and a `Digest` committing to the previous entry's digest. Publish `LogHead()` anywhere off chain and the entire history of every ref becomes falsifiable. `VerifyLog()` recomputes the chain; a client should run the same computation over the values it read back, since a transparency log nobody verifies is just a log. Moves are compare-and-swap: ```go r.SetRef(actor, height, "refs/heads/main", expectedOID, newOID, "ship it") ``` `expectedOID` is the tip the caller last saw, empty to create the ref. A stale expectation returns `ErrStaleRef` instead of overwriting. That is git's `--force-with-lease`, except the lease is held by consensus rather than by the server you are pushing to. `ForceSetRef` skips the expectation, needs `RoleMaintainer`, and is permanently recorded as `KindForce`: a force-push is not forbidden here, it is made impossible to hide. The chain has no objects, so it cannot check that a new tip descends from the old one, and this package does not pretend otherwise. Ordering, attribution and policy are on chain; ancestry is verified by a client that holds the repo. This is the same split as gittuf's reference state log, with the log moved out of the repository and into a place no maintainer can rewrite. ## Roles `RoleNone < RoleReader < RoleWriter < RoleMaintainer < RoleAdmin < RoleOwner`, totally ordered so every check is one comparison. Writers move refs, maintainers force and merge, admins manage members and policy. The last owner cannot be demoted. Anyone can open an issue or a change request without a role: the spam gate is that the author pays gas and locks the deposit for their own bytes. ## Reviews that cannot go stale unnoticed A `Review` names the object id it reviewed, not the change. Push a new head and every earlier approval stops counting, because it approved something that is no longer what would be merged. Nothing has to remember to dismiss it, and no setting can turn the behaviour off. Only a writer's approval counts toward `RequiredApprovals`; anyone else's review is signal, not authority. A `request-changes` verdict from a writer blocks the merge while it stands. `MergeChange` is a compare-and-swap on the target ref plus a policy check, and it writes a `KindMerge` entry naming the change it came from. ## API shape Errors, never panics: this package is pure, so a realm turns an error into an abort (the only way to revert state in gno) and a test asserts on the value. Every collection is an `avl.Tree`, so every listing is ordered and paginatable, and no iteration walks unbounded state. The caller supplies the actor address and the block height, which is what makes the whole engine unit-testable with no chain at all. ```go f := forge.New() r, _ := f.CreateRepo(alice, height, "moul/forge", "an on-chain forge", "") r.SetMember(alice, bob, forge.RoleMaintainer) r.SetRef(alice, height, "refs/heads/main", "", oid, "initial import") c, _ := r.OpenChange(carol, height, "title", "body", "", "refs/heads/feat", head, "refs/heads/main") r.ReviewChange(bob, height, c.ID, forge.VerdictApprove, "lgtm") r.MergeChange(bob, height, c.ID, oid, merged, "merge change 0") ``` ## Limits Every stored string is bounded (see the `Max*` constants) because an unbounded field is an unbounded deposit. Ref names are a `refs/`-rooted subset of git-check-ref-format; object ids are 40 or 64 lowercase hex characters; repo ids are `<namespace>/<name>`, where the name is a lowercase slug and the namespace is either a slug (a claimed user name) or a bech32 address. The two shapes cannot collide: an address is 40 characters and a slug caps at 39. This package validates the **shape** of a namespace and nothing else. Whether a caller may claim one is an ownership question that needs a chain, so it lives in the realm: a name must be held in `r/sys/users`, an address must be the caller's own. One economic rule shows up in the API: deleting is privileged. On gno.land the storage-deposit refund goes to whoever frees the bytes, not to whoever paid for them, so an open delete path pays for vandalism. `DeleteRef` needs `RoleMaintainer`, and issues, comments and reviews have no delete at all. <!-- 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. **Dependency graph:**  > ⚠️ **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 -->
- #4change.gno
- #5package forge import "gno.land/p/nt/avl/v0" // Change states. const ( StateOpen = "open" StateMerged = "merged" StateClosed = "closed" ) // Review verdicts. const ( VerdictApprove = "approve" VerdictRequestChanges = "request-changes" VerdictComment = "comment" ) // Change is a change request (a pull request): a claim that TargetRef should be // moved to include HeadOID, plus the reviews of that claim. // // Reviews are bound to the object id they reviewed, not to the change. Push a // new head and every earlier approval stops counting: not by a policy toggle a // maintainer can switch off, but because the approval names an object that is // no longer what is being merged. type Change struct { ID int64 Title string Body string Author address SourceRepo string // forge repo id, or a mirror locator; "" means this repo SourceRef string HeadOID string TargetRef string State string CreatedAt int64 UpdatedAt int64 MergedOID string // the object TargetRef moved to MergedBy address MergedAt int64 reviews *avl.Tree // address string -> *Review (latest per reviewer) comments *avl.Tree // padded id -> *Comment nextComment int64 } // Review is one reviewer's verdict on one object id. type Review struct { Reviewer address Verdict string OID string // the head the reviewer actually looked at Body string Height int64 } // OpenChange files a change request. Permissionless, like an issue: the // proposal costs its author gas and deposit, and costs a maintainer nothing // until they choose to look. func (r *Repo) OpenChange(actor address, height int64, title, body, sourceRepo, sourceRef, headOID, targetRef string) (*Change, error) { if r.Archived { return nil, ErrRepoArchived } if title == "" || !ValidLine(title, MaxTitleLen) { return nil, ErrInvalidText } if !ValidText(body, MaxBodyLen) { return nil, ErrInvalidText } if sourceRepo != "" && !ValidRepoID(sourceRepo) && !ValidMirror(sourceRepo) { return nil, ErrInvalidRepoID } if sourceRef != "" && !ValidRefName(sourceRef) { return nil, ErrInvalidRefName } if !ValidOID(headOID) { return nil, ErrInvalidOID } if !ValidRefName(targetRef) { return nil, ErrInvalidRefName } c := &Change{ ID: r.nextChange, Title: title, Body: body, Author: actor, SourceRepo: sourceRepo, SourceRef: sourceRef, HeadOID: headOID, TargetRef: targetRef, State: StateOpen, CreatedAt: height, UpdatedAt: height, reviews: avl.NewTree(), comments: avl.NewTree(), } r.changes.Set(seqKey(c.ID), c) r.nextChange++ return c, nil } // UpdateChangeHead repoints an open change at a new object. The author may // always update their own; a writer may update anyone's (the "maintainer pushed // a fixup" case). func (r *Repo) UpdateChangeHead(actor address, height, id int64, headOID string) error { c := r.Change(id) if c == nil { return ErrChangeNotFound } if c.State != StateOpen { return ErrChangeNotOpen } if c.Author != actor && !r.Can(actor, RoleWriter) { return ErrUnauthorized } if !ValidOID(headOID) { return ErrInvalidOID } if headOID == c.HeadOID { return ErrSameOID } c.HeadOID = headOID c.UpdatedAt = height return nil } // ReviewChange records a verdict against the change's current head. Anyone may // review; only a writer's approval counts toward the merge policy (see // CountApprovals): an unprivileged review is signal, not authority. func (r *Repo) ReviewChange(actor address, height, id int64, verdict, body string) error { if r.Archived { return ErrRepoArchived } c := r.Change(id) if c == nil { return ErrChangeNotFound } if c.State != StateOpen { return ErrChangeNotOpen } switch verdict { case VerdictApprove, VerdictRequestChanges, VerdictComment: default: return ErrInvalidVerdict } if verdict == VerdictApprove && actor == c.Author && !r.AllowSelfApproval { return ErrSelfApproval } if !ValidText(body, MaxCommentLen) { return ErrInvalidText } c.reviews.Set(actor.String(), &Review{ Reviewer: actor, Verdict: verdict, OID: c.HeadOID, Body: body, Height: height, }) c.UpdatedAt = height return nil } // CommentChange appends a reply to a change request. func (r *Repo) CommentChange(actor address, height, id int64, body string) (*Comment, error) { if r.Archived { return nil, ErrRepoArchived } c := r.Change(id) if c == nil { return nil, ErrChangeNotFound } if body == "" || !ValidText(body, MaxCommentLen) { return nil, ErrInvalidText } cm := &Comment{ID: c.nextComment, Author: actor, Body: body, CreatedAt: height} c.comments.Set(seqKey(cm.ID), cm) c.nextComment++ c.UpdatedAt = height return cm, nil } // CloseChange withdraws or rejects a change. Author or maintainer. func (r *Repo) CloseChange(actor address, height, id int64) error { c := r.Change(id) if c == nil { return ErrChangeNotFound } if c.State != StateOpen { return ErrChangeNotOpen } if c.Author != actor && !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } c.State = StateClosed c.UpdatedAt = height return nil } // CountApprovals counts approvals that still apply: cast by a writer or above, // against the change's current head, and (unless the repo allows it) not the // author's own. func (r *Repo) CountApprovals(c *Change) int { n := 0 c.reviews.Iterate("", "", func(_ string, value any) bool { rv := value.(*Review) if rv.Verdict != VerdictApprove || rv.OID != c.HeadOID { return false } if rv.Reviewer == c.Author && !r.AllowSelfApproval { return false } if !r.Can(rv.Reviewer, RoleWriter) { return false } n++ return false }) return n } // CountBlocking counts writers who requested changes on the current head. func (r *Repo) CountBlocking(c *Change) int { n := 0 c.reviews.Iterate("", "", func(_ string, value any) bool { rv := value.(*Review) if rv.Verdict == VerdictRequestChanges && rv.OID == c.HeadOID && r.Can(rv.Reviewer, RoleWriter) { n++ } return false }) return n } // MergeChange moves TargetRef to mergedOID and records the move as one more // entry in the reference log, tagged with the change it came from. // // expectedTargetOID is a compare-and-swap on the target ref ("" when the ref // does not exist yet): a change approved against one base cannot be merged onto // a base that moved underneath it. mergedOID is computed off chain by whoever // performs the merge: the chain records the claim, signed, ordered and // attributed, and a client with the objects verifies that the result actually // contains HeadOID. func (r *Repo) MergeChange(actor address, height, id int64, expectedTargetOID, mergedOID, note string) (*LogEntry, error) { if r.Archived { return nil, ErrRepoArchived } c := r.Change(id) if c == nil { return nil, ErrChangeNotFound } if c.State != StateOpen { return nil, ErrChangeNotOpen } if !r.Can(actor, RoleMaintainer) { return nil, ErrUnauthorized } if !ValidOID(mergedOID) { return nil, ErrInvalidOID } if !ValidLine(note, MaxNoteLen) { return nil, ErrInvalidText } if r.CountBlocking(c) > 0 { return nil, ErrChangesRequested } if r.CountApprovals(c) < r.RequiredApprovals { return nil, ErrNotEnoughApproval } cur := r.Ref(c.TargetRef) switch { case cur == nil && expectedTargetOID != "": return nil, ErrRefNotFound case cur != nil && cur.OID != expectedTargetOID: return nil, ErrStaleRef case cur != nil && cur.OID == mergedOID: return nil, ErrSameOID } old := "" if cur != nil { old = cur.OID } r.refs.Set(c.TargetRef, &Ref{Name: c.TargetRef, OID: mergedOID, UpdatedAt: height, UpdatedBy: actor}) e := r.appendLog(actor, height, c.TargetRef, old, mergedOID, KindMerge, c.ID, note) c.State = StateMerged c.MergedOID = mergedOID c.MergedBy = actor c.MergedAt = height c.UpdatedAt = height return e, nil } // Change returns a change by id, or nil. func (r *Repo) Change(id int64) *Change { v := r.changes.Get(seqKey(id)) if v == nil { return nil } return v.(*Change) } // IterateChanges walks change requests newest-first. func (r *Repo) IterateChanges(offset, count int, cb func(*Change) bool) { if count <= 0 { count = r.changes.Size() } r.changes.ReverseIterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Change)) }) } // OpenChangeCount counts change requests still open. func (r *Repo) OpenChangeCount() int { n := 0 r.changes.Iterate("", "", func(_ string, value any) bool { if value.(*Change).State == StateOpen { n++ } return false }) return n } // ReviewCount is the number of reviewers who have weighed in (latest verdict // per reviewer, on any head). func (c *Change) ReviewCount() int { return c.reviews.Size() } // Review returns a reviewer's latest verdict, or nil. func (c *Change) Review(a address) *Review { v := c.reviews.Get(a.String()) if v == nil { return nil } return v.(*Review) } // IterateReviews walks reviews in reviewer-address order. func (c *Change) IterateReviews(cb func(*Review) bool) { c.reviews.Iterate("", "", func(_ string, value any) bool { return cb(value.(*Review)) }) } // CommentCount is the number of replies on the change. func (c *Change) CommentCount() int { return c.comments.Size() } // IterateComments walks replies oldest-first. func (c *Change) IterateComments(offset, count int, cb func(*Comment) bool) { if count <= 0 { count = c.comments.Size() } c.comments.IterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Comment)) }) } // Stale reports whether a review no longer applies to the change's head. func (c *Change) Stale(rv *Review) bool { return rv.OID != c.HeadOID }
- #6change_test.gno
- #7package forge import ( "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) // openChange sets up a repo with main at oid("a") and one open change proposing // oid("b"), authored by carol (a writer). func openChange(t *testing.T) (*Repo, *Change) { t.Helper() _, r := newRepo(t) _, err := r.SetRef(alice, 100, "refs/heads/main", "", oid("a"), "initial") urequire.NoError(t, err) c, err := r.OpenChange(carol, 101, "add the log", "closes #1", "", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) return r, c } func TestOpenChangeValidation(t *testing.T) { _, r := newRepo(t) _, err := r.OpenChange(eve, 101, "", "", "", "", oid("b"), "refs/heads/main") uassert.ErrorIs(t, err, ErrInvalidText) _, err = r.OpenChange(eve, 101, "t", "", "", "", "beef", "refs/heads/main") uassert.ErrorIs(t, err, ErrInvalidOID) _, err = r.OpenChange(eve, 101, "t", "", "", "", oid("b"), "main") uassert.ErrorIs(t, err, ErrInvalidRefName) _, err = r.OpenChange(eve, 101, "t", "", "NOT/an/id", "", oid("b"), "refs/heads/main") uassert.ErrorIs(t, err, ErrInvalidRepoID) // A fork or a plain git remote are both legal sources: the objects live // off chain either way. c, err := r.OpenChange(eve, 101, "from a fork", "", "eve/forge", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) uassert.Equal(t, StateOpen, c.State) c2, err := r.OpenChange(eve, 101, "from a remote", "", "https://example.com/x.git", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) uassert.Equal(t, int64(1), c2.ID) uassert.Equal(t, 2, r.OpenChangeCount()) } func TestApprovalsAreBoundToTheReviewedObject(t *testing.T) { r, c := openChange(t) urequire.NoError(t, r.ReviewChange(bob, 102, 0, VerdictApprove, "lgtm")) uassert.Equal(t, 1, r.CountApprovals(c)) // Push a new head: the approval named an object that is no longer what // would be merged, so it stops counting. Nothing had to remember to // dismiss it. urequire.NoError(t, r.UpdateChangeHead(carol, 103, 0, oid("c"))) uassert.Equal(t, 0, r.CountApprovals(c)) uassert.True(t, c.Stale(c.Review(bob))) urequire.NoError(t, r.ReviewChange(bob, 104, 0, VerdictApprove, "still lgtm")) uassert.Equal(t, 1, r.CountApprovals(c)) uassert.Equal(t, 1, c.ReviewCount(), "one verdict per reviewer, latest wins") } func TestApprovalWeight(t *testing.T) { r, c := openChange(t) // Anyone may review; only a writer's approval counts toward policy. urequire.NoError(t, r.ReviewChange(eve, 102, 0, VerdictApprove, "as a user: works")) urequire.NoError(t, r.ReviewChange(dave, 102, 0, VerdictApprove, "reader here")) uassert.Equal(t, 2, c.ReviewCount()) uassert.Equal(t, 0, r.CountApprovals(c), "unprivileged review is signal, not authority") // The author cannot approve their own change, unless the repo says so. uassert.ErrorIs(t, r.ReviewChange(carol, 102, 0, VerdictApprove, "mine"), ErrSelfApproval) urequire.NoError(t, r.SetPolicy(alice, 1, true)) urequire.NoError(t, r.ReviewChange(carol, 102, 0, VerdictApprove, "mine")) uassert.Equal(t, 1, r.CountApprovals(c)) uassert.ErrorIs(t, r.ReviewChange(bob, 102, 0, "lgtm?", ""), ErrInvalidVerdict) uassert.ErrorIs(t, r.ReviewChange(bob, 102, 9, VerdictApprove, ""), ErrChangeNotFound) } func TestMergePolicy(t *testing.T) { r, c := openChange(t) // Default policy is one approval. _, err := r.MergeChange(bob, 105, 0, oid("a"), oid("e"), "merge #0") uassert.ErrorIs(t, err, ErrNotEnoughApproval) urequire.NoError(t, r.ReviewChange(bob, 106, 0, VerdictApprove, "lgtm")) // A writer cannot merge, however well reviewed. _, err = r.MergeChange(carol, 107, 0, oid("a"), oid("e"), "") uassert.ErrorIs(t, err, ErrUnauthorized) // A blocking review stops the merge while it stands. urequire.NoError(t, r.ReviewChange(alice, 107, 0, VerdictRequestChanges, "needs a test")) uassert.Equal(t, 1, r.CountBlocking(c)) _, err = r.MergeChange(bob, 108, 0, oid("a"), oid("e"), "") uassert.ErrorIs(t, err, ErrChangesRequested) urequire.NoError(t, r.ReviewChange(alice, 109, 0, VerdictApprove, "test added")) // The base moved: the change was approved against a tree that no longer // exists, so the merge is refused rather than silently rebased. _, err = r.MergeChange(bob, 110, 0, oid("9"), oid("e"), "") uassert.ErrorIs(t, err, ErrStaleRef) e, err := r.MergeChange(bob, 111, 0, oid("a"), oid("e"), "merge change 0") urequire.NoError(t, err) uassert.Equal(t, KindMerge, e.Kind) uassert.Equal(t, int64(0), e.ChangeID, "the log entry names the change it came from") uassert.Equal(t, oid("a"), e.OldOID) uassert.Equal(t, oid("e"), r.Ref("refs/heads/main").OID, "the ref moved") uassert.Equal(t, StateMerged, c.State) uassert.Equal(t, bob.String(), c.MergedBy.String()) uassert.Equal(t, int64(111), c.MergedAt) uassert.Equal(t, 0, r.OpenChangeCount()) ok, _ := r.VerifyLog() uassert.True(t, ok) // A merged change is closed to everything. _, err = r.MergeChange(bob, 112, 0, oid("e"), oid("f"), "") uassert.ErrorIs(t, err, ErrChangeNotOpen) uassert.ErrorIs(t, r.UpdateChangeHead(carol, 112, 0, oid("d")), ErrChangeNotOpen) uassert.ErrorIs(t, r.ReviewChange(bob, 112, 0, VerdictApprove, ""), ErrChangeNotOpen) uassert.ErrorIs(t, r.CloseChange(carol, 112, 0), ErrChangeNotOpen) } func TestMergeCreatesMissingRef(t *testing.T) { _, r := newRepo(t) c, err := r.OpenChange(carol, 101, "first branch", "", "", "refs/heads/feat", oid("b"), "refs/heads/main") urequire.NoError(t, err) urequire.NoError(t, r.SetPolicy(alice, 0, false)) _, err = r.MergeChange(bob, 102, 0, oid("a"), oid("b"), "") uassert.ErrorIs(t, err, ErrRefNotFound, "a target that does not exist takes no expectation") e, err := r.MergeChange(bob, 103, 0, "", oid("b"), "first merge") urequire.NoError(t, err) uassert.Equal(t, KindMerge, e.Kind) uassert.Equal(t, "", e.OldOID) uassert.Equal(t, oid("b"), r.Ref("refs/heads/main").OID) uassert.Equal(t, StateMerged, c.State) } func TestChangeCommentsAndClose(t *testing.T) { r, c := openChange(t) cm, err := r.CommentChange(eve, 102, 0, "does this cover the force case?") urequire.NoError(t, err) uassert.Equal(t, int64(0), cm.ID) uassert.Equal(t, 1, c.CommentCount()) _, err = r.CommentChange(eve, 102, 0, "") uassert.ErrorIs(t, err, ErrInvalidText) uassert.ErrorIs(t, r.CloseChange(eve, 103, 0), ErrUnauthorized) urequire.NoError(t, r.CloseChange(carol, 103, 0), "the author may withdraw") uassert.Equal(t, StateClosed, c.State) uassert.True(t, r.Change(42) == nil) uassert.ErrorIs(t, r.UpdateChangeHead(carol, 104, 42, oid("d")), ErrChangeNotFound) } func TestChangeIterationIsNewestFirst(t *testing.T) { _, r := newRepo(t) for _, title := range []string{"one", "two", "three"} { _, err := r.OpenChange(carol, 101, title, "", "", "", oid("b"), "refs/heads/main") urequire.NoError(t, err) } var got []string r.IterateChanges(0, 0, func(c *Change) bool { got = append(got, c.Title) return false }) uassert.Equal(t, "three,two,one", join(got)) var reviewers []string c := r.Change(0) urequire.NoError(t, r.ReviewChange(bob, 102, 0, VerdictApprove, "")) urequire.NoError(t, r.ReviewChange(alice, 102, 0, VerdictComment, "")) c.IterateReviews(func(rv *Review) bool { reviewers = append(reviewers, rv.Verdict) return false }) uassert.Equal(t, 2, len(reviewers)) }
- #8errors.gno
- #9package forge import "errors" // Stable, machine-readable error values. Callers (realms, clients, indexers) // should switch on these rather than on message text: a realm turns them into // panics, and the panic string is the only thing a user sees. var ( ErrInvalidRepoID = errors.New("forge: invalid repo id") ErrInvalidRefName = errors.New("forge: invalid ref name") ErrInvalidOID = errors.New("forge: invalid object id") ErrInvalidText = errors.New("forge: invalid text") ErrInvalidRole = errors.New("forge: invalid role") ErrInvalidVerdict = errors.New("forge: invalid review verdict") ErrInvalidMirror = errors.New("forge: invalid mirror locator") ErrTooLong = errors.New("forge: value too long") ErrTooMany = errors.New("forge: too many entries") ErrRepoExists = errors.New("forge: repo already exists") ErrRepoNotFound = errors.New("forge: repo not found") ErrRepoArchived = errors.New("forge: repo is archived") ErrRefNotFound = errors.New("forge: ref not found") ErrRefExists = errors.New("forge: ref already exists") ErrStaleRef = errors.New("forge: stale ref (compare-and-swap failed)") ErrUnauthorized = errors.New("forge: unauthorized") ErrIssueNotFound = errors.New("forge: issue not found") ErrIssueClosed = errors.New("forge: issue is closed") ErrChangeNotFound = errors.New("forge: change not found") ErrChangeNotOpen = errors.New("forge: change is not open") ErrSelfApproval = errors.New("forge: self-approval is not allowed") ErrNotEnoughApproval = errors.New("forge: not enough approvals") ErrChangesRequested = errors.New("forge: changes requested by a reviewer") ErrSameOID = errors.New("forge: ref already points at that object") ErrLastOwner = errors.New("forge: cannot demote the last owner") ErrInvalidPkgPath = errors.New("forge: invalid package path") ErrInvalidTag = errors.New("forge: invalid release tag") ErrPackageClaimed = errors.New("forge: package path is claimed by another repo") ErrPackageNotFound = errors.New("forge: package not found") ErrReleaseExists = errors.New("forge: release already exists") ErrReleaseNotFound = errors.New("forge: release not found") )
- #10forge.gno
- #11// Package forge is the domain engine of an on-chain software forge: repos, // roles, an append-only reference log, issues and change requests (pull // requests), with no chain imports of its own. // // What it does NOT do, on purpose: store blobs, trees or packfiles. Git objects // stay wherever git already puts them (a mirror, an IPFS CID, a peer) and this // package records what a forge is actually trusted for and what git alone does // not authenticate: // // 1. which object id a ref points at, in what order it got there, and who said // so: an append-only, hash-chained reference log (the same shape as // gittuf's reference state log, with consensus playing the notary); // 2. who is allowed to move which ref, and under what review policy; // 3. the social layer: issues, change requests, reviews: bound to addresses // rather than to platform accounts; // 4. the merge decision itself, recorded as one more entry in the same log. // // The chain cannot see the object graph, so it cannot verify that a new tip // descends from the old one. It does not pretend to: every ref move is a // compare-and-swap against the tip the caller expected (git's // --force-with-lease, moved somewhere the forge operator cannot rewrite), any // move that abandons that discipline is recorded as a force, and ancestry is // checked by a client that has the objects. Ordering, attribution and policy // are on chain; proof is local. // // All state lives in avl trees so every listing is ordered and paginatable, and // every mutation takes the actor and the block height from the caller: the // package is pure, deterministic and unit-testable without a chain. // // Live demo: gno.land/r/moul/forge/v0. package forge import ( "strconv" "strings" "gno.land/p/nt/avl/v0" ) // Role is a repo-scoped capability level. Roles are totally ordered: every // check is "at least this role", so there is one comparison to audit. type Role int const ( RoleNone Role = iota // not a member RoleReader // explicit read (all repos are public in v0) RoleWriter // move non-protected refs, update own changes RoleMaintainer // force-move refs, merge changes, triage issues RoleAdmin // manage members and repo settings RoleOwner // admin + transfer; at least one always exists ) // String renders the role as the lowercase token used by the realm API. func (r Role) String() string { switch r { case RoleReader: return "reader" case RoleWriter: return "writer" case RoleMaintainer: return "maintainer" case RoleAdmin: return "admin" case RoleOwner: return "owner" default: return "none" } } // ParseRole is the inverse of Role.String. func ParseRole(s string) (Role, error) { switch s { case "none": return RoleNone, nil case "reader": return RoleReader, nil case "writer": return RoleWriter, nil case "maintainer": return RoleMaintainer, nil case "admin": return RoleAdmin, nil case "owner": return RoleOwner, nil } return RoleNone, ErrInvalidRole } // Forge is the top-level registry: repo id -> repo. type Forge struct { repos *avl.Tree // "<namespace>/<name>" -> *Repo // packages is the forge-wide index behind PackageRepo: a deployed package // path resolves to the repo claiming it without scanning every repo. It // lives here rather than only on the repo because the question an explorer // asks starts from the path and does not know the repo yet. packages *avl.Tree // package path -> *Package } // New returns an empty forge. func New() *Forge { return &Forge{repos: avl.NewTree(), packages: avl.NewTree()} } // Repo is one repository. Nothing here is the code: Mirrors says where the // objects can be fetched, Refs says what the objects are supposed to be. type Repo struct { ID string // "<namespace>/<name>", immutable Description string DefaultRef string // fully-qualified, e.g. "refs/heads/main" Mirrors []string ParentID string // fork lineage, "" for a root repo CreatedAt int64 // block height Archived bool // Merge policy. RequiredApprovals int // approvals needed to merge a change AllowSelfApproval bool // may the change author's own approval count members *avl.Tree // address string -> Role refs *avl.Tree // ref name -> *Ref log *avl.Tree // padded seq -> *LogEntry (append-only) issues *avl.Tree // padded id -> *Issue changes *avl.Tree // padded id -> *Change packages *avl.Tree // package path -> *Package (this repo's half of the index) releases *avl.Tree // tag -> *Release // releaseOrder is cut order, which is the only order the forge knows to be // true. Tags are not required to be semver, so sorting the release tree by // key would present v10 before v2 and call it newest. releaseOrder *avl.Tree // padded seq -> tag head string // digest of the last log entry ("" when the log is empty) nextSeq int64 nextIssue int64 nextChange int64 } // CreateRepo registers a repo owned by actor. func (f *Forge) CreateRepo(actor address, height int64, id, description, defaultRef string) (*Repo, error) { if !ValidRepoID(id) { return nil, ErrInvalidRepoID } if !ValidText(description, MaxDescLen) { return nil, ErrInvalidText } if defaultRef == "" { defaultRef = "refs/heads/main" } if !ValidRefName(defaultRef) { return nil, ErrInvalidRefName } if f.repos.Has(id) { return nil, ErrRepoExists } r := &Repo{ ID: id, Description: description, DefaultRef: defaultRef, CreatedAt: height, RequiredApprovals: 1, members: avl.NewTree(), refs: avl.NewTree(), log: avl.NewTree(), issues: avl.NewTree(), changes: avl.NewTree(), // A fork routes through here too, and inherits neither: the parent // publishes those package paths and cut those releases, not the child. packages: avl.NewTree(), releases: avl.NewTree(), releaseOrder: avl.NewTree(), } r.members.Set(actor.String(), RoleOwner) f.repos.Set(id, r) return r, nil } // Fork registers newID as a fork of srcID and copies the parent's current refs // into the child's log, so the fork records exactly what it forked from. The // objects are not copied: they never were on chain: so the child inherits the // parent's mirrors as its initial fetch locators. func (f *Forge) Fork(actor address, height int64, srcID, newID string) (*Repo, error) { src := f.Repo(srcID) if src == nil { return nil, ErrRepoNotFound } child, err := f.CreateRepo(actor, height, newID, src.Description, src.DefaultRef) if err != nil { return nil, err } child.ParentID = srcID child.Mirrors = append([]string{}, src.Mirrors...) note := "fork of " + srcID + " at seq " + strconv.FormatInt(src.nextSeq, 10) if len(note) > MaxNoteLen { note = "fork of " + srcID } n := 0 src.refs.Iterate("", "", func(key string, value any) bool { ref := value.(*Ref) child.appendLog(actor, height, ref.Name, "", ref.OID, KindCreate, 0, note) child.refs.Set(ref.Name, &Ref{Name: ref.Name, OID: ref.OID, UpdatedAt: height, UpdatedBy: actor}) n++ return n >= 32 // a fork records a snapshot, not an unbounded copy }) return child, nil } // Repo returns the repo, or nil. func (f *Forge) Repo(id string) *Repo { v := f.repos.Get(id) if v == nil { return nil } return v.(*Repo) } // HasRepo reports whether the id is taken. func (f *Forge) HasRepo(id string) bool { return f.repos.Has(id) } // Size is the number of repos. func (f *Forge) Size() int { return f.repos.Size() } // IterateRepos walks repos in id order, newest-last, and stops when cb returns // true. offset/count page the walk; count <= 0 means "to the end". func (f *Forge) IterateRepos(offset, count int, cb func(*Repo) bool) { if count <= 0 { count = f.repos.Size() } f.repos.IterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Repo)) }) } // IterateNamespace walks the repos of one namespace in id order. func (f *Forge) IterateNamespace(ns string, cb func(*Repo) bool) { f.repos.Iterate(ns+"/", ns+"0", func(_ string, value any) bool { // '0' is '/'+1 return cb(value.(*Repo)) }) } // RoleOf returns the member's role, RoleNone when not a member. func (r *Repo) RoleOf(a address) Role { v := r.members.Get(a.String()) if v == nil { return RoleNone } return v.(Role) } // Can reports whether a holds at least the given role. func (r *Repo) Can(a address, min Role) bool { return r.RoleOf(a) >= min } // MemberCount is the number of members with an explicit role. func (r *Repo) MemberCount() int { return r.members.Size() } // IterateMembers walks members in address order. func (r *Repo) IterateMembers(cb func(addr string, role Role) bool) { r.members.Iterate("", "", func(key string, value any) bool { return cb(key, value.(Role)) }) } // SetMember grants or revokes a role. Admins manage members; only an owner may // mint another owner, and the last owner cannot be demoted: a repo with no // owner is a repo nobody can ever unarchive. func (r *Repo) SetMember(actor address, target address, role Role) error { if role < RoleNone || role > RoleOwner { return ErrInvalidRole } if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } if role == RoleOwner && !r.Can(actor, RoleOwner) { return ErrUnauthorized } cur := r.RoleOf(target) if cur == RoleOwner && role != RoleOwner { if !r.Can(actor, RoleOwner) { return ErrUnauthorized // an admin cannot demote an owner } if r.ownerCount() == 1 { return ErrLastOwner } } if role == RoleNone { r.members.Remove(target.String()) return nil } r.members.Set(target.String(), role) return nil } func (r *Repo) ownerCount() int { n := 0 r.members.Iterate("", "", func(_ string, value any) bool { if value.(Role) == RoleOwner { n++ } return false }) return n } // SetDescription updates the one-line description. func (r *Repo) SetDescription(actor address, description string) error { if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } if !ValidText(description, MaxDescLen) { return ErrInvalidText } r.Description = description return nil } // SetMirrors replaces the fetch locators. The first one is the canonical // remote; the rest are fallbacks. The chain records them, it never fetches. func (r *Repo) SetMirrors(actor address, mirrors []string) error { if !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } if len(mirrors) > MaxMirrors { return ErrTooMany } for _, m := range mirrors { if !ValidMirror(m) { return ErrInvalidMirror } } r.Mirrors = append([]string{}, mirrors...) return nil } // SetDefaultRef points the repo at another default branch. func (r *Repo) SetDefaultRef(actor address, name string) error { if !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } if !ValidRefName(name) { return ErrInvalidRefName } r.DefaultRef = name return nil } // SetPolicy sets the merge policy: how many approvals a change needs, and // whether the author's own approval counts. func (r *Repo) SetPolicy(actor address, requiredApprovals int, allowSelfApproval bool) error { if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } if requiredApprovals < 0 || requiredApprovals > 16 { return ErrTooMany } r.RequiredApprovals = requiredApprovals r.AllowSelfApproval = allowSelfApproval return nil } // SetArchived freezes (or unfreezes) the repo. An archived repo takes no // mutation except unarchiving: the log stays readable forever. func (r *Repo) SetArchived(actor address, archived bool) error { if !r.Can(actor, RoleAdmin) { return ErrUnauthorized } r.Archived = archived return nil } // Counts for rendering. func (r *Repo) RefCount() int { return r.refs.Size() } func (r *Repo) IssueCount() int { return r.issues.Size() } func (r *Repo) ChangeCount() int { return r.changes.Size() } // seqKey zero-pads an id to a fixed width so avl keys sort numerically. // ufmt has no width flags in gno, so the padding is done by hand: "%016d" // silently returns the bare number and would sort 10 before 2. func seqKey(n int64) string { s := strconv.FormatInt(n, 10) if len(s) >= 16 { return s } return strings.Repeat("0", 16-len(s)) + s }
- #12forge_test.gno
- #13package forge import ( "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) func TestCreateRepo(t *testing.T) { f := New() r, err := f.CreateRepo(alice, 42, "moul/forge", "desc", "") urequire.NoError(t, err) uassert.Equal(t, "refs/heads/main", r.DefaultRef, "default ref defaults") uassert.Equal(t, int64(42), r.CreatedAt) uassert.Equal(t, "owner", r.RoleOf(alice).String()) uassert.Equal(t, 1, f.Size()) _, err = f.CreateRepo(bob, 43, "moul/forge", "", "") uassert.ErrorIs(t, err, ErrRepoExists) _, err = f.CreateRepo(bob, 43, "Moul/Forge", "", "") uassert.ErrorIs(t, err, ErrInvalidRepoID) _, err = f.CreateRepo(bob, 43, "moul/other", "", "main") uassert.ErrorIs(t, err, ErrInvalidRefName) uassert.True(t, f.Repo("moul/nope") == nil, "missing repo is nil") uassert.True(t, f.HasRepo("moul/forge")) } func TestMembership(t *testing.T) { _, r := newRepo(t) uassert.True(t, r.Can(bob, RoleWriter), "maintainer covers writer") uassert.False(t, r.Can(carol, RoleMaintainer)) uassert.Equal(t, "none", r.RoleOf(eve).String()) uassert.Equal(t, 4, r.MemberCount()) // A writer cannot hand out roles. uassert.ErrorIs(t, r.SetMember(carol, eve, RoleWriter), ErrUnauthorized) // An admin can promote up to admin, but cannot mint an owner... urequire.NoError(t, r.SetMember(alice, bob, RoleAdmin)) uassert.ErrorIs(t, r.SetMember(bob, eve, RoleOwner), ErrUnauthorized) // ...nor demote one. uassert.ErrorIs(t, r.SetMember(bob, alice, RoleReader), ErrUnauthorized) // The last owner cannot demote themselves: a repo with no owner is a repo // nobody can ever administer again. uassert.ErrorIs(t, r.SetMember(alice, alice, RoleReader), ErrLastOwner) urequire.NoError(t, r.SetMember(alice, bob, RoleOwner)) urequire.NoError(t, r.SetMember(alice, alice, RoleReader)) uassert.Equal(t, "reader", r.RoleOf(alice).String()) // Revoking removes the member outright. urequire.NoError(t, r.SetMember(bob, dave, RoleNone)) uassert.Equal(t, "none", r.RoleOf(dave).String()) uassert.Equal(t, 3, r.MemberCount()) uassert.ErrorIs(t, r.SetMember(bob, eve, Role(99)), ErrInvalidRole) } func TestRepoSettings(t *testing.T) { _, r := newRepo(t) uassert.ErrorIs(t, r.SetDescription(carol, "nope"), ErrUnauthorized) urequire.NoError(t, r.SetDescription(alice, "an on-chain forge for gno")) uassert.Equal(t, "an on-chain forge for gno", r.Description) uassert.ErrorIs(t, r.SetMirrors(carol, []string{"https://example.com/x.git"}), ErrUnauthorized) uassert.ErrorIs(t, r.SetMirrors(bob, []string{"nope"}), ErrInvalidMirror) urequire.NoError(t, r.SetMirrors(bob, []string{"https://github.com/moul/gno-contracts.git"})) uassert.Equal(t, 1, len(r.Mirrors)) uassert.ErrorIs(t, r.SetPolicy(carol, 2, false), ErrUnauthorized) uassert.ErrorIs(t, r.SetPolicy(alice, 99, false), ErrTooMany) urequire.NoError(t, r.SetPolicy(alice, 2, true)) uassert.Equal(t, 2, r.RequiredApprovals) uassert.True(t, r.AllowSelfApproval) urequire.NoError(t, r.SetDefaultRef(bob, "refs/heads/trunk")) uassert.Equal(t, "refs/heads/trunk", r.DefaultRef) // An archived repo takes no writes at all. urequire.NoError(t, r.SetArchived(alice, true)) _, err := r.SetRef(alice, 101, "refs/heads/trunk", "", oid("a"), "") uassert.ErrorIs(t, err, ErrRepoArchived) _, err = r.OpenIssue(eve, 101, "hello", "", nil) uassert.ErrorIs(t, err, ErrRepoArchived) urequire.NoError(t, r.SetArchived(alice, false)) } func TestForkSnapshotsRefs(t *testing.T) { f, r := newRepo(t) urequire.NoError(t, r.SetMirrors(alice, []string{"https://github.com/moul/gno-contracts.git"})) _, err := r.SetRef(alice, 101, "refs/heads/main", "", oid("a"), "initial") urequire.NoError(t, err) _, err = r.SetRef(alice, 102, "refs/tags/v0.1.0", "", oid("b"), "tag") urequire.NoError(t, err) child, err := f.Fork(eve, 110, "moul/forge", "eve/forge") urequire.NoError(t, err) uassert.Equal(t, "moul/forge", child.ParentID) uassert.Equal(t, "owner", child.RoleOf(eve).String(), "the forker owns the fork") uassert.Equal(t, 2, child.RefCount(), "refs are snapshotted") uassert.Equal(t, 2, child.LogSize(), "and the snapshot is in the log") uassert.Equal(t, oid("a"), child.Ref("refs/heads/main").OID) uassert.Equal(t, 1, len(child.Mirrors), "the fork inherits where to fetch objects") ok, bad := child.VerifyLog() uassert.True(t, ok, "fork log is a valid chain") uassert.Equal(t, int64(-1), bad) _, err = f.Fork(eve, 111, "moul/nope", "eve/nope") uassert.ErrorIs(t, err, ErrRepoNotFound) _, err = f.Fork(eve, 111, "moul/forge", "eve/forge") uassert.ErrorIs(t, err, ErrRepoExists) } func TestIterateReposAndNamespace(t *testing.T) { f := New() for _, id := range []string{"alice/one", "alice/two", "bob/three"} { _, err := f.CreateRepo(alice, 1, id, "", "") urequire.NoError(t, err) } var all []string f.IterateRepos(0, 0, func(r *Repo) bool { all = append(all, r.ID) return false }) uassert.Equal(t, "alice/one,alice/two,bob/three", join(all)) var ns []string f.IterateNamespace("alice", func(r *Repo) bool { ns = append(ns, r.ID) return false }) uassert.Equal(t, "alice/one,alice/two", join(ns)) var page []string f.IterateRepos(1, 1, func(r *Repo) bool { page = append(page, r.ID) return false }) uassert.Equal(t, "alice/two", join(page)) } func join(ss []string) string { out := "" for i, s := range ss { if i > 0 { out += "," } out += s } return out }
- #14gnomod.toml
- #15module = "gno.land/p/moul/forge/v1" gno = "0.9"
- #16helpers_test.gno
- #17package forge import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/urequire/v0" ) // Deterministic, checksum-valid test addresses. var ( alice = testutils.TestAddress("alice") // owner bob = testutils.TestAddress("bob") // maintainer carol = testutils.TestAddress("carol") // writer dave = testutils.TestAddress("dave") // reader eve = testutils.TestAddress("eve") // stranger ) // oid builds a well-formed 40-hex object id out of one hex digit. func oid(c string) string { return strings.Repeat(c, 40) } // newRepo returns a repo owned by alice with the standard cast: bob // maintainer, carol writer, dave reader, eve nothing. func newRepo(t *testing.T) (*Forge, *Repo) { t.Helper() f := New() r, err := f.CreateRepo(alice, 100, "moul/forge", "an on-chain forge", "") urequire.NoError(t, err) urequire.NoError(t, r.SetMember(alice, bob, RoleMaintainer)) urequire.NoError(t, r.SetMember(alice, carol, RoleWriter)) urequire.NoError(t, r.SetMember(alice, dave, RoleReader)) return f, r }
- #18issue.gno
- #19package forge import "gno.land/p/nt/avl/v0" // Issue is a discussion thread bound to a repo. Anyone with an address may open // one: the spam gate is not a moderator, it is that the author pays gas and // locks the storage deposit for every byte they write. type Issue struct { ID int64 Title string Body string Author address Open bool Labels []string CreatedAt int64 UpdatedAt int64 comments *avl.Tree // padded id -> *Comment nextComment int64 } // Comment is one reply, on an issue or on a change request. type Comment struct { ID int64 Author address Body string CreatedAt int64 } // OpenIssue files an issue. Permissionless by design. func (r *Repo) OpenIssue(actor address, height int64, title, body string, labels []string) (*Issue, error) { if r.Archived { return nil, ErrRepoArchived } if title == "" || !ValidLine(title, MaxTitleLen) { return nil, ErrInvalidText } if !ValidText(body, MaxBodyLen) { return nil, ErrInvalidText } if err := checkLabels(labels); err != nil { return nil, err } i := &Issue{ ID: r.nextIssue, Title: title, Body: body, Author: actor, Open: true, Labels: append([]string{}, labels...), CreatedAt: height, UpdatedAt: height, comments: avl.NewTree(), } r.issues.Set(seqKey(i.ID), i) r.nextIssue++ return i, nil } // CommentIssue appends a reply. Closed issues still take comments (closing is a // triage state, not a gag); an archived repo takes none. func (r *Repo) CommentIssue(actor address, height, id int64, body string) (*Comment, error) { if r.Archived { return nil, ErrRepoArchived } i := r.Issue(id) if i == nil { return nil, ErrIssueNotFound } if body == "" || !ValidText(body, MaxCommentLen) { return nil, ErrInvalidText } c := &Comment{ID: i.nextComment, Author: actor, Body: body, CreatedAt: height} i.comments.Set(seqKey(c.ID), c) i.nextComment++ i.UpdatedAt = height return c, nil } // SetIssueOpen closes or reopens an issue. The author can always close their // own; maintainers can close anyone's. func (r *Repo) SetIssueOpen(actor address, height, id int64, open bool) error { if r.Archived { return ErrRepoArchived } i := r.Issue(id) if i == nil { return ErrIssueNotFound } if i.Author != actor && !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } i.Open = open i.UpdatedAt = height return nil } // SetIssueLabels replaces an issue's labels. Triage is a maintainer action. func (r *Repo) SetIssueLabels(actor address, height, id int64, labels []string) error { if r.Archived { return ErrRepoArchived } i := r.Issue(id) if i == nil { return ErrIssueNotFound } if !r.Can(actor, RoleMaintainer) { return ErrUnauthorized } if err := checkLabels(labels); err != nil { return err } i.Labels = append([]string{}, labels...) i.UpdatedAt = height return nil } // Issue returns an issue by id, or nil. func (r *Repo) Issue(id int64) *Issue { v := r.issues.Get(seqKey(id)) if v == nil { return nil } return v.(*Issue) } // IterateIssues walks issues newest-first. func (r *Repo) IterateIssues(offset, count int, cb func(*Issue) bool) { if count <= 0 { count = r.issues.Size() } r.issues.ReverseIterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Issue)) }) } // OpenIssueCount counts issues still open. func (r *Repo) OpenIssueCount() int { n := 0 r.issues.Iterate("", "", func(_ string, value any) bool { if value.(*Issue).Open { n++ } return false }) return n } // CommentCount is the number of replies on the issue. func (i *Issue) CommentCount() int { return i.comments.Size() } // IterateComments walks replies oldest-first. func (i *Issue) IterateComments(offset, count int, cb func(*Comment) bool) { if count <= 0 { count = i.comments.Size() } i.comments.IterateByOffset(offset, count, func(_ string, value any) bool { return cb(value.(*Comment)) }) } func checkLabels(labels []string) error { if len(labels) > MaxLabels { return ErrTooMany } for _, l := range labels { if !ValidLabel(l) { return ErrInvalidText } } return nil }
- #20issue_test.gno
- #21package forge import ( "testing" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) func TestIssueLifecycle(t *testing.T) { _, r := newRepo(t) // Filing an issue needs no role: the gate is that the author pays for the // bytes they write. i, err := r.OpenIssue(eve, 200, "Render eats the last newline", "Steps:\n1. …", []string{"bug"}) urequire.NoError(t, err) uassert.Equal(t, int64(0), i.ID) uassert.True(t, i.Open) uassert.Equal(t, eve.String(), i.Author.String()) uassert.Equal(t, 1, r.IssueCount()) uassert.Equal(t, 1, r.OpenIssueCount()) c, err := r.CommentIssue(bob, 201, 0, "reproduced") urequire.NoError(t, err) uassert.Equal(t, int64(0), c.ID) uassert.Equal(t, 1, i.CommentCount()) uassert.Equal(t, int64(201), i.UpdatedAt) // The author may close their own; a stranger may not close someone else's. uassert.ErrorIs(t, r.SetIssueOpen(carol, 202, 0, false), ErrUnauthorized) urequire.NoError(t, r.SetIssueOpen(eve, 202, 0, false)) uassert.False(t, i.Open) uassert.Equal(t, 0, r.OpenIssueCount()) // Closed is a triage state, not a gag. _, err = r.CommentIssue(eve, 203, 0, "still happening on pearl") urequire.NoError(t, err) // Maintainers can reopen and label. urequire.NoError(t, r.SetIssueOpen(bob, 204, 0, true)) uassert.ErrorIs(t, r.SetIssueLabels(eve, 205, 0, []string{"p1"}), ErrUnauthorized) urequire.NoError(t, r.SetIssueLabels(bob, 205, 0, []string{"bug", "p1"})) uassert.Equal(t, 2, len(i.Labels)) } func TestIssueValidation(t *testing.T) { _, r := newRepo(t) _, err := r.OpenIssue(eve, 200, "", "body", nil) uassert.ErrorIs(t, err, ErrInvalidText, "a title is required") _, err = r.OpenIssue(eve, 200, "two\nlines", "", nil) uassert.ErrorIs(t, err, ErrInvalidText, "a title is one line") _, err = r.OpenIssue(eve, 200, "ok", "", []string{"a,b"}) uassert.ErrorIs(t, err, ErrInvalidText, "labels are comma-free") _, err = r.OpenIssue(eve, 200, "ok", "", []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"}) uassert.ErrorIs(t, err, ErrTooMany) _, err = r.CommentIssue(eve, 200, 7, "no such issue") uassert.ErrorIs(t, err, ErrIssueNotFound) uassert.ErrorIs(t, r.SetIssueOpen(bob, 200, 7, false), ErrIssueNotFound) urequire.NoError(t, func() error { _, err := r.OpenIssue(eve, 200, "ok", "", nil); return err }()) _, err = r.CommentIssue(eve, 201, 0, "") uassert.ErrorIs(t, err, ErrInvalidText, "an empty comment is not a comment") } func TestIssueIterationIsNewestFirst(t *testing.T) { _, r := newRepo(t) for _, title := range []string{"first", "second", "third"} { _, err := r.OpenIssue(eve, 200, title, "", nil) urequire.NoError(t, err) } var got []string r.IterateIssues(0, 0, func(i *Issue) bool { got = append(got, i.Title) return false }) uassert.Equal(t, "third,second,first", join(got)) var page []string r.IterateIssues(1, 1, func(i *Issue) bool { page = append(page, i.Title) return false }) uassert.Equal(t, "second", join(page)) i := r.Issue(0) urequire.NotEqual(t, nil, i) for n := 0; n < 3; n++ { _, err := r.CommentIssue(eve, 201, 0, "ping") urequire.NoError(t, err) } var bodies []string i.IterateComments(0, 2, func(c *Comment) bool { bodies = append(bodies, c.Body) return false }) uassert.Equal(t, "ping,ping", join(bodies)) }
- #22pkg.gno
- #23package forge import "strings" // MaxPkgPathLen bounds a published package path. gno.land's own paths are far // shorter; the cap exists because every stored byte is a storage deposit // somebody pays. const MaxPkgPathLen = 256 // Package is the claim that a repo publishes a deployed package path. // // This is the join nothing else in the forge could express. A repo id is // "<namespace>/<name>", exactly two parts, so `r/moul/home` can never be one: // there was no way to ask "which repo publishes this package path", which is // the first question a reader arriving from an explorer has. // // The claim is deliberately one-directional and unverified. A realm cannot ask // the chain who deployed a path, so the forge cannot check that the repo really // is the source of the package. What it can guarantee is that only someone with // write access to the repo said so, and that the namespace of the repo is // governed by r/sys/users. A reader weighs the claim the way they weigh a // go.mod: it says where the author says the source lives. type Package struct { Path string // "gno.land/r/moul/home", the deployed package path RepoID string Ref string // the ref this was published from, "" if unstated OID string // the object id, "" if unstated Tag string // the release tag, "" if not published as part of one PublishedAt int64 // block height PublishedBy address } // ValidPkgPath reports whether s is a plausible gno package path. // // Deliberately shallow: it checks shape, not existence, because a realm cannot // ask the chain whether a path is deployed and a check that only sometimes // works is worse than one that never claims to. func ValidPkgPath(s string) bool { if s == "" || len(s) > MaxPkgPathLen { return false } if strings.Contains(s, "..") || strings.HasSuffix(s, "/") { return false } parts := strings.Split(s, "/") if len(parts) < 3 { return false } for _, p := range parts { if p == "" { return false } for i := 0; i < len(p); i++ { c := p[i] ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_' if !ok { return false } } } return true } // PublishPackage records that this repo publishes path, and indexes it // forge-wide so PackageRepo can answer without scanning every repo. // // Re-publishing the same path from the same repo overwrites the entry, which is // what a redeploy is. Publishing a path another repo already claims is refused: // silently moving it would let any repo owner capture another project's // package path in an explorer. func (f *Forge) PublishPackage(actor address, height int64, repoID, path, ref, oid, tag string) (*Package, error) { r := f.Repo(repoID) if r == nil { return nil, ErrRepoNotFound } if r.Archived { return nil, ErrRepoArchived } if !r.Can(actor, RoleWriter) { return nil, ErrUnauthorized } if !ValidPkgPath(path) { return nil, ErrInvalidPkgPath } if ref != "" && !ValidRefName(ref) { return nil, ErrInvalidRefName } if oid != "" && !ValidOID(oid) { return nil, ErrInvalidOID } if tag != "" && !ValidText(tag, MaxTagLen) { return nil, ErrInvalidText } if owner := f.packages.Get(path); owner != nil { if existing := owner.(*Package); existing.RepoID != repoID { return nil, ErrPackageClaimed } } p := &Package{ Path: path, RepoID: repoID, Ref: ref, OID: oid, Tag: tag, PublishedAt: height, PublishedBy: actor, } f.packages.Set(path, p) r.packages.Set(path, p) return p, nil } // UnpublishPackage withdraws the claim. The package stays on chain; only the // repo's assertion about it goes away. func (f *Forge) UnpublishPackage(actor address, repoID, path string) error { r := f.Repo(repoID) if r == nil { return ErrRepoNotFound } if !r.Can(actor, RoleWriter) { return ErrUnauthorized } if _, removed := r.packages.Remove(path); !removed { return ErrPackageNotFound } f.packages.Remove(path) return nil } // PackageRepo is the lookup an explorer makes: given a deployed path, which // repo claims to publish it. Returns "" when nobody does. func (f *Forge) PackageRepo(path string) string { v := f.packages.Get(path) if v == nil { return "" } return v.(*Package).RepoID } // PackageAt returns the full claim for a path, or nil. func (f *Forge) PackageAt(path string) *Package { v := f.packages.Get(path) if v == nil { return nil } return v.(*Package) } // PackageCount is how many paths the whole forge claims. func (f *Forge) PackageCount() int { return f.packages.Size() } // IteratePackages walks every claimed path in the forge, in path order. func (f *Forge) IteratePackages(offset, count int, cb func(*Package) bool) { if count <= 0 { count = f.packages.Size() } f.packages.IterateByOffset(offset, count, func(_ string, v any) bool { return cb(v.(*Package)) }) } // PackageCount is how many paths this repo claims. func (r *Repo) PackageCount() int { return r.packages.Size() } // IteratePackages walks the paths this repo claims, in path order. func (r *Repo) IteratePackages(offset, count int, cb func(*Package) bool) { if count <= 0 { count = r.packages.Size() } r.packages.IterateByOffset(offset, count, func(_ string, v any) bool { return cb(v.(*Package)) }) }
- #24pkg_test.gno
- Attached funds
- 6000000ugnot
- Package
- gno.land/p/moul/pilot/v0
Arguments · 7
- #1pilot
- #2README.md
- #3# `gno.land/p/moul/pilot/v0` A realm-driven account: **one realm holds the funds and the identity, a key pilots it, and its powers arrive afterwards as separate realms it never imports.** The gno answer to a Gnosis Safe with modules. Live instance: [`r/moul/pilot`](../../../r/moul/pilot). A power: [`r/moul/x/pilotdemo`](../../../r/moul/x/pilotdemo). ## Why it is not just a multisig gno has **no dynamic call**: a realm cannot invoke an arbitrary package path with runtime-built arguments. So an account can never execute arbitrary calldata the way a Safe does. Every outbound action has to be Go code in some realm. The inversion that makes it work: the account is deployed once and stores `Module` values handed to it by realms that did not exist at the time. The power is the calldata, published as readable source, and installing one costs no redeploy of the account. ## The two grants, and the only difference that matters | | `GrantPurse` | `GrantIdentity` | |---|---|---| | spends | the main treasury, metered | its own sub-treasury `account#subpath` | | can act as the account toward other realms | no | yes | | `Revoke` takes it back | **yes, immediately** | **no, never** | A `Purse` is a type this package declares, so every call on one re-enters this code and re-reads the live roster and budget. A module that stashed a purse and calls it a year later still goes through the check. A sub-identity token is the opposite. The token itself cannot be persisted, but `banker.NewBanker` authorizes at construction and re-checks nothing ever again, so a module can mint one from the lent token and keep it. `Revoke` shuts the account's door and does not reach that banker. The blast radius is exactly what the sub-address was funded with, and it is permanent. Grant an identity only to code you have read, and note that you can read it: module source is on chain before you approve it. ## Shape ```go // once, from the account realm acct := pilot.New(0, cur) // the owner, through the account realm's crossing functions acct.Approve(0, cur, "gno.land/r/you/somepower/v0", "power", pilot.GrantPurse, 1_000) acct.Fund(0, cur, path, 500) acct.SetBudget(0, cur, path, 2_000) acct.Revoke(0, cur, path) acct.Exec(0, cur, path, args) // the module realm, with its own cur: the account reads the path from the // runtime and never from an argument h := account.Handle() h.Register(0, cur, self) purse := h.PurseFor(0, cur) ``` ## Both realms in the pair must be public A module's object is persisted in the account's roster, and a value of a type defined in a private realm cannot be persisted by anyone else. A private account realm cannot be imported at all, and a redeploy would wipe the owner, the roster and every budget while leaving the coins at the address. `private = true` is wrong for both halves, and each `gnomod.toml` says so. <!-- 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. **Dependency graph:**  > ⚠️ **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 -->
- #4gnomod.toml
- #5module = "gno.land/p/moul/pilot/v0" gno = "0.9"
- #6pilot.gno
- #7// Package pilot is a realm-driven account: one realm holds the funds and the // identity, a key pilots it, and its powers are separate realms installed // afterwards without ever redeploying it. The gno answer to a Gnosis Safe // with modules. // // An account realm keeps a *Pilot private and hands modules a narrow // [Account] handle. Everything privileged stays on *Pilot, which is never // returned, so a module can only reach the two methods it needs. // // Two ways to delegate, and they differ in exactly one property: // // - A [Purse] is revocable. Every method on it re-enters the declaring // package and re-checks the live roster and budget, so Revoke and // SetBudget take effect immediately, even on a purse a module retained. // - A sub-identity token (rlm.Sub) is permanent. It lets the module act as // "<account>#<subpath>" toward any other realm, which a purse cannot do, // but the module can mint a banker from it and keep it forever. Removing // the module does not take that back; only emptying the sub-address does. // Grant one only to code you have read. // // Live instance: r/moul/pilot. Demo module: r/moul/x/pilotdemo. package pilot import ( "chain" "chain/banker" "errors" "strings" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ufmt/v0" ) // Module is implemented by a module realm and installed into an account. // The account never imports it: it learns the module only as this interface. // // Run is threaded (the leading int keeps it out of crossing-function // territory, which a /p/ package may not declare). rlm is whatever the // account chose to delegate: its own sub-identity token for a trusted // module, or a zero value when the module was installed purse-only. type Module interface { Name() string Run(_ int, rlm realm, args string) string } // Grant says what a module was given. type Grant uint8 const ( // GrantPurse is funds only, revocable at any time. GrantPurse Grant = iota // GrantIdentity also lends the account's sub-identity. Permanent. GrantIdentity ) func (g Grant) String() string { if g == GrantIdentity { return "identity" } return "purse" } var ( ErrNotOwner = errors.New("pilot: not the owner") ErrNotApproved = errors.New("pilot: path not approved") ErrNotInstalled = errors.New("pilot: module not installed") ErrRevoked = errors.New("pilot: module revoked") ErrOverBudget = errors.New("pilot: over budget") ErrStaleRealm = errors.New("pilot: stale realm value") ErrBadName = errors.New("pilot: a path and a subpath are [a-zA-Z0-9._/-] and not empty") ) type slot struct { mod Module subpath string grant Grant budget int64 spent int64 runs int64 live bool } // Pilot is the account. The realm that constructs it owns it and must not // hand it out; hand out [Pilot.Handle] instead. type Pilot struct { owner address host string // the account realm's own pkgpath vault banker.Banker // over host's address, minted once at New slots *avl.Tree // module pkgpath -> *slot handle *Account } // Account is the module-facing half: two methods, both keyed on the calling // realm's OWN pkgpath, which a realm cannot forge for another. type Account struct { p *Pilot } // New builds an account owned by the caller of the crossing function that // reaches it. Call it once, from the account realm, passing that realm's cur. func New(_ int, rlm realm) *Pilot { assertCurrent(0, rlm) p := &Pilot{ owner: rlm.Previous().Address(), host: rlm.PkgPath(), vault: banker.NewBanker(banker.BankerTypeRealmSend, rlm), slots: avl.NewTree(), } p.handle = &Account{p: p} return p } // assertPlain is why Render can interpolate a path without escaping it: the // only two strings a caller ever puts in the roster are checked here, at write // time, against the charset a package path and a subpath are built from. func assertPlain(s string) { if s == "" { panic(ErrBadName) } for i := 0; i < len(s); i++ { c := s[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': case c == '.', c == '/', c == '-', c == '_': default: panic(ErrBadName) } } } func assertCurrent(_ int, rlm realm) { if !rlm.IsCurrent() { panic(ErrStaleRealm) } } // AssertOwner panics unless the user behind rlm owns the account. Only the // account realm may call this: rlm must be its own live cur, so that // rlm.Previous() is the signer and not some intermediary's caller. func (p *Pilot) AssertOwner(_ int, rlm realm) { assertCurrent(0, rlm) if rlm.PkgPath() != p.host { panic(ErrNotOwner) } if rlm.Previous().Address() != p.owner { panic(ErrNotOwner) } } // Handle is what an account realm exposes to modules. func (p *Pilot) Handle() *Account { return p.handle } func (p *Pilot) Owner() address { return p.owner } func (p *Pilot) Host() string { return p.host } // Address is the account's main treasury. func (p *Pilot) Address() address { return chain.PackageAddress(p.host) } // SubAddress is one module's own treasury, derivable off-chain by anyone. func (p *Pilot) SubAddress(path string) address { s := p.mustSlot(path) return chain.DerivePkgSubAddr(p.host, s.subpath) } func (p *Pilot) mustSlot(path string) *slot { s, ok := p.slots.Get(path).(*slot) if !ok { panic(ErrNotInstalled) } return s } // Approve authorises a package path to install itself later. The code does // not have to exist yet, which is the whole point: the account is deployed // once and learns new powers afterwards. func (p *Pilot) Approve(_ int, rlm realm, path, subpath string, grant Grant, budget int64) { p.AssertOwner(0, rlm) assertPlain(path) assertPlain(subpath) if old, ok := p.slots.Get(path).(*slot); ok { old.subpath = subpath old.grant = grant old.budget = budget old.spent = 0 // a new approval is a new allowance return } p.slots.Set(path, &slot{subpath: subpath, grant: grant, budget: budget}) } // SetBudget is the live knob. It applies to a purse a module already holds. func (p *Pilot) SetBudget(_ int, rlm realm, path string, budget int64) { p.AssertOwner(0, rlm) p.mustSlot(path).budget = budget } // Revoke stops a module. It takes back its purse immediately; it does NOT // take back a sub-identity that was granted, nor any banker minted from one. func (p *Pilot) Revoke(_ int, rlm realm, path string) { p.AssertOwner(0, rlm) p.mustSlot(path).live = false } // Fund moves coins from the account's main treasury into a module's // sub-treasury. For an identity grant this is the permanent blast radius. func (p *Pilot) Fund(_ int, rlm realm, path string, amount int64) { p.AssertOwner(0, rlm) s := p.mustSlot(path) p.vault.SendCoins(p.Address(), chain.DerivePkgSubAddr(p.host, s.subpath), chain.NewCoins(chain.NewCoin("ugnot", amount))) } // Exec drives an installed module. rlm must be the account realm's own live // cur: an identity grant mints its sub-token from it, which only the account // realm's namespace can do. func (p *Pilot) Exec(_ int, rlm realm, path, args string) string { p.AssertOwner(0, rlm) s := p.mustSlot(path) if !s.live { panic(ErrRevoked) } s.runs++ if s.grant == GrantIdentity { return s.mod.Run(0, rlm.Sub(s.subpath), args) } return s.mod.Run(0, rlm, args) } // Register is called BY the module realm, with its own live cur. The account // reads the path from the runtime and never from an argument. func (a *Account) Register(_ int, rlm realm, m Module) { assertCurrent(0, rlm) path := rlm.PkgPath() s, ok := a.p.slots.Get(path).(*slot) if !ok { panic(ErrNotApproved) } s.mod = m s.live = true } // PurseFor hands the calling module its revocable purse. func (a *Account) PurseFor(_ int, rlm realm) *Purse { assertCurrent(0, rlm) path := rlm.PkgPath() if _, ok := a.p.slots.Get(path).(*slot); !ok { panic(ErrNotApproved) } return &Purse{p: a.p, path: path} } // Purse is a capability this package declares, so every use of it runs here // and is re-checked against live state. That is what makes it revocable, // where a banker minted from a lent realm token is not. type Purse struct { p *Pilot path string } // Pay spends from the account's main treasury, against the module's budget. func (u *Purse) Pay(to address, amount int64) { s, ok := u.p.slots.Get(u.path).(*slot) if !ok { panic(ErrNotInstalled) } if !s.live { panic(ErrRevoked) } if amount <= 0 || amount > s.budget-s.spent { panic(ErrOverBudget) } s.spent += amount u.p.vault.SendCoins(u.p.Address(), to, chain.NewCoins(chain.NewCoin("ugnot", amount))) } // Left is what this module may still spend. func (u *Purse) Left() int64 { s, ok := u.p.slots.Get(u.path).(*slot) if !ok || !s.live { return 0 } return s.budget - s.spent } // Render is the account page. The account realm forwards its Render here. func (p *Pilot) Render(path string) string { var sb strings.Builder sb.WriteString("# " + p.host + "\n\n") sb.WriteString("| | |\n|---|---|\n") sb.WriteString("| owner | " + p.owner.String() + " |\n") sb.WriteString("| treasury | " + p.Address().String() + " |\n") sb.WriteString(ufmt.Sprintf("| balance | %d ugnot |\n\n", p.balance(p.Address()))) if p.slots.Size() == 0 { sb.WriteString("No power installed yet.\n") return sb.String() } sb.WriteString("## powers\n\n") sb.WriteString("| path | grant | identity | state | left | runs | sub-treasury |\n") sb.WriteString("|---|---|---|---|---|---|---|\n") p.slots.Iterate("", "", func(key string, v any) bool { s := v.(*slot) state := "approved" if s.live { state = "installed" } else if s.mod != nil { state = "revoked" } sub := chain.DerivePkgSubAddr(p.host, s.subpath) sb.WriteString(ufmt.Sprintf("| `%s` | %s | `%s#%s` | %s | %d | %d | %d ugnot |\n", key, s.grant.String(), p.host, s.subpath, state, s.budget-s.spent, s.runs, p.balance(sub))) return false }) return sb.String() } func (p *Pilot) balance(addr address) int64 { return banker.NewReadonlyBanker().GetCoin(addr, "ugnot") }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1facade
- #2README.md
- #3# `gno.land/r/moul/x/upgrade/schema/facade/v0` The **permanent entry point** of pattern G. Holds one `Call(cur, verb, payload)` signature forever and moves the API into data each handler declares: it parses the schema, checks arity before dispatch, enumerates verbs without a transaction, and refuses an upgrade whose schema would break an existing caller. See [the pattern](../README.md) and [the exploration](../../README.md). <!-- 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. **Dependency graph:**  > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4facade.gno
- #5// untrusted-render: every string Render echoes is either a verb name validated // by parseSchema against [a-z0-9_] at accept time, or a package path read off a // crossing frame. No caller-typed payload is ever rendered. // // Package facade is the permanent entry point of the "API as data" upgrade // pattern (pattern G of the exploration; see ../../README.md). // // Patterns E and F put a Go interface at the permanent path, which fixes the // method set at deploy: adding an operation later needs a whole extra realm. // This one puts a single entry point there instead, // // Call(cur realm, verb, payload string) string // // and moves the API into DATA that each implementation declares. The signature // that can never change is that one line; everything the application does can // still grow. // // Three things fall out of the API being data, and they are the reason to pay // the price below: // // 1. Callers can ENUMERATE it. Verbs, Signature and SchemaText answer without // a transaction, so a client discovers the API instead of being compiled // against it. // 2. Payloads are CHECKED before the handler runs, so an arity mistake is one // abort with a readable message rather than whatever the handler does with // the wrong number of arguments. // 3. Upgrades can be DIFFED. Accept refuses a handler whose schema would drop // or reshape a verb some existing caller depends on, which no amount of Go // interface satisfaction can catch: a handler is free to satisfy Handler // and answer nothing. // // The price is the type system. Arguments are strings a caller encodes, and a // misspelled verb is an abort at runtime rather than a compile error. Pattern F // is the other side of that trade and both ship here on purpose. // // State is deliberately out of scope. These handlers are pure; where an // application's data should live is pattern C's question, and the answer does // not change because the entry point became a string. package facade import ( "strings" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ownable/v0" "gno.land/p/nt/ufmt/v0" ) const owner address = "g1manfred47kzduec920z88wfr64ylksmdcedlf5" // @moul const prefix = "gno.land/r/moul/x/upgrade/schema/impl/" // sep is the payload separator. A single byte, because the point here is the // shape of the boundary and not the encoding: a real one would need escaping, // and choosing it is a decision this pattern does not make for you. const sep = "|" // Handler is the whole interface an implementation satisfies. It never changes, // because everything that would have changed it is in Schema instead. type Handler interface { // Schema declares the API, one verb per line, "name arg1 arg2". Schema() string // Invoke runs a verb. The facade has already checked that the verb exists // and that args has exactly the declared arity. Invoke(verb string, args []string) string } type verb struct { name string params []string } var ( Ownable = ownable.NewWithAddress(owner) candidates = avl.NewTree() // pkgpath -> Handler live Handler livePath string liveVerbs = avl.NewTree() // verb name -> *verb, for lookup liveOrder []string // the same verbs in DECLARATION order, for listing ) // Propose nominates the calling realm, exactly as in pattern F. Its schema is // parsed here so a malformed one is rejected at proposal rather than at accept. func Propose(cur realm, h Handler) { caller := cur.Previous().PkgPath() if !strings.HasPrefix(caller, prefix) { panic("unauthorized: " + caller + " is not under " + prefix) } if h == nil { panic("handler must not be nil") } parseSchema(h.Schema()) // panics if malformed candidates.Set(caller, h) } // Accept promotes a candidate, and refuses one that would break an existing // caller. This is the check a Go interface cannot express. func Accept(cur realm, pkgPath string) { h, next, order := resolve(cur, pkgPath) assertNoRegression(next) live, livePath, liveVerbs, liveOrder = h, pkgPath, next, order } // AcceptBreaking promotes a candidate that Accept refuses. // // It exists because the diff is SYMMETRIC, which is not obvious until it bites: // once v1 has added a verb, rolling back to v0 drops that verb and is a // regression by exactly the same rule that protects callers going forward. A // pattern that can only move forward is worse than one with no diff at all, so // the escape hatch is required, and making it a separate function is the point: // the owner has to type a different word, and the audit log shows which one. // // Use it to roll back, and to retire a verb nobody calls any more. Not to make // an upgrade go through. func AcceptBreaking(cur realm, pkgPath string) { h, next, order := resolve(cur, pkgPath) live, livePath, liveVerbs, liveOrder = h, pkgPath, next, order } // resolve is the owner check and the lookup both accepts share. func resolve(cur realm, pkgPath string) (Handler, *avl.Tree, []string) { Ownable.AssertOwnedBy(cur.Previous().Address()) v := candidates.Get(pkgPath) if v == nil { panic("no candidate at " + pkgPath) } h := v.(Handler) t, order := parseSchema(h.Schema()) return h, t, order } // assertNoRegression is the upgrade diff. A new schema may ADD verbs and may not // remove one or change its arity, because a caller compiled against the old one // is still out there calling it. func assertNoRegression(next *avl.Tree) { // Walk in DECLARATION order, not avl order, so the verb named in the abort // is the first one a reader of the live schema would reach. Iterating the // tree reports whichever violation happens to sort first, which makes the // message depend on a verb's spelling. for _, name := range liveOrder { old := liveVerbs.Get(name).(*verb) n := next.Get(name) if n == nil { panic("schema regression: the candidate drops verb " + name + ", which an existing caller may still call; AcceptBreaking overrides") } if len(n.(*verb).params) != len(old.params) { panic("schema regression: the candidate changes the arity of verb " + name + "; AcceptBreaking overrides") } } } // Call is the one signature this realm is committed to forever. func Call(cur realm, verbName, payload string) string { assertLive() v := liveVerbs.Get(verbName) if v == nil { panic("unknown verb " + verbName + ", known: " + strings.Join(Verbs(), ", ")) } spec := v.(*verb) args := []string{} if payload != "" { args = strings.Split(payload, sep) } if len(args) != len(spec.params) { panic(ufmt.Sprintf("verb %s takes %d argument(s), got %d, signature is %s", verbName, len(spec.params), len(args), Signature(verbName))) } return live.Invoke(verbName, args) } // Verbs lists the accepted API in DECLARATION order, which is the order the // handler wrote it in and the order a reader of the schema expects. Iterating // the avl tree instead would list them alphabetically, silently: caught by a // test, not by a compiler. func Verbs() []string { return liveOrder } // Signature is one verb's shape, as a caller would write it. func Signature(name string) string { v := liveVerbs.Get(name) if v == nil { return "" } return name + "(" + strings.Join(v.(*verb).params, ", ") + ")" } // SchemaText is the whole accepted API in the declaration format, so a client // can read back exactly what the handler declared. func SchemaText() string { out := "" for _, n := range Verbs() { v := liveVerbs.Get(n).(*verb) out += n for _, p := range v.params { out += " " + p } out += "\n" } return out } // Live is the package path currently serving, or "" before the first Accept. func Live() string { return livePath } // Candidates lists every path that has nominated itself, in order. func Candidates() []string { out := []string{} candidates.Iterate("", "", func(k string, _ any) bool { out = append(out, k) return false }) return out } func assertLive() { if live == nil { panic("no handler accepted") } } // parseSchema turns the declaration text into verbs, and is the only validation // of a name that Render later echoes. func parseSchema(text string) (*avl.Tree, []string) { t := avl.NewTree() order := []string{} for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" { continue } fields := strings.Split(line, " ") name := fields[0] assertIdent(name) params := []string{} for _, f := range fields[1:] { if f == "" { continue } assertIdent(f) params = append(params, f) } if t.Get(name) != nil { panic("malformed schema: verb " + name + " declared twice") } t.Set(name, &verb{name: name, params: params}) order = append(order, name) } if t.Size() == 0 { panic("malformed schema: no verbs declared") } return t, order } func assertIdent(s string) { if s == "" { panic("malformed schema: empty name") } for _, c := range s { if !(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_' { panic("malformed schema: " + s + " is not [a-z0-9_]") } } } func Render(_ string) string { if live == nil { return ufmt.Sprintf("schema/facade/v0: no handler accepted (%d candidate(s))\n", candidates.Size()) } out := ufmt.Sprintf("schema/facade/v0: %s\n", livePath) for _, n := range Verbs() { out += "- " + Signature(n) + "\n" } return out }
- #6gnomod.toml
- #7module = "gno.land/r/moul/x/upgrade/schema/facade/v0" gno = "0.9" # public: every implementation realm imports it to declare its schema and register
- #8render_example_test.gno
- #9package facade // ExampleRender pins the facade before any handler realm exists. Nothing is // imported here, so there is no candidate and nothing accepted. func ExampleRender() { print(Render("")) // Output: // schema/facade/v0: no handler accepted (0 candidate(s)) }
Result log
msg:0,success:true,log:,events:[] msg:1,success:true,log:,events:[] msg:2,success:true,log:,events:[]