Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions gno.land/adr/prxxxx_code_submission_policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# ADR: Code Submission Policy

## Context

Both `MsgAddPackage` and `MsgRun` feed user-supplied code through the Go
typechecker synchronously during transaction delivery. The typechecker has
superlinear performance on adversarial input and no meaningful gas bound, so
a single malicious transaction can consume excessive block-execution time.

Chain operators need a way to restrict code submission to a trusted set of
addresses while the permissionless path matures.

## Decision

Add two parameters to the `vm` module params (`vm:p`):

| Param | Type | Default | Description |
|---|---|---|---|
| `code_submission_policy` | string | `"permissionless"` | `"permissionless"` or `"permissioned"` |
| `code_submitters` | []address | `[]` | Allowlist of addresses; checked when policy is `"permissioned"` |

An ante-handler check (`checkCodeSubmissionPolicy`) runs **after** signature
verification. When `code_submission_policy` is `"permissioned"`, any
`MsgAddPackage` or `MsgRun` whose signer is not in `code_submitters` is
rejected with `ErrUnauthorized` before the typechecker is invoked.

Both `CheckTx` and `DeliverTx` paths are covered because the ante handler
runs at both stages, which also prevents unauthorized transactions from
entering the mempool.

## Consequences

- **Permissionless (default):** no behaviour change; existing chains and
devnets are unaffected.
- **Permissioned:** governance (GovDAO or equivalent) manages the
`code_submitters` allowlist via param proposals. Only listed addresses can
call `MsgAddPackage` / `MsgRun`.
- The policy is a chain parameter, so it can be toggled without a hard-fork.

### Setting `code_submitters` (repeated / string-array param)

`code_submitters` is a **repeated** param (`[]crypto.Address`). It is stored as
a JSON array and `GetParams` decodes it element-wise back into the typed field.
It must therefore be set through the *strings* param path — from governance,
`params.NewSysParamStringsPropRequest("vm", "p", "code_submitters", []string{...})`
(→ `ParamsKeeper.SetStrings`). Each entry is validated verbatim (a valid,
non-duplicate, non-zero bech32 address; no trimming) so that the value always
round-trips: an entry that passed validation but failed to decode would make
every subsequent `GetParams` (and thus every ante check) panic. A
comma-separated single string is **not** supported.

### Ordering caveat (avoid self-lockout)

Because `MsgRun` is itself gated, flipping the policy to `"permissioned"`
before adding the intended submitters would prevent those submitters from
issuing the governance `maketx run` transactions needed to fix it. Always add
addresses to `code_submitters` **first** (while still permissionless), then set
`code_submission_policy = "permissioned"`.

### Testing

- `gno.land/pkg/sdk/vm/code_submission_policy_test.go` — param validation,
governance setter parsing, and the genesis/governance storage round-trip.
- `gno.land/pkg/gnoland/code_submission_policy_test.go` — the ante-handler
enforcement matrix (policy modes, message types, multi-signer/multi-message).
- `gno.land/pkg/integration/testdata/code_submission_policy.txtar` — end-to-end
through a real node: govdao param changes, then authorized vs unauthorized
`addpkg`/`run`.

## Alternatives Considered

- **Handler-level check** (inside `handleMsgAddPackage` / `handleMsgRun`):
rejected because the check would happen *after* typechecking, not before.
- **ValidateBasic extension**: cannot read chain state (params).
- **Phase 2 — oracle-based permissionless** (packages stored inert, activated
off-chain): deferred; this ADR implements Phase 1 only.
40 changes: 40 additions & 0 deletions gno.land/pkg/gnoland/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) {
if sessRes, sessAbort := checkSessionRestrictions(newCtx, tx); sessAbort {
return newCtx, sessRes, true
}
if cspRes, cspAbort := checkCodeSubmissionPolicy(newCtx, tx, vmk); cspAbort {
return newCtx, cspRes, true
}
return
},
)
Expand Down Expand Up @@ -1124,6 +1127,43 @@ func checkSessionRestrictions(ctx sdk.Context, tx std.Tx) (sdk.Result, bool) {
return sdk.Result{}, false
}

