Skip to content

gno2: interrealm syntax (or possibly for gno1) #4223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
jaekwon opened this issue Apr 27, 2025 · 6 comments
Open

gno2: interrealm syntax (or possibly for gno1) #4223

jaekwon opened this issue Apr 27, 2025 · 6 comments
Labels

Comments

@jaekwon
Copy link
Contributor

jaekwon commented Apr 27, 2025

The Problem:

Commit 63a35bf on the meta4 branch:

--- a/examples/gno.land/r/gov/dao/v3/impl/prop_requests.gno
+++ b/examples/gno.land/r/gov/dao/v3/impl/prop_requests.gno
@@ -32,11 +32,13 @@ func NewUpgradeDaoImplRequest(newDao dao.DAO, realmPkg, reason string) dao.Propo
	}
 
	cb := func() error {
-               dao.UpdateImpl(dao.UpdateRequest{
-                       DAO:         newDao,
-                       AllowedDAOs: []string{"gno.land/r/gov/dao/v3/impl", realmPkg}, // keeping previous realm just in case something went wrong
-               })
-
+               cross(func() {
+                       crossing()
+                       cross(dao.UpdateImpl)(dao.UpdateRequest{
+                               DAO:         newDao,
+                               AllowedDAOs: []string{"gno.land/r/gov/dao/v3/impl", realmPkg}, // keeping previous realm just in case something went wrong
+                       })
+               })()
		return nil
	}

The previous non-crossing dao.UpdateImpl() call was valid because the
containing closure happened to be called from the gov/dao realm. But the logic
was failing because gov/dao.UpdateImpl() was expecting the previous realm to
be v3/impl.

This is similar to the re-entrancy issue that cross/crossing is supposed to
solve, but the function being called (dao.UpdateImpl) isn't an arbitary
caller-provided variable function. If it were such a variable function basic
unit testing would have revealed that it should have been cross-called.

Here the problem manifested as a denial, but it could also manifest
as an exploit in other situations.

// PKGPATH: r/bob
cb := func() error {
	// previous realm: r/alice
	//  current realm: r/bob
	cross(alice.Charge)() <-- charges r/bob
	alice.Charge() <-- charges r/alice
}
...

In the above example Bob doesn't really have any business calling alice.Charge,
a crossing-function, without cross(fn)(...). The type-checker or preprocessor
can help with that, but it doesn't solve the problem.

var fn func() = ...
cb := func() error {
	// The type-checker doesn't know whether fn is a crossing or
	// non-crossing function. It could be implied from usage, but
	// sometimes usage isn't provided, and besides that's not what
	// we want to achieve with a type-checked language.
	cross(fn)()
	fn()
}

While cross makes the intended usage explicit and clear, still remains the
problem of whether a non-crossing usage should even be allowed.

The general problem: function type arguments, or function values that
are provided by external realm callers are not generally safe to call,
because they may contain non-crossing calls to crossing functions
declared in the same realm as the function.

The specific problem: the non-crossing call of alice.Charge() is not
prohibited from bob's code.

The Solution: make alice.Charge() take an argument that bob doesn't
know or cannot provide even if known.

