From 485531fefbdd2ad8fda7eb3d6a6cfa8a96a6f6aa Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:31:18 +0200 Subject: [PATCH 1/5] feat(gnovm): add code_submission_policy vm param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CodeSubmissionPolicy (permissionless|permissioned) and CodeSubmitters allowlist to vm Params. When policy is permissioned, an ante-handler check rejects MsgAddPackage and MsgRun from any address not on the allowlist — before typechecking runs. Defaults to permissionless; existing chains are unaffected. --- gno.land/adr/prxxxx_code_submission_policy.md | 46 ++++++++++ gno.land/pkg/gnoland/app.go | 40 +++++++++ .../pkg/sdk/vm/apphash_crossrealm38_test.go | 2 +- gno.land/pkg/sdk/vm/params.go | 62 ++++++++++++++ gno.land/pkg/sdk/vm/params_test.go | 4 +- gno.land/pkg/sdk/vm/pb3_gen.go | 85 +++++++++++++++++++ 6 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 gno.land/adr/prxxxx_code_submission_policy.md diff --git a/gno.land/adr/prxxxx_code_submission_policy.md b/gno.land/adr/prxxxx_code_submission_policy.md new file mode 100644 index 00000000000..52186525c38 --- /dev/null +++ b/gno.land/adr/prxxxx_code_submission_policy.md @@ -0,0 +1,46 @@ +# 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. + +## 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. diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index ad302162d42..a089ad68a49 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -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 }, ) @@ -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. diff --git a/gno.land/pkg/sdk/vm/apphash_crossrealm38_test.go b/gno.land/pkg/sdk/vm/apphash_crossrealm38_test.go index 8e7b1e01d2f..04715b0dd6a 100644 --- a/gno.land/pkg/sdk/vm/apphash_crossrealm38_test.go +++ b/gno.land/pkg/sdk/vm/apphash_crossrealm38_test.go @@ -68,7 +68,7 @@ import ( // their *_test.gno source bytes), which shifts the iavlStore Merkle root. This // is the only consensus-relevant change in that PR; verified by bisection that // no other change in the PR moves this hash. The shift is therefore expected. -const expectedCrossrealm38Hash = "28f55f0ad9842bc3c4d8984f1a63a709203a1e99fae7e816786825e26629f618" +const expectedCrossrealm38Hash = "4b272e5c6d5fcfaca2d5831396b2d44cc14a6dbfcfa86e29166a9944c74b5d0a" func TestAppHashCrossrealm38(t *testing.T) { env := setupTestEnv() diff --git a/gno.land/pkg/sdk/vm/params.go b/gno.land/pkg/sdk/vm/params.go index 09d66c29a7f..447ee776f29 100644 --- a/gno.land/pkg/sdk/vm/params.go +++ b/gno.land/pkg/sdk/vm/params.go @@ -14,6 +14,17 @@ import ( "github.com/gnolang/gno/tm2/pkg/store" ) +// CodeSubmissionPolicy controls who may submit MsgAddPackage and MsgRun. +type CodeSubmissionPolicy string + +const ( + // CodeSubmissionPolicyPermissionless allows any address to submit code (default). + CodeSubmissionPolicyPermissionless CodeSubmissionPolicy = "permissionless" + // CodeSubmissionPolicyPermissioned restricts code submission to addresses listed + // in Params.CodeSubmitters. + CodeSubmissionPolicyPermissioned CodeSubmissionPolicy = "permissioned" +) + const ( sysNamesPkgDefault = "gno.land/r/sys/names" sysCLAPkgDefault = "gno.land/r/sys/cla" @@ -21,6 +32,7 @@ const ( depositDefault = "600000000ugnot" storagePriceDefault = "100ugnot" // cost per byte (1 gnot per 10KB) 1.333B GNOT == 13.33TB storageFeeCollectorNameDefault = "storage_fee_collector" + codeSubmissionPolicyDefault = CodeSubmissionPolicyPermissionless // Depth floors calibrated for B+32 at 100M items with 10K cache, batched 1000 muts. minGetReadDepth100Default = int64(300) // 3.0 GET read ops @@ -51,6 +63,14 @@ type Params struct { // "no floor / use tree estimate") because zero iter-step cost // would effectively disable iteration gas charging. IterNextCostFlat int64 `json:"iter_next_cost_flat" yaml:"iter_next_cost_flat"` + + // CodeSubmissionPolicy controls who may submit MsgAddPackage and MsgRun. + // Defaults to "permissionless"; set to "permissioned" to restrict + // submissions to the addresses in CodeSubmitters. + CodeSubmissionPolicy CodeSubmissionPolicy `json:"code_submission_policy" yaml:"code_submission_policy"` + // CodeSubmitters is the allowlist of addresses permitted to submit code + // when CodeSubmissionPolicy == "permissioned". Ignored otherwise. + CodeSubmitters []crypto.Address `json:"code_submitters" yaml:"code_submitters"` } // NewParams creates a new Params object @@ -69,6 +89,8 @@ func NewParams(namesPkgPath, claPkgPath, chainDomain, defaultDeposit, storagePri FixedSetReadDepth100: minSetReadDepth100, FixedWriteDepth100: minWriteDepth100, IterNextCostFlat: iterNextCostFlat, + CodeSubmissionPolicy: codeSubmissionPolicyDefault, + CodeSubmitters: nil, } } @@ -97,6 +119,8 @@ func (p Params) String() string { sb.WriteString(fmt.Sprintf("FixedSetReadDepth100: %d\n", p.FixedSetReadDepth100)) sb.WriteString(fmt.Sprintf("FixedWriteDepth100: %d\n", p.FixedWriteDepth100)) sb.WriteString(fmt.Sprintf("IterNextCostFlat: %d\n", p.IterNextCostFlat)) + sb.WriteString(fmt.Sprintf("CodeSubmissionPolicy: %q\n", p.CodeSubmissionPolicy)) + sb.WriteString(fmt.Sprintf("CodeSubmitters: %v\n", p.CodeSubmitters)) return sb.String() } @@ -159,6 +183,26 @@ func (p Params) Validate() error { if p.IterNextCostFlat > maxIterNextCostFlat { return fmt.Errorf("IterNextCostFlat must be <= %d, got %d", maxIterNextCostFlat, p.IterNextCostFlat) } + switch p.CodeSubmissionPolicy { + case CodeSubmissionPolicyPermissionless, CodeSubmissionPolicyPermissioned: + // valid + case "": + // treat empty as permissionless (zero-value compat) + default: + return fmt.Errorf("invalid code_submission_policy %q, must be %q or %q", + p.CodeSubmissionPolicy, CodeSubmissionPolicyPermissionless, CodeSubmissionPolicyPermissioned) + } + seen := make(map[string]struct{}, len(p.CodeSubmitters)) + for i, addr := range p.CodeSubmitters { + if addr.IsZero() { + return fmt.Errorf("CodeSubmitters[%d] is a zero address", i) + } + key := addr.String() + if _, dup := seen[key]; dup { + return fmt.Errorf("CodeSubmitters contains duplicate address %s", key) + } + seen[key] = struct{}{} + } return nil } @@ -261,6 +305,24 @@ func (vm *VMKeeper) WillSetParam(ctx sdk.Context, key string, value any) { params.FixedWriteDepth100 = sdkparams.MustParamInt64("fixed_write_depth_100", value) case "p:iter_next_cost_flat": params.IterNextCostFlat = sdkparams.MustParamInt64("iter_next_cost_flat", value) + case "p:code_submission_policy": + params.CodeSubmissionPolicy = CodeSubmissionPolicy(sdkparams.MustParamString("code_submission_policy", value)) + case "p:code_submitters": + addrs := sdkparams.MustParamString("code_submitters", value) + params.CodeSubmitters = nil + if addrs != "" { + for _, s := range strings.Split(addrs, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + addr, err := crypto.AddressFromString(s) + if err != nil { + panic(fmt.Sprintf("invalid code_submitters address %q: %v", s, err)) + } + params.CodeSubmitters = append(params.CodeSubmitters, addr) + } + } default: if strings.HasPrefix(key, "p:") { panic(fmt.Sprintf("unknown vm param key: %q", key)) diff --git a/gno.land/pkg/sdk/vm/params_test.go b/gno.land/pkg/sdk/vm/params_test.go index ffe300611cd..b8a00d10fd1 100644 --- a/gno.land/pkg/sdk/vm/params_test.go +++ b/gno.land/pkg/sdk/vm/params_test.go @@ -31,7 +31,9 @@ func TestParamsString(t *testing.T) { fmt.Sprintf("FixedGetReadDepth100: %d\n", p.FixedGetReadDepth100) + fmt.Sprintf("FixedSetReadDepth100: %d\n", p.FixedSetReadDepth100) + fmt.Sprintf("FixedWriteDepth100: %d\n", p.FixedWriteDepth100) + - fmt.Sprintf("IterNextCostFlat: %d\n", p.IterNextCostFlat) + fmt.Sprintf("IterNextCostFlat: %d\n", p.IterNextCostFlat) + + fmt.Sprintf("CodeSubmissionPolicy: %q\n", p.CodeSubmissionPolicy) + + fmt.Sprintf("CodeSubmitters: %v\n", p.CodeSubmitters) // Assert: check if the result matches the expected string. if result != expected { diff --git a/gno.land/pkg/sdk/vm/pb3_gen.go b/gno.land/pkg/sdk/vm/pb3_gen.go index 29ce0c7b0b9..f630e2052d7 100644 --- a/gno.land/pkg/sdk/vm/pb3_gen.go +++ b/gno.land/pkg/sdk/vm/pb3_gen.go @@ -8,6 +8,7 @@ import ( "reflect" "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/crypto" "github.com/gnolang/gno/tm2/pkg/sdk/params" "github.com/gnolang/gno/tm2/pkg/std" ) @@ -1169,6 +1170,30 @@ func (goo *GenesisState) UnmarshalBinary2(cdc *amino.Codec, bz []byte, anyDepth func (goo Params) MarshalBinary2(cdc *amino.Codec, buf []byte, offset int) (int, error) { var err error + for i := len(goo.CodeSubmitters) - 1; i >= 0; i-- { + repr, err := goo.CodeSubmitters[i].MarshalAmino() + if err != nil { + return offset, err + } + if repr != "" { + offset = amino.PrependString(buf, offset, string(repr)) + } else { + offset = amino.PrependByte(buf, offset, 0x00) + } + offset = amino.PrependFieldNumberAndTyp3(buf, offset, 15, amino.Typ3ByteLength) + } + if goo.CodeSubmissionPolicy != "" { + { + before := offset + offset = amino.PrependString(buf, offset, string(goo.CodeSubmissionPolicy)) + valueLen := before - offset + if valueLen > 1 || (valueLen == 1 && buf[offset] != 0x00) { + offset = amino.PrependFieldNumberAndTyp3(buf, offset, 14, amino.Typ3ByteLength) + } else { + offset = before + } + } + } if goo.IterNextCostFlat != 0 { { before := offset @@ -1381,6 +1406,17 @@ func (goo Params) SizeBinary2(cdc *amino.Codec) (int, error) { if goo.IterNextCostFlat != 0 { s += 1 + amino.VarintSize(int64(goo.IterNextCostFlat)) } + if goo.CodeSubmissionPolicy != "" { + s += 1 + amino.UvarintSize(uint64(len(goo.CodeSubmissionPolicy))) + len(goo.CodeSubmissionPolicy) + } + for _, elem := range goo.CodeSubmitters { + repr, err := elem.MarshalAmino() + if err != nil { + return 0, err + } + vs := amino.UvarintSize(uint64(len(repr))) + len(repr) + s += 1 + vs + } return s, nil } @@ -1533,6 +1569,55 @@ func (goo *Params) UnmarshalBinary2(cdc *amino.Codec, bz []byte, anyDepth int) e } bz = bz[n:] goo.IterNextCostFlat = int64(v) + case 14: + if typ3 != amino.Typ3ByteLength { + return fmt.Errorf("field 14: expected typ3 %v, got %v", amino.Typ3ByteLength, typ3) + } + v, n, err := amino.DecodeString(bz) + if err != nil { + return err + } + bz = bz[n:] + goo.CodeSubmissionPolicy = CodeSubmissionPolicy(v) + case 15: + if typ3 != amino.Typ3ByteLength { + return fmt.Errorf("field 15: expected typ3 %v, got %v", amino.Typ3ByteLength, typ3) + } + var addr crypto.Address + v, n, err := amino.DecodeString(bz) + if err != nil { + return err + } + bz = bz[n:] + if err := addr.UnmarshalAmino(string(v)); err != nil { + return err + } + goo.CodeSubmitters = append(goo.CodeSubmitters, addr) + for len(bz) > 0 { + var nextFnum uint32 + var nextTyp3 amino.Typ3 + nextFnum, nextTyp3, n, err = amino.DecodeFieldNumberAndTyp3(bz) + if err != nil { + return err + } + if nextFnum != 15 { + break + } + if nextTyp3 != amino.Typ3ByteLength { + return fmt.Errorf("field 15: expected typ3 %v, got %v", amino.Typ3ByteLength, nextTyp3) + } + bz = bz[n:] + var elem crypto.Address + v, n, err := amino.DecodeString(bz) + if err != nil { + return err + } + bz = bz[n:] + if err := elem.UnmarshalAmino(string(v)); err != nil { + return err + } + goo.CodeSubmitters = append(goo.CodeSubmitters, elem) + } default: return fmt.Errorf("unknown field number %d for Params", fnum) } From 6155800b800776b2632784f0c2688064f70f425c Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:28:33 +0200 Subject: [PATCH 2/5] chore(gnovm): regenerate vm.proto and pb3_gen.go for code_submission_policy --- gno.land/pkg/sdk/vm/pb3_gen.go | 29 +++++++++++++++-------------- gno.land/pkg/sdk/vm/vm.proto | 2 ++ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/gno.land/pkg/sdk/vm/pb3_gen.go b/gno.land/pkg/sdk/vm/pb3_gen.go index f630e2052d7..dfe34725301 100644 --- a/gno.land/pkg/sdk/vm/pb3_gen.go +++ b/gno.land/pkg/sdk/vm/pb3_gen.go @@ -1171,15 +1171,12 @@ func (goo *GenesisState) UnmarshalBinary2(cdc *amino.Codec, bz []byte, anyDepth func (goo Params) MarshalBinary2(cdc *amino.Codec, buf []byte, offset int) (int, error) { var err error for i := len(goo.CodeSubmitters) - 1; i >= 0; i-- { - repr, err := goo.CodeSubmitters[i].MarshalAmino() + elem := goo.CodeSubmitters[i] + er, err := elem.MarshalAmino() if err != nil { return offset, err } - if repr != "" { - offset = amino.PrependString(buf, offset, string(repr)) - } else { - offset = amino.PrependByte(buf, offset, 0x00) - } + offset = amino.PrependString(buf, offset, string(er)) offset = amino.PrependFieldNumberAndTyp3(buf, offset, 15, amino.Typ3ByteLength) } if goo.CodeSubmissionPolicy != "" { @@ -1410,11 +1407,11 @@ func (goo Params) SizeBinary2(cdc *amino.Codec) (int, error) { s += 1 + amino.UvarintSize(uint64(len(goo.CodeSubmissionPolicy))) + len(goo.CodeSubmissionPolicy) } for _, elem := range goo.CodeSubmitters { - repr, err := elem.MarshalAmino() + er, err := elem.MarshalAmino() if err != nil { return 0, err } - vs := amino.UvarintSize(uint64(len(repr))) + len(repr) + vs := amino.UvarintSize(uint64(len(er))) + len(er) s += 1 + vs } return s, nil @@ -1583,16 +1580,18 @@ func (goo *Params) UnmarshalBinary2(cdc *amino.Codec, bz []byte, anyDepth int) e if typ3 != amino.Typ3ByteLength { return fmt.Errorf("field 15: expected typ3 %v, got %v", amino.Typ3ByteLength, typ3) } - var addr crypto.Address + var ev crypto.Address + var rv string v, n, err := amino.DecodeString(bz) if err != nil { return err } bz = bz[n:] - if err := addr.UnmarshalAmino(string(v)); err != nil { + rv = string(v) + if err := ev.UnmarshalAmino(rv); err != nil { return err } - goo.CodeSubmitters = append(goo.CodeSubmitters, addr) + goo.CodeSubmitters = append(goo.CodeSubmitters, ev) for len(bz) > 0 { var nextFnum uint32 var nextTyp3 amino.Typ3 @@ -1607,16 +1606,18 @@ func (goo *Params) UnmarshalBinary2(cdc *amino.Codec, bz []byte, anyDepth int) e return fmt.Errorf("field 15: expected typ3 %v, got %v", amino.Typ3ByteLength, nextTyp3) } bz = bz[n:] - var elem crypto.Address + var ev crypto.Address + var rv string v, n, err := amino.DecodeString(bz) if err != nil { return err } bz = bz[n:] - if err := elem.UnmarshalAmino(string(v)); err != nil { + rv = string(v) + if err := ev.UnmarshalAmino(rv); err != nil { return err } - goo.CodeSubmitters = append(goo.CodeSubmitters, elem) + goo.CodeSubmitters = append(goo.CodeSubmitters, ev) } default: return fmt.Errorf("unknown field number %d for Params", fnum) diff --git a/gno.land/pkg/sdk/vm/vm.proto b/gno.land/pkg/sdk/vm/vm.proto index ff638171984..2537cd36769 100644 --- a/gno.land/pkg/sdk/vm/vm.proto +++ b/gno.land/pkg/sdk/vm/vm.proto @@ -81,4 +81,6 @@ message Params { sint64 fixed_set_read_depth100 = 11 [json_name = "fixed_set_read_depth_100"]; sint64 fixed_write_depth100 = 12 [json_name = "fixed_write_depth_100"]; sint64 iter_next_cost_flat = 13; + string code_submission_policy = 14; + repeated string code_submitters = 15; } \ No newline at end of file From 734e10b10d6bcbe2fe7c8f56aa5b7f9193798126 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:37:49 +0200 Subject: [PATCH 3/5] chore(gnovm): apply go fix modernizer (strings.SplitSeq) in vm params --- gno.land/pkg/sdk/vm/params.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gno.land/pkg/sdk/vm/params.go b/gno.land/pkg/sdk/vm/params.go index 447ee776f29..e44c95b9c9c 100644 --- a/gno.land/pkg/sdk/vm/params.go +++ b/gno.land/pkg/sdk/vm/params.go @@ -311,7 +311,7 @@ func (vm *VMKeeper) WillSetParam(ctx sdk.Context, key string, value any) { addrs := sdkparams.MustParamString("code_submitters", value) params.CodeSubmitters = nil if addrs != "" { - for _, s := range strings.Split(addrs, ",") { + for s := range strings.SplitSeq(addrs, ",") { s = strings.TrimSpace(s) if s == "" { continue From 023a0fbf07b70140ed8fb3121eed8d6a2aaa1c56 Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:08:16 +0200 Subject: [PATCH 4/5] fix(gnovm): set code_submitters via strings param so it round-trips through GetParams --- gno.land/adr/prxxxx_code_submission_policy.md | 30 +++++++++++++++++++ gno.land/pkg/sdk/vm/params.go | 30 +++++++++++-------- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/gno.land/adr/prxxxx_code_submission_policy.md b/gno.land/adr/prxxxx_code_submission_policy.md index 52186525c38..26d9277f6ba 100644 --- a/gno.land/adr/prxxxx_code_submission_policy.md +++ b/gno.land/adr/prxxxx_code_submission_policy.md @@ -37,6 +37,36 @@ entering the mempool. 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`): diff --git a/gno.land/pkg/sdk/vm/params.go b/gno.land/pkg/sdk/vm/params.go index e44c95b9c9c..2396fef1f73 100644 --- a/gno.land/pkg/sdk/vm/params.go +++ b/gno.land/pkg/sdk/vm/params.go @@ -308,20 +308,24 @@ func (vm *VMKeeper) WillSetParam(ctx sdk.Context, key string, value any) { case "p:code_submission_policy": params.CodeSubmissionPolicy = CodeSubmissionPolicy(sdkparams.MustParamString("code_submission_policy", value)) case "p:code_submitters": - addrs := sdkparams.MustParamString("code_submitters", value) - params.CodeSubmitters = nil - if addrs != "" { - for s := range strings.SplitSeq(addrs, ",") { - s = strings.TrimSpace(s) - if s == "" { - continue - } - addr, err := crypto.AddressFromString(s) - if err != nil { - panic(fmt.Sprintf("invalid code_submitters address %q: %v", s, err)) - } - params.CodeSubmitters = append(params.CodeSubmitters, addr) + // code_submitters is a repeated (string-array) param, set via the + // strings param path (params.NewSysParamStringsPropRequest / + // SetStrings). The keeper stores the raw string array and GetParams + // decodes it element-wise back into the typed []crypto.Address field, + // so each entry must be a valid address VERBATIM. We validate strictly + // (no trimming, no skipping of empty entries) precisely so that this + // validation matches what GetParams will later decode: any entry + // accepted here must round-trip, otherwise a value could pass + // validation yet make every subsequent GetParams panic. A comma- + // separated single string does NOT round-trip and is unsupported. + ss := sdkparams.MustParamStrings("code_submitters", value) + params.CodeSubmitters = make([]crypto.Address, 0, len(ss)) + for _, s := range ss { + addr, err := crypto.AddressFromString(s) + if err != nil { + panic(fmt.Sprintf("invalid code_submitters address %q: %v", s, err)) } + params.CodeSubmitters = append(params.CodeSubmitters, addr) } default: if strings.HasPrefix(key, "p:") { From c54a9c295af51e4128680e1dd132b8e9749e09bc Mon Sep 17 00:00:00 2001 From: moul <94029+moul@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:08:16 +0200 Subject: [PATCH 5/5] test(gnovm): add code_submission_policy unit and integration tests --- .../gnoland/code_submission_policy_test.go | 210 +++++++++++++++ .../testdata/code_submission_policy.txtar | 181 +++++++++++++ .../pkg/sdk/vm/code_submission_policy_test.go | 253 ++++++++++++++++++ 3 files changed, 644 insertions(+) create mode 100644 gno.land/pkg/gnoland/code_submission_policy_test.go create mode 100644 gno.land/pkg/integration/testdata/code_submission_policy.txtar create mode 100644 gno.land/pkg/sdk/vm/code_submission_policy_test.go diff --git a/gno.land/pkg/gnoland/code_submission_policy_test.go b/gno.land/pkg/gnoland/code_submission_policy_test.go new file mode 100644 index 00000000000..e0fe941d7ce --- /dev/null +++ b/gno.land/pkg/gnoland/code_submission_policy_test.go @@ -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) +} diff --git a/gno.land/pkg/integration/testdata/code_submission_policy.txtar b/gno.land/pkg/integration/testdata/code_submission_policy.txtar new file mode 100644 index 00000000000..8a5bec9da21 --- /dev/null +++ b/gno.land/pkg/integration/testdata/code_submission_policy.txtar @@ -0,0 +1,181 @@ +# End-to-end test for the vm code_submission_policy param. +# +# When policy is "permissioned", only addresses in code_submitters may submit +# MsgAddPackage / MsgRun. The check runs in the ante handler, so unauthorized +# submissions are rejected before execution. +# +# Ordering matters: we add test1 to code_submitters FIRST (while still +# permissionless), THEN flip the policy to permissioned. Doing it the other way +# would gate test1's own governance `maketx run` txs and deadlock the chain. + +# An extra funded account that is NOT on the allowlist. +adduser unauthorized + +loadpkg gno.land/r/gov/dao +loadpkg gno.land/r/gov/dao/v3/impl +loadpkg gno.land/r/sys/params + +patchpkg "g1wymu47drhr0kuq2098m792lytgtj2nyx77yrsm" "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" + +loadpkg gno.land/r/gov/dao/v3/loader $WORK/loader + +gnoland start + +# Baseline: default policy is permissionless. +gnokey query params/vm:p:code_submission_policy +stdout 'data: "permissionless"$' + +# --- Proposal 0: add test1 to code_submitters (while permissionless) --- +gnokey maketx run -gas-fee 2500001ugnot -gas-wanted 29_000_000 -chainid=tendermint_test test1 $WORK/run/submit_submitters.gno +stdout OK! + +gnokey maketx run -gas-fee 2200001ugnot -gas-wanted 27_000_000 -chainid=tendermint_test test1 $WORK/run/vote0.gno +stdout OK! + +gnokey maketx run -gas-fee 2600001ugnot -gas-wanted 27_000_000 -chainid=tendermint_test test1 $WORK/run/exec0.gno +stdout OK! + +gnokey query params/vm:p:code_submitters +stdout 'g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5' + +# --- Proposal 1: flip policy to permissioned --- +gnokey maketx run -gas-fee 2500001ugnot -gas-wanted 29_000_000 -chainid=tendermint_test test1 $WORK/run/submit_policy.gno +stdout OK! + +gnokey maketx run -gas-fee 2200001ugnot -gas-wanted 27_000_000 -chainid=tendermint_test test1 $WORK/run/vote1.gno +stdout OK! + +gnokey maketx run -gas-fee 2600001ugnot -gas-wanted 27_000_000 -chainid=tendermint_test test1 $WORK/run/exec1.gno +stdout OK! + +gnokey query params/vm:p:code_submission_policy +stdout 'data: "permissioned"$' + +# --- Authorized: test1 is on the allowlist --- +gnokey maketx addpkg -pkgdir $WORK/hello -pkgpath gno.land/r/$test1_user_addr/hello -gas-fee 350001ugnot -gas-wanted 3_500_000 -chainid=tendermint_test test1 +stdout OK! + +# --- Unauthorized: the extra account is not on the allowlist --- +! gnokey maketx addpkg -pkgdir $WORK/hello2 -pkgpath gno.land/r/$unauthorized_user_addr/hello -gas-fee 350001ugnot -gas-wanted 3_500_000 -chainid=tendermint_test unauthorized +stderr 'not authorized to submit code' + +# MsgRun is gated too: unauthorized run is rejected... +! gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 20_000_000 -chainid=tendermint_test unauthorized $WORK/run/hello.gno +stderr 'not authorized to submit code' + +# ...while an authorized run succeeds. +gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 20_000_000 -chainid=tendermint_test test1 $WORK/run/hello.gno +stdout OK! + +-- loader/load_govdao.gno -- +package load_govdao + +import ( + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" + "gno.land/r/gov/dao/v3/memberstore" +) + +func init(cur realm) { + memberstore.Get(0, cur).SetTier(memberstore.T1) + memberstore.Get(0, cur).SetTier(memberstore.T2) + memberstore.Get(0, cur).SetTier(memberstore.T3) + + memberstore.Get(0, cur).SetMember(memberstore.T1, address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), memberstore.NewMember(3)) // member address + + dao.UpdateImpl(cross(cur), dao.NewUpdateRequest(impl.GetInstance(0, cur), []string{"gno.land/r/gov/dao/v3/impl"})) +} + +-- run/submit_submitters.gno -- +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/sys/params" +) + +func main(cur realm) { + prop := params.NewSysParamStringsPropRequest(cross(cur), "vm", "p", "code_submitters", + []string{"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"}) + dao.MustCreateProposal(cross(cur), prop) +} + +-- run/submit_policy.gno -- +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/sys/params" +) + +func main(cur realm) { + prop := params.NewSysParamStringPropRequest(cross(cur), "vm", "p", "code_submission_policy", "permissioned") + dao.MustCreateProposal(cross(cur), prop) +} + +-- run/vote0.gno -- +package main + +import "gno.land/r/gov/dao" + +func main(cur realm) { + dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(0))) +} + +-- run/exec0.gno -- +package main + +import "gno.land/r/gov/dao" + +func main(cur realm) { + dao.ExecuteProposal(cross(cur), dao.ProposalID(0)) +} + +-- run/vote1.gno -- +package main + +import "gno.land/r/gov/dao" + +func main(cur realm) { + dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(1))) +} + +-- run/exec1.gno -- +package main + +import "gno.land/r/gov/dao" + +func main(cur realm) { + dao.ExecuteProposal(cross(cur), dao.ProposalID(1)) +} + +-- run/hello.gno -- +package main + +func main() { + println("hello from run") +} + +-- hello/gnomod.toml -- +module = "gno.land/r/hello" + +gno = "0.9" + +-- hello/hello.gno -- +package hello + +func Hello() string { + return "hello" +} + +-- hello2/gnomod.toml -- +module = "gno.land/r/hello2" + +gno = "0.9" + +-- hello2/hello.gno -- +package hello + +func Hello() string { + return "hello" +} diff --git a/gno.land/pkg/sdk/vm/code_submission_policy_test.go b/gno.land/pkg/sdk/vm/code_submission_policy_test.go new file mode 100644 index 00000000000..b9643ee72be --- /dev/null +++ b/gno.land/pkg/sdk/vm/code_submission_policy_test.go @@ -0,0 +1,253 @@ +package vm + +import ( + "testing" + + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// addr is a small helper that derives a deterministic, valid bech32 address +// from a seed, for use in the code_submission_policy tests. +func cspTestAddr(seed string) crypto.Address { + return crypto.AddressFromPreimage([]byte(seed)) +} + +// TestCodeSubmissionPolicyConstants documents the on-chain string values of the +// policy constants. These strings are consensus-relevant (they are stored in +// params and parsed from governance proposals), so a rename must be deliberate. +func TestCodeSubmissionPolicyConstants(t *testing.T) { + t.Parallel() + assert.Equal(t, CodeSubmissionPolicy("permissionless"), CodeSubmissionPolicyPermissionless) + assert.Equal(t, CodeSubmissionPolicy("permissioned"), CodeSubmissionPolicyPermissioned) +} + +// TestDefaultParamsCodeSubmission verifies the default params are permissionless +// with no submitters, so existing chains are unaffected. +func TestDefaultParamsCodeSubmission(t *testing.T) { + t.Parallel() + p := DefaultParams() + assert.Equal(t, CodeSubmissionPolicyPermissionless, p.CodeSubmissionPolicy) + assert.Nil(t, p.CodeSubmitters) + assert.NoError(t, p.Validate()) +} + +// TestParamsValidateCodeSubmission exercises Params.Validate for the +// code_submission_policy and code_submitters fields. +func TestParamsValidateCodeSubmission(t *testing.T) { + t.Parallel() + + addr1 := cspTestAddr("submitter1") + addr2 := cspTestAddr("submitter2") + + tests := []struct { + name string + modify func(p Params) Params + wantErr string // substring; empty means no error expected + }{ + { + name: "permissionless with no submitters", + modify: func(p Params) Params { p.CodeSubmissionPolicy = CodeSubmissionPolicyPermissionless; return p }, + }, + { + name: "permissioned with submitters", + modify: func(p Params) Params { + p.CodeSubmissionPolicy = CodeSubmissionPolicyPermissioned + p.CodeSubmitters = []crypto.Address{addr1, addr2} + return p + }, + }, + { + name: "permissioned with empty submitters is valid at the param level", + modify: func(p Params) Params { + p.CodeSubmissionPolicy = CodeSubmissionPolicyPermissioned + p.CodeSubmitters = nil + return p + }, + }, + { + name: "empty policy is treated as permissionless", + modify: func(p Params) Params { p.CodeSubmissionPolicy = ""; return p }, + }, + { + name: "invalid policy string", + modify: func(p Params) Params { p.CodeSubmissionPolicy = "sometimes"; return p }, + wantErr: "invalid code_submission_policy", + }, + { + name: "submitters may be set even when permissionless", + modify: func(p Params) Params { + p.CodeSubmissionPolicy = CodeSubmissionPolicyPermissionless + p.CodeSubmitters = []crypto.Address{addr1} + return p + }, + }, + { + name: "zero address in submitters", + modify: func(p Params) Params { + p.CodeSubmissionPolicy = CodeSubmissionPolicyPermissioned + p.CodeSubmitters = []crypto.Address{addr1, {}} + return p + }, + wantErr: "zero address", + }, + { + name: "duplicate address in submitters", + modify: func(p Params) Params { + p.CodeSubmissionPolicy = CodeSubmissionPolicyPermissioned + p.CodeSubmitters = []crypto.Address{addr1, addr2, addr1} + return p + }, + wantErr: "duplicate address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := tt.modify(DefaultParams()) + err := p.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + +// TestWillSetParamCodeSubmissionPolicy exercises the governance param setter for +// the code_submission_policy key. WillSetParam re-validates after applying, so +// an invalid policy string panics. +func TestWillSetParamCodeSubmissionPolicy(t *testing.T) { + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + vmk := env.vmk + prmk := env.prmk + + tests := []struct { + name string + value string + shouldPanic bool + want CodeSubmissionPolicy + }{ + {name: "set permissioned", value: "permissioned", want: CodeSubmissionPolicyPermissioned}, + {name: "set permissionless", value: "permissionless", want: CodeSubmissionPolicyPermissionless}, + {name: "invalid policy panics", value: "bogus", shouldPanic: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vmk.SetParams(ctx, DefaultParams()) + if tt.shouldPanic { + assert.Panics(t, func() { + prmk.SetString(ctx, "vm:p:code_submission_policy", tt.value) + }) + return + } + prmk.SetString(ctx, "vm:p:code_submission_policy", tt.value) + assert.Equal(t, tt.want, vmk.GetParams(ctx).CodeSubmissionPolicy) + }) + } +} + +// TestWillSetParamCodeSubmitters exercises the governance strings-param setter +// for code_submitters: trimming, empty-segment skipping, empty list -> nil, +// invalid address -> panic, duplicate -> panic (via Validate). +// +// code_submitters is set as a string array (SetStrings) so the stored JSON +// array round-trips into the typed []crypto.Address field via GetParams. This +// test also serves as the regression guard for that round-trip: it calls +// GetParams after every successful set, which would panic if the stored +// representation were incompatible (see the comment in WillSetParam). +func TestWillSetParamCodeSubmitters(t *testing.T) { + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + vmk := env.vmk + prmk := env.prmk + + addr1 := cspTestAddr("submitter1") + addr2 := cspTestAddr("submitter2") + s1, s2 := addr1.String(), addr2.String() + + tests := []struct { + name string + value []string + shouldPanic bool + wantEmpty bool + want []crypto.Address + }{ + {name: "empty clears list", value: []string{}, wantEmpty: true}, + {name: "single address", value: []string{s1}, want: []crypto.Address{addr1}}, + {name: "two addresses", value: []string{s1, s2}, want: []crypto.Address{addr1, addr2}}, + // Entries are validated verbatim (no trimming), because the keeper + // stores the raw strings and GetParams decodes them element-wise. + // Anything that would not round-trip is rejected at set time. + {name: "whitespace entry rejected", value: []string{" " + s1 + " "}, shouldPanic: true}, + {name: "empty entry rejected", value: []string{s1, "", s2}, shouldPanic: true}, + {name: "invalid address panics", value: []string{"not-an-address"}, shouldPanic: true}, + {name: "duplicate address panics", value: []string{s1, s1}, shouldPanic: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Start from permissioned so the submitters list is meaningful, + // but validation of submitters happens regardless of policy. + base := DefaultParams() + base.CodeSubmissionPolicy = CodeSubmissionPolicyPermissioned + vmk.SetParams(ctx, base) + + if tt.shouldPanic { + assert.Panics(t, func() { + prmk.SetStrings(ctx, "vm:p:code_submitters", tt.value) + }) + return + } + prmk.SetStrings(ctx, "vm:p:code_submitters", tt.value) + // GetParams must not panic; it must return the decoded list. + got := vmk.GetParams(ctx).CodeSubmitters + if tt.wantEmpty { + assert.Empty(t, got) + } else { + assert.Equal(t, tt.want, got) + } + }) + } +} + +// TestCodeSubmittersRoundTrip is the focused regression test for the storage +// round-trip bug: both the genesis path (SetParams -> SetStruct) and the +// governance path (SetStrings) must produce a code_submitters value that +// GetParams can read back without panicking. +func TestCodeSubmittersRoundTrip(t *testing.T) { + addr1 := cspTestAddr("rt-submitter1") + addr2 := cspTestAddr("rt-submitter2") + + t.Run("genesis SetParams", func(t *testing.T) { + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + p := DefaultParams() + p.CodeSubmissionPolicy = CodeSubmissionPolicyPermissioned + p.CodeSubmitters = []crypto.Address{addr1, addr2} + require.NoError(t, env.vmk.SetParams(ctx, p)) + + got := env.vmk.GetParams(ctx) + assert.Equal(t, CodeSubmissionPolicyPermissioned, got.CodeSubmissionPolicy) + assert.Equal(t, []crypto.Address{addr1, addr2}, got.CodeSubmitters) + }) + + t.Run("governance SetStrings", func(t *testing.T) { + env := setupTestEnv() + ctx := env.vmk.MakeGnoTransactionStore(env.ctx) + require.NoError(t, env.vmk.SetParams(ctx, DefaultParams())) + + env.prmk.SetString(ctx, "vm:p:code_submission_policy", "permissioned") + env.prmk.SetStrings(ctx, "vm:p:code_submitters", []string{addr1.String(), addr2.String()}) + + got := env.vmk.GetParams(ctx) + assert.Equal(t, CodeSubmissionPolicyPermissioned, got.CodeSubmissionPolicy) + assert.Equal(t, []crypto.Address{addr1, addr2}, got.CodeSubmitters) + }) +}