// checkCodeSubmissionPolicy enforces the vm code_submission_policy param.
// When policy is "permissioned", MsgAddPackage and MsgRun are rejected unless
// every signer of those messages appears in the CodeSubmitters allowlist.
// Called after the auth ante handler so signatures are already verified.
func checkCodeSubmissionPolicy(ctx sdk.Context, tx std.Tx, vmk *vm.VMKeeper) (sdk.Result, bool) {
params := vmk.GetParams(ctx)
policy := params.CodeSubmissionPolicy
if policy == "" {
policy = vm.CodeSubmissionPolicyPermissionless
}
if policy == vm.CodeSubmissionPolicyPermissionless {
return sdk.Result{}, false
}
allowed := make(map[string]struct{}, len(params.CodeSubmitters))
for _, a := range params.CodeSubmitters {
allowed[a.String()] = struct{}{}
}
for _, msg := range tx.GetMsgs() {
if msg.Route() != "vm" {
continue
}
t := msg.Type()
if t != "add_package" && t != "run" {
continue
}
for _, signer := range msg.GetSigners() {
if _, ok := allowed[signer.String()]; !ok {
return sdk.ABCIResultFromError(std.ErrUnauthorized(fmt.Sprintf(
"address %s is not authorized to submit code (code_submission_policy=%s)",
signer, policy,
))), true
}
}
}
return sdk.Result{}, false
}

// sessionAlwaysDenied reports whether a msg can never be signed by a session,
// regardless of AllowPaths. Auth is denied at the route level (forward-compat
// against new auth msgs); vm/add_package at the type level.
Expand Down
210 changes: 210 additions & 0 deletions gno.land/pkg/gnoland/code_submission_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package gnoland

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/gnolang/gno/gno.land/pkg/sdk/vm"
bft "github.com/gnolang/gno/tm2/pkg/bft/types"
"github.com/gnolang/gno/tm2/pkg/crypto"
"github.com/gnolang/gno/tm2/pkg/db/memdb"
"github.com/gnolang/gno/tm2/pkg/log"
"github.com/gnolang/gno/tm2/pkg/sdk"
"github.com/gnolang/gno/tm2/pkg/sdk/auth"
"github.com/gnolang/gno/tm2/pkg/sdk/bank"
"github.com/gnolang/gno/tm2/pkg/sdk/params"
"github.com/gnolang/gno/tm2/pkg/std"
"github.com/gnolang/gno/tm2/pkg/store"
"github.com/gnolang/gno/tm2/pkg/store/dbadapter"
"github.com/gnolang/gno/tm2/pkg/store/iavl"
)

// setupCodePolicyEnv builds a minimal VMKeeper wired to a params keeper — just
// enough for checkCodeSubmissionPolicy, which only reads vm params and the tx
// messages. Stdlibs are intentionally NOT loaded (not needed for the ante
// check), keeping the test fast.
func setupCodePolicyEnv(t *testing.T) (sdk.Context, *vm.VMKeeper) {
t.Helper()

db := memdb.NewMemDB()
baseCapKey := store.NewStoreKey("baseCapKey")
iavlCapKey := store.NewStoreKey("iavlCapKey")

ms := store.NewCommitMultiStore(db)
ms.MountStoreWithDB(baseCapKey, dbadapter.StoreConstructor, db)
ms.MountStoreWithDB(iavlCapKey, iavl.StoreConstructor, db)
require.NoError(t, ms.LoadLatestVersion())

ctx := sdk.NewContext(sdk.RunTxModeDeliver, ms, &bft.Header{ChainID: "test-chain-id", Height: 1}, log.NewNoopLogger())

prmk := params.NewParamsKeeper(iavlCapKey)
acck := auth.NewAccountKeeper(iavlCapKey, prmk.ForModule(auth.ModuleName), std.ProtoBaseAccount, std.ProtoBaseSessionAccount)
bankk := bank.NewBankKeeper(acck, prmk.ForModule(bank.ModuleName))
vmk := vm.NewVMKeeper(baseCapKey, iavlCapKey, acck, bankk, prmk)
prmk.Register(auth.ModuleName, acck)
prmk.Register(bank.ModuleName, bankk)
prmk.Register(vm.ModuleName, vmk)

return ctx, vmk
}

// addPkgMsg builds a minimal MsgAddPackage signed by creator.
func addPkgMsg(creator crypto.Address) std.Msg {
files := []*std.MemFile{{Name: "a.gno", Body: "package a\n"}}
return vm.NewMsgAddPackage(creator, "gno.land/r/test/a", files)
}

// runMsg builds a minimal MsgRun signed by caller.
func runMsg(caller crypto.Address) std.Msg {
files := []*std.MemFile{{Name: "main.gno", Body: "package main\nfunc main() {}\n"}}
return vm.NewMsgRun(caller, nil, files)
}

