Skip to content

Commit

Permalink
feat: new govdao pattern with context (#2380)
Browse files Browse the repository at this point in the history
This PR introduces a new pattern for arbitrary govdao proposals using
Gno code using contexts. It involves wrapping the provided closure with
a system that configures a `context.Context` with a field indicating
that the execution occurs in the context of an `approvedByGovDao`
proposal. This opens the door to a new ACL system, not based on
`PrevRealm()`, but on a context-based "certification" system.

I believe this pattern makes sense to exist; however, here are some
drawbacks I can see:

1. `context.Context` is not yet needed (no goroutine support), and we
may eventually never need this package depending on how we implement
goroutines internally. (h/t @thehowl)
2. `context.Context` is used like an environment variable, which
introduces implicitness—something we usually try to avoid to make Gno a
very explicit language. Explicitness brings “simplicity” and
“verifiability.”
3. The usual Go idiomatic way of using `context` is to pass it as the
first argument. If this becomes more common, it will result in more
contracts having exposed functions that are not callable by `maketx
call` (though they can be called with `maketx run` or via an import).

Depends on #2379  
Related to #2386

---------

Signed-off-by: moul <94029+moul@users.noreply.github.com>
Co-authored-by: Antonio Navarro Perez <antnavper@gmail.com>
  • Loading branch information
moul and ajnavarro authored Jul 8, 2024
1 parent 8ddf0d8 commit a40ac61
Show file tree
Hide file tree
Showing 9 changed files with 324 additions and 13 deletions.
72 changes: 72 additions & 0 deletions examples/gno.land/p/demo/context/context.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Package context provides a minimal implementation of Go context with support
// for Value and WithValue.
//
// Adapted from https://github.com/golang/go/tree/master/src/context/.
// Copyright 2016 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package context

type Context interface {
// Value returns the value associated with this context for key, or nil
// if no value is associated with key.
Value(key interface{}) interface{}
}

// Empty returns a non-nil, empty context, similar with context.Background and
// context.TODO in Go.
func Empty() Context {
return &emptyCtx{}
}

type emptyCtx struct{}

func (ctx emptyCtx) Value(key interface{}) interface{} {
return nil
}

func (ctx emptyCtx) String() string {
return "context.Empty"
}

type valueCtx struct {
parent Context
key, val interface{}
}

func (ctx *valueCtx) Value(key interface{}) interface{} {
if ctx.key == key {
return ctx.val
}
return ctx.parent.Value(key)
}

func stringify(v interface{}) string {
switch s := v.(type) {
case stringer:
return s.String()
case string:
return s
}
return "non-stringer"
}

type stringer interface {
String() string
}

func (c *valueCtx) String() string {
return stringify(c.parent) + ".WithValue(" +
stringify(c.key) + ", " +
stringify(c.val) + ")"
}

// WithValue returns a copy of parent in which the value associated with key is
// val.
func WithValue(parent Context, key, val interface{}) Context {
if key == nil {
panic("nil key")
}
// XXX: if !reflect.TypeOf(key).Comparable() { panic("key is not comparable") }
return &valueCtx{parent, key, val}
}
96 changes: 96 additions & 0 deletions examples/gno.land/p/demo/context/context_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package context

import "testing"

func TestContextExample(t *testing.T) {
type favContextKey string

k := favContextKey("language")
ctx := WithValue(Empty(), k, "Gno")

if v := ctx.Value(k); v != nil {
if string(v) != "Gno" {
t.Errorf("language value should be Gno, but is %s", v)
}
} else {
t.Errorf("language key value was not found")
}

if v := ctx.Value(favContextKey("color")); v != nil {
t.Errorf("color key was found")
}
}

// otherContext is a Context that's not one of the types defined in context.go.
// This lets us test code paths that differ based on the underlying type of the
// Context.
type otherContext struct {
Context
}

type (
key1 int
key2 int
)

// func (k key2) String() string { return fmt.Sprintf("%[1]T(%[1]d)", k) }

var (
k1 = key1(1)
k2 = key2(1) // same int as k1, different type
k3 = key2(3) // same type as k2, different int
)

func TestValues(t *testing.T) {
check := func(c Context, nm, v1, v2, v3 string) {
if v, ok := c.Value(k1).(string); ok == (len(v1) == 0) || v != v1 {
t.Errorf(`%s.Value(k1).(string) = %q, %t want %q, %t`, nm, v, ok, v1, len(v1) != 0)
}
if v, ok := c.Value(k2).(string); ok == (len(v2) == 0) || v != v2 {
t.Errorf(`%s.Value(k2).(string) = %q, %t want %q, %t`, nm, v, ok, v2, len(v2) != 0)
}
if v, ok := c.Value(k3).(string); ok == (len(v3) == 0) || v != v3 {
t.Errorf(`%s.Value(k3).(string) = %q, %t want %q, %t`, nm, v, ok, v3, len(v3) != 0)
}
}

c0 := Empty()
check(c0, "c0", "", "", "")

t.Skip() // XXX: depends on https://github.com/gnolang/gno/issues/2386

c1 := WithValue(Empty(), k1, "c1k1")
check(c1, "c1", "c1k1", "", "")

/*if got, want := c1.String(), `context.Empty.WithValue(context_test.key1, c1k1)`; got != want {
t.Errorf("c.String() = %q want %q", got, want)
}*/

c2 := WithValue(c1, k2, "c2k2")
check(c2, "c2", "c1k1", "c2k2", "")

/*if got, want := fmt.Sprint(c2), `context.Empty.WithValue(context_test.key1, c1k1).WithValue(context_test.key2(1), c2k2)`; got != want {
t.Errorf("c.String() = %q want %q", got, want)
}*/

c3 := WithValue(c2, k3, "c3k3")
check(c3, "c2", "c1k1", "c2k2", "c3k3")

c4 := WithValue(c3, k1, nil)
check(c4, "c4", "", "c2k2", "c3k3")

o0 := otherContext{Empty()}
check(o0, "o0", "", "", "")

o1 := otherContext{WithValue(Empty(), k1, "c1k1")}
check(o1, "o1", "c1k1", "", "")

o2 := WithValue(o1, k2, "o2k2")
check(o2, "o2", "c1k1", "o2k2", "")

o3 := otherContext{c4}
check(o3, "o3", "", "c2k2", "c3k3")

o4 := WithValue(o3, k3, nil)
check(o4, "o4", "", "c2k2", "")
}
1 change: 1 addition & 0 deletions examples/gno.land/p/demo/context/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/p/demo/context
5 changes: 4 additions & 1 deletion examples/gno.land/p/gov/proposal/gno.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
module gno.land/p/gov/proposal

require gno.land/p/demo/uassert v0.0.0-latest
require (
gno.land/p/demo/context v0.0.0-latest
gno.land/p/demo/uassert v0.0.0-latest
)
51 changes: 45 additions & 6 deletions examples/gno.land/p/gov/proposal/proposal.gno
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package proposal
import (
"errors"
"std"

"gno.land/p/demo/context"
)

var errNotGovDAO = errors.New("only r/gov/dao can be the caller")
Expand All @@ -16,11 +18,20 @@ func NewExecutor(callback func() error) Executor {
}
}

