Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6fb5ea7
test(gnovm): add failing filetest alias2
ltzmaxwell May 27, 2026
ce32d09
fix(gnovm): preserve embedded alias name as struct field name
ltzmaxwell May 27, 2026
813ad3b
refactor(gnovm): reuse unconst in pickEmbedName
ltzmaxwell Jun 12, 2026
d0a7b5e
test(gnovm): cover selector, pointer, and interface alias embeds
ltzmaxwell Jun 12, 2026
fbfbab4
refactor(gnovm): inline pickEmbedName, drop dead embed-name fallback
ltzmaxwell Jun 17, 2026
bd22a3c
test(gnovm): cover alias-to-defined-type embedded field name
ltzmaxwell Jun 17, 2026
155f1a7
docs(gnovm): explain what each alias-embed filetest guards
ltzmaxwell Jun 17, 2026
589ab87
fix(gnovm): name embedded interfaces from resolved type, not alias sp…
ltzmaxwell Jun 18, 2026
6426f3d
fix(gnovm): flatten embedded interface method sets for type identity
ltzmaxwell Jun 18, 2026
20a51ce
refactor(gnovm): build interface methods with embed=false before flat…
ltzmaxwell Jun 18, 2026
7f226b4
docs(gnovm): tighten flatten comment, note legacy embedded-interface …
ltzmaxwell Jun 18, 2026
e2c3d98
fix(gnovm): preserve cross-package unexported-method identity when fl…
ltzmaxwell Jun 18, 2026
6e9c403
test(gnovm): unit-test flattenInterfaceMethods origin-pkgpath and dedup
ltzmaxwell Jun 24, 2026
bccbd4f
test(gnovm): move flatten test into types_test.go (matches types.go)
ltzmaxwell Jun 24, 2026
280b575
test(gnovm): cover struct embedding an interface alias (spelling rule)
ltzmaxwell Jun 24, 2026
37c99e0
fix(gnovm): sort interface methods by the same package fallback emiss…
ltzmaxwell Jun 26, 2026
216e8ae
test(gnovm): clarify provenance test — empty PkgPath is direct-decl o…
ltzmaxwell Jun 27, 2026
2c95fec
test(gnovm): filetest for interface identity across construction prov…
ltzmaxwell Jun 29, 2026
5cec6f6
test(gnovm): codec parity for FieldType.PkgPath wire field
ltzmaxwell Jun 29, 2026
67d07ae
test(gnovm): multi-level cross-package embed preserves unexported ori…
ltzmaxwell Jun 29, 2026
4003909
test(gnovm): pin that doOpInterfaceType flattens embeds (runtime path)
ltzmaxwell Jun 29, 2026
eeed186
docs(gnovm): ADR follow-up — migrate+re-flatten persisted types, then…
ltzmaxwell Jun 29, 2026
423483f
docs(gnovm): reframe ADR follow-up as fresh/migrate if-else, decision…
ltzmaxwell Jun 29, 2026
a1dd1a0
docs(gnovm): ADR — precise master behavior (resolved-name embed; alia…
ltzmaxwell Jun 29, 2026
8fcbf67
docs(gnovm): trim ADR — drop transient-state note, keep the flatten v…
ltzmaxwell Jun 29, 2026
d3ab1e6
test(gnovm): gofmt alias2 (blank line between func and const decls)
ltzmaxwell Jun 29, 2026
a769982
test(gnovm): fold alias5 into iface_embed_id (consistent filetest cat…
ltzmaxwell Jun 29, 2026
6ad03c9
test(gnovm): two same-name unexported methods from different packages…
ltzmaxwell Jun 30, 2026
8f864b3
test(gnovm): pin same-name/conflicting-signature embed is rejected
ltzmaxwell Jun 30, 2026
a3b14e2
refactor(gnovm): /simplify — drop dead Len, extract originPkg, stamp …
ltzmaxwell Jun 30, 2026
0c7c6d3
fix(gnovm): gate unexported interface-method selection by origin pack…
ltzmaxwell Jul 2, 2026
7302974
refactor(gnovm): slices.ContainsFunc for the flatten no-embed fast path
ltzmaxwell Jul 2, 2026
880040d
fix(gnovm)!: assume flattened interfaces; hard-error on pre-flattenin…
ltzmaxwell Jul 2, 2026
c8c7329
refactor(gnovm): debugAssert-gate hot unflattened-interface checks, k…
ltzmaxwell Jul 2, 2026
d887f38
test(gnovm): t.Parallel in hard-error subtests (tparallel lint)
ltzmaxwell Jul 2, 2026
37f6ea2
Merge branch 'master' into fix/alias_2
ltzmaxwell Jul 2, 2026
df6772e
Merge branch 'master' into fix/alias_2
ltzmaxwell Jul 3, 2026
a43ef22
refactor(gnovm): move unflattened-interface check to decode boundary …
ltzmaxwell Jul 3, 2026
8d5ab2e
refactor(gnovm): shorten unflattened-interface panic message
ltzmaxwell Jul 3, 2026
72145c8
refactor(gnovm): minimal unflattened-interface panic message
ltzmaxwell Jul 3, 2026
f6e7f09
test(gnovm): pin rune/byte builtin-alias embeds distinct from int32/u…
ltzmaxwell Jul 3, 2026
a00dde6
test(gnovm): cover doOpStructType embed naming (unit + filetest that …
ltzmaxwell Jul 3, 2026
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
173 changes: 173 additions & 0 deletions gnovm/adr/pr5739_interface_method_set_flattening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Interface method-set flattening and cross-package unexported-method identity

## Context

GnoVM derives an anonymous interface's `TypeID` from its method list. Before
this change, an *embedded* interface was kept as a single named entry in
`InterfaceType.Methods` (e.g. `interface{ Stringer }` stored a method-list
entry named `Stringer` whose type was the embedded interface). That made the
embedded-interface *name* — including an alias spelling — part of the
`TypeID`, so GnoVM diverged from Go:

- `interface{ Stringer }` was not identical to `interface{ Str() string }`.
- `interface{ SAlias }` (where `type SAlias = Stringer`) was not identical to
`interface{ Stringer }`.

**Precise behavior on master (verified):** master named the embed entry from the
*resolved* type — `fillEmbeddedName` does `case *DeclaredType: ft.Name = ct.Name`
— and never flattened. So the two divergences are not symmetric:

| comparison | master | Go |
|---|---|---|
| `interface{ SAlias }` vs `interface{ Stringer }` | **identical** (alias resolves to the same `Stringer` entry name) | identical |
| `interface{ Stringer }` vs `interface{ Str() string }` | **distinct** (entry named `Stringer` vs method `Str`) | identical |

So on master the alias case was already correct; only embed-vs-explicit
diverged. No naming policy — resolved or spelled — can fix embed-vs-explicit,
because the embed is still one named entry rather than its methods. Only
flattening removes the embed name from identity.

Go computes interface identity from the **flattened method set**; embedding
contributes methods, not a name. PR #5739 therefore flattens embedded
interfaces into their constituent methods at type construction
(`flattenInterfaceMethods`, called from `doOpInterfaceType` and
`staticTypeFromAST`), so the embed/alias spelling no longer leaks into the
`TypeID`.

## The cross-package unexported-method problem

Flattening exposed a latent representational gap. An unexported interface
method's identity in Go is `(pkgpath, name)` — `p.sec` and `q.sec` are
distinct methods, and a type outside `p` cannot satisfy an interface
containing `p.sec` (the "sealed interface" pattern). GnoVM encodes that
package qualification **once**, on the containing `InterfaceType.PkgPath`,
used by `FieldTypeList.TypeIDForPackage` and by interface-satisfaction gating
in `VerifyImplementedBy` / `FindEmbeddedFieldType`.

When flattening hoists an unexported method out of an interface defined in
package `P` into an anonymous interface in package `Q`, that single-pkgpath
encoding re-qualifies the method to `Q`. A `/challenge` pass reproduced two
failures (both regressions vs the baseline before this change, confirmed against a
real-Go oracle):

1. **Identity over-collapse.** `interface{ p.Sec }` (with unexported `p.sec`)
became identical to `interface{ sec() int }` declared in `q` — Go treats
them as distinct.
2. **Satisfaction bypass (security-relevant).** A type declared in `q` with a
`sec()` method was accepted as satisfying `p.Sec`, bypassing the sealed-
interface mechanism. Go correctly rejects it.

Over-collapse is the dangerous direction (type confusion / access-control
bypass), and it is consensus-relevant in a VM.

## Decision

Make flattening fully Go-faithful by recording each method's **origin
package** alongside the method, instead of relying on the enclosing
interface's single `PkgPath`:

- Add `FieldType.PkgPath` — the defining package of an unexported method
(empty for exported methods and for legacy/non-flattened entries, which
fall back to the enclosing interface's `PkgPath`).
- `flattenInterfaceMethods` stamps the origin package on each hoisted (and
directly-declared) unexported method, and deduplicates on `(pkgpath, name)`
so that two same-named unexported methods from different packages coexist.
- `FieldTypeList.Less` and `FieldTypeList.TypeIDForPackage` key/qualify on the
per-method `PkgPath` when set.
- `VerifyImplementedBy` gates unexported access against the method's origin
package, not the enclosing interface's package.

This is fully faithful to Go, including the pathological case of two distinct
same-package sealed interfaces with identical method sets.

### Cost

`FieldType` is amino-serialized (`gnolang.proto` / `pb3_gen.go`), so adding
`PkgPath` changes the persisted shape of the type representation — a
serialization-format change requiring a coordinated chain upgrade, on top of
the `TypeID` changes flattening already implies. The new field is appended
(proto field 5) and defaults empty, so legacy-decoded entries behave exactly
as before.

## Alternatives considered

### Alternative A — do not hoist unsafe embeds (rejected, recorded for trace)

Flatten only when identity is preserved: hoist exported methods and
unexported methods whose origin package equals the enclosing interface; for an
embed carrying cross-package unexported methods, keep it as a sub-interface
entry and reuse the existing recursive satisfaction path.

- **Pro:** sound (no over-collapse, no bypass), small diff, **no serialization
change** — `FieldType` shape is untouched.
- **Con:** conservative — it *over-distinguishes* in one case: two distinct
sealed interfaces *in the same package* with identical method sets, embedded
from a *third* package, are treated as distinct where Go treats them as
identical. Over-distinction is the safe direction (a legitimate equality is
missed; satisfaction can never be forged), deterministic across nodes, and
the triggering case is exotic.

This was rejected only because we chose full Go-fidelity over avoiding the
serialization change. If the serialization/migration cost is later judged not
worth the pathological case, Alternative A is the drop-in fallback: it closes
the same security hole with no persisted-shape change. This ADR exists so that
trade-off can be revisited.

### Alternative B — keep the embed-entry representation (rejected)

Name embedded interfaces from the resolved type (the minimal fix that shipped
first on this branch). Sound, but only equalizes alias-vs-target; leaves
`interface{ Stringer } != interface{ Str() string }` diverging from Go.

## Consequences

- Interface identity matches Go across embedding, aliasing, multi-level,
diamond, order, mixed embed+direct, and cross-package (exported and
unexported) method sets.
- Consensus-breaking: `TypeID`s of anonymous interfaces that embed other
interfaces change, and the `FieldType` serialization gains a field. Must
land with a chain upgrade (same release class as #5737: coordinated
upgrade / fresh genesis).
- Runtime **assumes flattened** interfaces. The legacy embedded-interface
branches in `FindEmbeddedFieldType` / `VerifyImplementedBy` are dropped; an
`InterfaceKind` entry in `Methods` (only possible by decoding
bytes persisted before this change) is a **hard error** (`panicUnflattened`),
enforced where the concern lives: ungated at the **decode boundary**
(`fillType`, reached from both type-entry decode and object loads — store
bytes are external input, so this check stays in production), and under
`-tags debugAssert` at the interior sites (method resolution, satisfaction,
`TypeID`), which may assume the invariant on a validated store.

## Rollout: state persisted before this change is unsupported (decided)

Every `InterfaceType` is born one of three ways: **preprocess (AST)**,
**runtime (`doOpInterfaceType`)**, or **decode**. The first two always flatten;
decode faithfully reproduces stored bytes. So an unflattened interface can
reach runtime in exactly one situation: decoding bytes persisted **before
this change**.

Tolerating those bytes (the recursion branches this PR originally kept) was a
half-measure: the type's *identity* already moved with this change, so
resolution/satisfaction would "work" against a type whose equality, type-keyed
store entries, and hashes are silently split from post-change construction.
Silent-wrong loses to loud-fail on a chain, so the branches are **dropped**
and a decoded unflattened interface **panics** with an actionable message.

A network carrying state persisted before this change has two supported paths, both of which
make unflattened bytes impossible (the panic is then a dead invariant check):

- **Fresh genesis / tx replay** (how gno.land testnets regenesis): all state
is reconstructed through the new VM, flattened by construction. The only
pre-fork audit is source acceptance — historical txs must still preprocess
(this PR tightens one case: cross-package unexported-method selection).
- **Re-flatten migration** (only if in-place state must survive): walk the
type table, flatten every `InterfaceType` (stamping origin `PkgPath`),
remap old→new `TypeID`s in all object refs, merge newly-colliding entries,
and verify no `InterfaceKind` entry remains.

Conflict handling (same-name, different-signature embedded methods) needs no
follow-up: `flattenInterfaceMethods` `panic`s, but during preprocessing that is
wrapped into a positioned `*PreprocessError` (the same idiom as other type
errors, e.g. "struct has no field"), so it surfaces as a matchable `// Error:`.
go/types also rejects it as a `// TypeCheckError:`. Both are pinned by
`iface_embed_conflict.gno`, matching Go's "duplicate method" compile error.
1 change: 1 addition & 0 deletions gnovm/pkg/gnolang/gnolang.proto
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ message FieldType {
google.protobuf.Any type = 2 [json_name = "Type"];
bool embedded = 3 [json_name = "Embedded"];
string tag = 4 [json_name = "Tag"];
string pkg_path = 5 [json_name = "PkgPath"];
}

message FuncType {
Expand Down
8 changes: 5 additions & 3 deletions gnovm/pkg/gnolang/op_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ func (m *Machine) doOpStructType() {
// allocate (minimum) space for fields
fields := make([]FieldType, 0, len(x.Fields))
// populate fields
for _, ftv := range ftvs {
for i, ftv := range ftvs {
ft := ftv.V.(TypeValue).Type.(FieldType)
fillEmbeddedName(&ft)
fillEmbeddedName(&ft, x.Fields[i].Type)
fields = append(fields, ft)
}
// push struct type
Expand All @@ -142,9 +142,11 @@ func (m *Machine) doOpInterfaceType() {
// pop methods
for i := len(x.Methods) - 1; 0 <= i; i-- {
ft := m.PopValue().V.(TypeValue).Type.(FieldType)
fillEmbeddedName(&ft)
methods[i] = ft
}
// flatten embedded interfaces so identity is the method set, not the
// embedded-interface (alias) spelling; see flattenInterfaceMethods.
methods = flattenInterfaceMethods(methods, m.Package.PkgPath)
// push interface type
it := &InterfaceType{
PkgPath: m.Package.PkgPath,
Expand Down
17 changes: 17 additions & 0 deletions gnovm/pkg/gnolang/parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,5 +163,22 @@ func parityCasesGnolang() []struct {

// SliceValue referring to no backing Value.
{"SliceValue/empty", &SliceValue{Base: nil, Offset: 0, Length: 0, Maxcap: 0}},

// FieldType.PkgPath (wire field 5) — set on an unexported method that
// was flattened out of an interface in package "p". Guards the
// genproto2/reflect parity of that field: without the hand-added
// field-5 marshal in pb3_gen.go, the reflect codec emits PkgPath while
// the fast path drops it, and this case fails.
{"FieldType/unexported-pkgpath", &FieldType{Name: "sec", PkgPath: "p", Type: &FuncType{}}},
{"FieldType/exported-empty-pkgpath", &FieldType{Name: "M", Type: &FuncType{}}},
// InterfaceType carrying both an exported (empty PkgPath) and a
// stamped unexported method — the realistic persisted shape.
{"InterfaceType/stamped-unexported", &InterfaceType{
PkgPath: "q",
Methods: []FieldType{
{Name: "M", Type: &FuncType{}},
{Name: "sec", PkgPath: "p", Type: &FuncType{}},
},
}},
}
}
25 changes: 25 additions & 0 deletions gnovm/pkg/gnolang/pb3_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 10 additions & 5 deletions gnovm/pkg/gnolang/preprocess.go
Original file line number Diff line number Diff line change
Expand Up @@ -4153,9 +4153,13 @@ func staticTypeFromAST(store Store, last BlockNode, x Expr) (Type, bool) {
validateStructFields(st, "<anonymous struct>")
return st, true
case *InterfaceTypeExpr:
pkgPath := packageOf(last).PkgPath
it := &InterfaceType{
PkgPath: packageOf(last).PkgPath,
Methods: buildFieldTypesAST(store, last, x.Methods, true),
PkgPath: pkgPath,
// Build without embed naming (embed=false): flattenInterfaceMethods
// expands embedded interfaces into their method set, making identity
// the method set rather than the embedded-interface (alias) spelling.
Methods: flattenInterfaceMethods(buildFieldTypesAST(store, last, x.Methods, false), pkgPath),
Generic: x.Generic,
}
validateEmbedDepth(it, "<anonymous interface>")
Expand All @@ -4167,8 +4171,9 @@ func staticTypeFromAST(store Store, last BlockNode, x Expr) (Type, bool) {

// buildFieldTypesAST constructs a []FieldType directly from FieldTypeExprs,
// mirroring doOpFieldType + doOp{Struct,Interface,Func}Type aggregation.
// embed=true mirrors doOpStructType/doOpInterfaceType which call
// fillEmbeddedName per field; embed=false mirrors doOpFuncType which does not.
// embed=true mirrors doOpStructType, which names embedded fields per field;
// embed=false mirrors doOpFuncType and the interface path (which leaves embeds
// unnamed and flattens them via flattenInterfaceMethods).
func buildFieldTypesAST(store Store, last BlockNode, fxs FieldTypeExprs, embed bool) []FieldType {
fts := make([]FieldType, len(fxs))
for i := range fxs {
Expand All @@ -4181,7 +4186,7 @@ func buildFieldTypesAST(store Store, last BlockNode, fxs FieldTypeExprs, embed b
ft.Tag = Tag(evalConst(store, last, fx.Tag).GetString())
}
if embed {
fillEmbeddedName(&ft)
fillEmbeddedName(&ft, fx.Type)
}
fts[i] = ft
}
Expand Down
7 changes: 7 additions & 0 deletions gnovm/pkg/gnolang/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,7 @@ func copyFieldsWithRefs(fields []FieldType) []FieldType {
Type: refOrCopyType(field.Type),
Embedded: field.Embedded,
Tag: field.Tag,
PkgPath: field.PkgPath,
}
}
return fieldsCpy
Expand Down Expand Up @@ -1819,6 +1820,12 @@ func fillType(store Store, typ Type) Type {
case *InterfaceType:
for i, mthd := range ct.Methods {
ct.Methods[i].Type = fillType(store, mthd.Type)
// An embed entry means the bytes predate interface flattening
// (unsupported state); reject at this decode boundary, which
// sees every stored type. See panicUnflattened.
if ct.Methods[i].Type.Kind() == InterfaceKind {
ct.panicUnflattened(ct.Methods[i])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one ungated legacy-state check, placed here deliberately: store bytes are external input, and fillType sees every stored type — type entries via GetTypeSafe, and object-carried anonymous types via fillTypesOfValue (only DeclaredType collapses to RefType on persist; unnamed types nest inline, so a GetTypeSafe-only check would miss them). An embed entry can only come from bytes persisted before flattening, so it fails at decode, before any semantics run.

The interior sites (FindEmbeddedFieldType / VerifyImplementedBy / TypeID) assume the invariant on a validated store and only assert under -tags debugAssert.

}
}
return ct
case *TypeType:
Expand Down
Loading