// callMsg builds a MsgCall (vm route, type "exec") — not gated by policy.
func callMsg(caller crypto.Address) std.Msg {
return vm.NewMsgCall(caller, nil, "gno.land/r/test/a", "Foo", nil)
}

// sendMsg builds a bank MsgSend (non-vm route) — not gated by policy.
func sendMsg(from, to crypto.Address) std.Msg {
return bank.NewMsgSend(from, to, std.Coins{std.NewCoin("ugnot", 1)})
}

func TestCheckCodeSubmissionPolicy(t *testing.T) {
allowed := crypto.AddressFromPreimage([]byte("allowed"))
other := crypto.AddressFromPreimage([]byte("other"))
third := crypto.AddressFromPreimage([]byte("third"))

tests := []struct {
name string
policy vm.CodeSubmissionPolicy
submitters []crypto.Address
msgs []std.Msg
wantAbort bool
}{
{
name: "permissionless allows unlisted add_package",
policy: vm.CodeSubmissionPolicyPermissionless,
msgs: []std.Msg{addPkgMsg(other)},
wantAbort: false,
},
{
name: "empty policy treated as permissionless",
policy: "", // zero value
msgs: []std.Msg{addPkgMsg(other)},
wantAbort: false,
},
{
name: "permissioned allows listed add_package",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{addPkgMsg(allowed)},
wantAbort: false,
},
{
name: "permissioned rejects unlisted add_package",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{addPkgMsg(other)},
wantAbort: true,
},
{
name: "permissioned allows listed run",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{runMsg(allowed)},
wantAbort: false,
},
{
name: "permissioned rejects unlisted run",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{runMsg(other)},
wantAbort: true,
},
{
name: "permissioned ignores MsgCall from unlisted",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{callMsg(other)},
wantAbort: false,
},
{
name: "permissioned ignores bank send from unlisted",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{sendMsg(other, allowed)},
wantAbort: false,
},
{
name: "permissioned rejects if any code msg is unlisted",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{addPkgMsg(allowed), addPkgMsg(other)},
wantAbort: true,
},
{
name: "permissioned allows all-listed code msgs",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed, third},
msgs: []std.Msg{addPkgMsg(allowed), runMsg(third)},
wantAbort: false,
},
{
name: "permissioned with empty allowlist rejects add_package",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: nil,
msgs: []std.Msg{addPkgMsg(allowed)},
wantAbort: true,
},
{
name: "permissioned allows non-code msg mixed with listed code msg",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{sendMsg(other, allowed), addPkgMsg(allowed)},
wantAbort: false,
},
{
name: "permissioned rejects unlisted code msg mixed with non-code msg",
policy: vm.CodeSubmissionPolicyPermissioned,
submitters: []crypto.Address{allowed},
msgs: []std.Msg{sendMsg(allowed, other), addPkgMsg(other)},
wantAbort: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, vmk := setupCodePolicyEnv(t)
p := vm.DefaultParams()
p.CodeSubmissionPolicy = tt.policy
p.CodeSubmitters = tt.submitters
require.NoError(t, vmk.SetParams(ctx, p))

tx := std.Tx{Msgs: tt.msgs}
res, abort := checkCodeSubmissionPolicy(ctx, tx, vmk)

assert.Equal(t, tt.wantAbort, abort, "abort mismatch")
if tt.wantAbort {
assert.False(t, res.IsOK(), "expected non-OK result on abort")
assert.Contains(t, res.Log, "not authorized to submit code")
} else {
assert.True(t, res.IsOK(), "expected OK result, got: %s", res.Log)
}
})
}
}

// TestCheckCodeSubmissionPolicyDefaultParams verifies that a freshly-defaulted
// vm keeper (permissionless) never aborts a code submission.
func TestCheckCodeSubmissionPolicyDefaultParams(t *testing.T) {
ctx, vmk := setupCodePolicyEnv(t)
require.NoError(t, vmk.SetParams(ctx, vm.DefaultParams()))

tx := std.Tx{Msgs: []std.Msg{addPkgMsg(crypto.AddressFromPreimage([]byte("anyone")))}}
res, abort := checkCodeSubmissionPolicy(ctx, tx, vmk)
assert.False(t, abort)
assert.True(t, res.IsOK(), res.Log)
}
Loading
Loading