// NewCtxExecutor creates a new executor with the provided callback function.
func NewCtxExecutor(callback func(ctx context.Context) error) Executor {
return &executorImpl{
callbackCtx: callback,
done: false,
}
}

// executorImpl is an implementation of the Executor interface.
type executorImpl struct {
callback func() error
done bool
success bool
callback func() error
callbackCtx func(ctx context.Context) error
done bool
success bool
}

// Execute runs the executor's callback function.
Expand All @@ -32,9 +43,13 @@ func (exec *executorImpl) Execute() error {
// Verify the executor is r/gov/dao
assertCalledByGovdao()

// Run the callback
err := exec.callback()

var err error
if exec.callback != nil {
err = exec.callback()
} else if exec.callbackCtx != nil {
ctx := context.WithValue(context.Empty(), statusContextKey, approvedStatus)
err = exec.callbackCtx(ctx)
}
exec.done = true
exec.success = err == nil

Expand Down Expand Up @@ -62,6 +77,21 @@ func (exec executorImpl) GetStatus() Status {
}
}

func IsApprovedByGovdaoContext(ctx context.Context) bool {
v := ctx.Value(statusContextKey)
if v == nil {
return false
}
vs, ok := v.(string)
return ok && vs == approvedStatus
}

func AssertContextApprovedByGovDAO(ctx context.Context) {
if !IsApprovedByGovdaoContext(ctx) {
panic("not approved by govdao")
}
}