// PKGPATH: r/alice
func Charge(cur realm) {
	cur.PkgPath() // is always r/alice
	...
}
// PKGPATH: r/bob
cb := func() error {
	// previous realm: r/alice
	//  current realm: r/bob
	alice.Charge() <-- preprocess error: "missing realm; pass in the current realm or cross-call"
	alice.Charge(cur) <-- preprocess error: "cur undefined"
	cross(alice.Charge)()
}
cb := func(cur realm) error {
	cur.PkgPath() // is always r/bob
	alice.Charge(cur) <-- preprocess error: "realm mismatch"
	(cross(alice.Charge)() // crossing to alice
}
...

With the new rules, it would would have been clear at the preprocess
stage that dao.UpdateImpl() had to be called crossing.

It would also be clear at the preprocess stage even if the function
were a variable:

var fn1 func(realm) = ...
var fn2 extrealm.Func = ...
cb := func(cur realm) error {
	cur.PkgPath() // is always r/bob
	fn1(cur) <-- preprocess error: "passing realm to an unnamed function type"
	fn2(cur) <-- preprocess error: "passing realm to an external function type"
}

The Solution:

import (
	"gno.land/r/extrealm"
)

// First argument is 'realm' type, thus a crossing function.
// 'realm' type argument must be the first argument.
func Public1(cur realm, x int) {

	// realm is like *Realm:
	println(cur.Address(), cur.PkgPath())

	// Call a crossing function of same realm without crossing:
	Public2(cur, "hello)

	// Call a crossing function of same realm with crossing:
	cross(Public2)("bye")

	// Call a crossing function literal:
	func(cur realm) {
		Public2(cur, "dontcare")
	}(cur)

	// Can be used as a type:
	var x realm = nil
	x = cur
	println(x) // prints Realm{g1...,"gno.land/r/myrealm"}

	// Can be used to get the previous realm:
	prev := cur.Previous()
	prev == std.PreviousRealm() // true, but std.PreviousRealm() should be deprecated.

	// Can be used to get ancestor realms:
	prev.Previous() // the one before previous

	// Cannot pass cur to an external realm function:
	extrealm.Public3(cur, "dontcare") <-- preprocess error: "realm mismatch"

	// Cannot pass cur to an unnamed function type variable:
	var x func(realm) = ...
	x(cur) <-- preprocess error: "passing realm to an unnamed function type"

	// Can pass cur to a named function type variable declared in same realm:
	type myCrossingFn func(realm)
	var x myCrossingFn = ...
	x(cur) // OK

	// Cannot pass cur to an external function type:
	var y extrealm.CrossingFn = ...
	y(cur) <-- preprocess error: "passing realm to an external function type"

	// Cannot be used as anything but the first argument type:
	func(_ int, cur realm) { ... }(cur) <-- preprocess error: "realm must be the first argument"

	// Cannot use secondary variables as 'cur':
	Public2(x, "dontcare") <-- preprocess error: "can only use 'cur' but got 'x'"

	// Cannot be captured in a closure:
	func() {
		// The current realm may not be cur.
		Public2(cur, "dontcare") <-- preprocess error: "realm cannot be captured in a closure"

		// Public2 can still be cross-called.
		cross(Public2)("this works")
	}()
}

func Public2(cur realm, y string) { ... }

This has the benefit that crossing functions and non-crossing functions have distinct types, so the distinction is type-checked.

Proposed Action

Transpile existing Gno code to new Gno code that conforms to the above within 24 hours.

@notJoon
Copy link
Member

notJoon commented Apr 28, 2025

I like the concept you've proposed. This approach would be useful for managing crossing explicitly. Below are some alternatives I've considered. It's a somewhat radical idea, but what if we add a separate function keyword and partially use generic syntax?

type Cross[T any] T

cross func Foo(a Cross[T], b T) string { /* ... */ }

This approach adds a cross keyword to explicitly indicate that the function is crossing, and leverages the type system to explicitly show that specific parameters or variables are also crossing.

This structure references patterns like Rust's unsafe fn + *const T or TypeScript's async + Promise<T>.

For example, in TypeScript:

async function fetchText(url: string): Promise<string> {
    const response = await fetch(url);
    return response.text();
}

fetchText("https://example.com/data")
    .then(text => console.log("Result:", text))
    .catch(err => console.error("Error:", err));

The async keyword marks asynchronicity in the function declaration, and at the type level, the return type Promise<string> expresses that it needs to be handled with await or catch.

Using this approach, examples of cross usage could be:

// Define a wrapper type
type Cross[T any] T

// Function declarations use the 'cross' keyword to indicate crossing functions
// (non-crossing functions use regular func as usual)
cross func Register(name string) {
    // …
}

func Render() string {
    return "rendered"
}

// The 'cross' keyword can also be used within interfaces
type MyInterface interface {
    cross Hello() // crossing method
    Print()       // non-crossing method
}

// When passing crossing functions as parameters, use the wrapper type
func foo(x Cross[func() int]) {
    // x can only be passed as a crossing function
    _ = x()
}

// Rules for calling & assigning between crossing functions
cross func Pub1() {
    Pub2() // OK: calling same package(non-cross) function
    Pub2() // OK: can call Pub2 because it's a cross function
}

cross func Pub2() {
    // …
}

// When only one of two function parameters is crossing
cross func Exec(
    ex1 Cross[func()], // crossing
    ex2 func(),        // non-crossing
) {
    ex1() // OK: crossing call
    ex2() // OK: non-crossing call (same package)

    // Assignment examples
    var a1 Cross[func()] = ex1 // OK: same type
    var a2 func()        = ex1 // OK: demotion allowed
    // var a3 func()    = a1    // INVALID: requires explicit downcast
    // var a4 Cross[func()] = ex2 // INVALID: can't assign non-crossing to cross
}

Here, I've used:

  • Cross[T]: Tracking crossing status at the type level
  • cross func: Indicating crossing functions at the keyword level

This allows us to:

  1. Distinguish function domains in declarations
  2. Manage crossing status of parameters/return types more precisely in signatures

However, gno doesn't currently support generic types, and adding reserved words or keywords requires changing the parser and grammar rules, and this grammatical changes could increase the learning curve. The benefit is that we can clearly express the risk level or effect of APIs at both keyword and type levels.

If we should avoid modifying syntax we could consider using comments like Go's function directives. Like //go:noinline, we could add a pragma just above the line to be applied:

//gno:cross
func Foo(...) { /* ... */ }

This way, we can handle it with comments, so there's no need to modify the parser. The type checker would still need changes, though.

It also seems possible to apply crossing at specific scope levels. For example, here's how to apply crossing to an entire package:

//gno:cross
package payment

func Gateway(...) { /* ... */ }
func Charge(...) { /* ... */ }

@jaekwon
Copy link
Contributor Author

jaekwon commented Apr 28, 2025

I don't see why there's a need for separate Cross[t], why not just cross func.

Updated OP to support unnamed type specifications @func () int.

@notJoon
Copy link
Member

notJoon commented Apr 28, 2025

I don't see why there's a need for separate Cross[t], why not just cross func.

Updated OP to support unnamed type specifications @func () int.

I was thinking about a Cross[T] type for considering an effectful structure. just the cross function would sufficient.

@jaekwon jaekwon added the Gno2 label Apr 29, 2025
@jaekwon jaekwon changed the title gno2: @funcname for crossing gno2: interrealm syntax (or possibly for gno1) May 7, 2025
@jaekwon
Copy link
Contributor Author

jaekwon commented May 7, 2025

Updated the first post with a new suggestion w/ cur realm as first argument.

@aeddi
Copy link
Contributor

aeddi commented May 7, 2025

This is cool:

	// Can be used to get the previous realm:
	prev := cur.Previous()
	
	// Can be used to get ancestor realms:
	prev.Previous() // the one before previous

I wonder if some attacks would be possible by persisting a ⁠cur realm between different calls, especially if these keep a reference to the origin caller and the stack of previous Realms.

@jaekwon
Copy link
Contributor Author

jaekwon commented May 8, 2025

This is cool:

// Can be used to get the previous realm:
prev := cur.Previous()

// Can be used to get ancestor realms:
prev.Previous() // the one before previous
I wonder if some attacks would be possible by persisting a ⁠cur realm between different calls, especially if these keep a reference to the origin caller and the stack of previous Realms.

Ah yeah, should add "cannot be persisted" until we understand how it could be misued.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Development

No branches or pull requests

4 participants