// assertCalledByGovdao asserts that the calling Realm is /r/gov/dao
func assertCalledByGovdao() {
caller := std.CurrentRealm().PkgPath()
Expand All @@ -70,3 +100,12 @@ func assertCalledByGovdao() {
panic(errNotGovDAO)
}
}

type propContextKey string

func (k propContextKey) String() string { return string(k) }

const (
statusContextKey = propContextKey("govdao-prop-status")
approvedStatus = "approved"
)
12 changes: 11 additions & 1 deletion examples/gno.land/r/gnoland/blog/admin.gno
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"strings"

"gno.land/p/demo/avl"
"gno.land/p/demo/context"
"gno.land/p/gov/proposal"
)

var (
Expand Down Expand Up @@ -39,11 +41,19 @@ func AdminRemoveModerator(addr std.Address) {
moderatorList.Set(addr.String(), false) // FIXME: delete instead?
}

func DaoAddPost(ctx context.Context, slug, title, body, publicationDate, authors, tags string) {
proposal.AssertContextApprovedByGovDAO(ctx)
caller := std.DerivePkgAddr("gno.land/r/gov/dao")
addPost(caller, slug, title, body, publicationDate, authors, tags)
}

func ModAddPost(slug, title, body, publicationDate, authors, tags string) {
assertIsModerator()

caller := std.GetOrigCaller()
addPost(caller, slug, title, body, publicationDate, authors, tags)
}

func addPost(caller std.Address, slug, title, body, publicationDate, authors, tags string) {
var tagList []string
if tags != "" {
tagList = strings.Split(tags, ",")
Expand Down
2 changes: 2 additions & 0 deletions examples/gno.land/r/gnoland/blog/gno.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ module gno.land/r/gnoland/blog
require (
gno.land/p/demo/avl v0.0.0-latest
gno.land/p/demo/blog v0.0.0-latest
gno.land/p/demo/context v0.0.0-latest
gno.land/p/gov/proposal v0.0.0-latest
)
12 changes: 7 additions & 5 deletions examples/gno.land/r/gov/dao/prop1_filetest.gno
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// PKGPATH: gno.land/r/foo/prop01
//
// Please note that this package is intended for demonstration purposes only.
// You could execute this code (the init part) by running a `maketx run` command
// or by uploading a similar package to a personal namespace.
//
// For the specific case of validators, a `r/gnoland/valopers` will be used to
// organize the lifecycle of validators (register, etc), and this more complex
// contract will be responsible to generate proposals.
package main
package prop01

import (
"std"
Expand Down Expand Up @@ -67,20 +69,20 @@ func main() {

// Output:
// --
// - [/r/gov/dao:0](0) - manual valset changes proposal example (by g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm)
// - [/r/gov/dao:0](0) - manual valset changes proposal example (by g1acws3q8u0hjl9pndjy8rqz58q3x05dnd6d99qa)
// --
// # Prop#0
//
// manual valset changes proposal example
// Status: active
// Author: g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm
// Author: g1acws3q8u0hjl9pndjy8rqz58q3x05dnd6d99qa
// --
// --
// # Prop#0
//
// manual valset changes proposal example
// Status: accepted
// Author: g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm
// Author: g1acws3q8u0hjl9pndjy8rqz58q3x05dnd6d99qa
// --
// No valset changes to apply.
// --
Expand All @@ -89,7 +91,7 @@ func main() {
//
// manual valset changes proposal example
// Status: succeeded
// Author: g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm
// Author: g1acws3q8u0hjl9pndjy8rqz58q3x05dnd6d99qa
// --
// Valset changes:
// - #123: g12345678 (10)
Expand Down
Loading

0 comments on commit a40ac61

Please sign in to comment.