Skip to content

fix(gnovm): meter gas correctly for switch case#5217

Open
davd-gzl wants to merge 18 commits into
gnolang:masterfrom
davd-gzl:fix/type-switch-gas-metering
Open

fix(gnovm): meter gas correctly for switch case#5217
davd-gzl wants to merge 18 commits into
gnolang:masterfrom
davd-gzl:fix/type-switch-gas-metering

Conversation

@davd-gzl

@davd-gzl davd-gzl commented Mar 2, 2026

Copy link
Copy Markdown
Member

fix:

Fix flat gas cost in doOpTypeSwitch by adding per-clause, per-case, and per-interface-method gas charges using existing CPU constants.

…switch

doOpTypeSwitch previously charged a flat OpCPUTypeSwitch (171) regardless
of the number of clauses, cases, or interface methods checked. This allowed
O(N*M) CPU work for O(1) gas, enabling denial-of-service via crafted type
switch statements with many interface cases.

Add per-iteration gas charges inside doOpTypeSwitch, reusing existing
constants: OpCPUSwitchClause (38) per clause, OpCPUSwitchClauseCase (143)
per case, and OpCPUTypeAssert1 (30) per interface method. This makes gas
scale linearly with actual work, consistent with expression switch metering.
@github-actions github-actions Bot added the 📦 🤖 gnovm Issues or PRs gnovm related label Mar 2, 2026
@Gno2D2 Gno2D2 added the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Mar 2, 2026
@Gno2D2

Gno2D2 commented Mar 2, 2026

Copy link
Copy Markdown
Collaborator

🛠 PR Checks Summary

All Automated Checks passed. ✅

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
Read More

🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers.

✅ Automated Checks (for Contributors):

🟢 Maintainers must be able to edit this pull request (more info)
🟢 Pending initial approval by a review team member, or review from tech-staff

☑️ Contributor Actions:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Automated Checks
Maintainers must be able to edit this pull request (more info)

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 The pull request was created from a fork (head branch repo: davd-gzl/gno)

Then

🟢 Requirement satisfied
└── 🟢 Maintainer can modify this pull request

Pending initial approval by a review team member, or review from tech-staff

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 Not (🔴 Pull request author is a member of the team: tech-staff)

Then

🟢 Requirement satisfied
└── 🟢 If
    ├── 🟢 Condition
    │   └── 🟢 Or
    │       ├── 🟢 User notJoon already reviewed PR 5217 with state APPROVED
    │       ├── 🟢 At least 1 user(s) of the team tech-staff reviewed pull request
    │       └── 🔴 This pull request is a draft
    └── 🟢 Then
        └── 🟢 Not (🔴 This label is applied to pull request: review/triage-pending)

Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission

@codecov

codecov Bot commented Mar 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.78378% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
gnovm/pkg/gnolang/types.go 81.25% 7 Missing and 5 partials ⚠️

📢 Thoughts on this report? Let us know!

@mvallenet mvallenet added this to the 🐛Beta Bug Bash milestone Mar 2, 2026
@mvallenet

Copy link
Copy Markdown
Contributor

Should you add a filetest to compare gas usage depending on the size of switch case ?

Comment thread gnovm/pkg/gnolang/op_exec.go Outdated
Comment thread gnovm/pkg/gnolang/op_exec.go Outdated
@Kouteki Kouteki moved this from Triage to In Review in 🧙‍♂️Gno.land development Mar 4, 2026
davd-gzl and others added 3 commits March 8, 2026 17:11
Add InterfaceType.TotalMethodCount() to recursively count methods
including embedded interfaces, replacing len(it.Methods) which
undercounted when interfaces embed other interfaces.

Also add per-method gas metering to doOpTypeAssert1 and doOpTypeAssert2,
which were missing gas charges proportional to interface method count.
This closes the same O(N*M) DoS vector that was fixed for type switches.
@davd-gzl
davd-gzl requested a review from mvallenet March 8, 2026 16:15
@davd-gzl

davd-gzl commented Mar 8, 2026

Copy link
Copy Markdown
Member Author

And I added the filetests: 9a38c56

Comment thread gnovm/pkg/gnolang/op_expressions.go Outdated
@Gno2D2 Gno2D2 removed the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Mar 9, 2026
@mvallenet
mvallenet requested review from ltzmaxwell and thehowl March 23, 2026 14:44
@mvallenet mvallenet moved this from Waiting for Internal Review (PR) to Ready For Core Review (PR) in Security Bugs - HackenProof Workflow Mar 23, 2026
@nemanjantic nemanjantic added the a/vm GnoVM, Security, Runtime team label Apr 8, 2026

@notJoon notJoon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you resolve the conflict? thank you!

Comment thread gnovm/pkg/gnolang/types.go Outdated
Resolve conflicts after gnolang#5291 (gas model recalibration) landed.

- machine.go: take master's recalibrated CPU constants; reintroduce
  OpCPUInterfaceMethodCheck = 4 for the N*M VerifyImplementedBy term
  on top of master's linear OpCPUSlopeTypeAssertIface.
- op_expressions.go (doOpTypeAssert1/2) and op_exec.go (doOpTypeSwitch
  interface case): replace per-N charge with calcMethodCheckGasCost,
  preserving recursive method counting and the concrete-side M factor
  master misses.
- Replace typeswitch_gas.txtar with focused filetests under
  gnovm/tests/files/gas/ matching the gnolang#5154 style: small/large pairs
  for typeassert iface, typeswitch iface, and typeswitch clauses,
  plus an embedded-interface case to verify TotalMethodCount recursion.
@Gno2D2 Gno2D2 added the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Apr 29, 2026
@davd-gzl
davd-gzl marked this pull request as draft April 29, 2026 19:13
@Gno2D2 Gno2D2 removed the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Apr 29, 2026
davd-gzl and others added 4 commits April 30, 2026 18:38
…d probe

Type switches and interface type assertions charged gas via two overlapping
schemes that diverged from the actual work performed:
- OpCPUSlopeTypeSwitchCase fired per clause regardless of whether a method
  walk happened (concrete-type clauses do none).
- calcMethodCheckGasCost computed costPerMethod * N * M up front, mixing
  interface and concrete method counts in a way that did not match the
  O(M) per-method cost of findEmbeddedFieldType.

Replace both with a single per-leaf-method-probe charge inside
verifyImplementedBy: perCheck = OpCPUInterfaceMethodCheck * methods(ot),
charged once per probed interface method. The concrete-method count walks
pointer indirection, embedded struct fields, and embedded interfaces with
a cycle guard against self-referential types.

Also:
- Hoist perCheck out of doOpTypeSwitch's clause loop (constant for xv.T).
- Rename FindEmbeddedFieldType's recursion-guard parameter m -> seen to
  disambiguate from *Machine.
- Add gas tests for pointer concrete (typeswitch_iface_pointer.gno) and
  embedded-struct (typeswitch_iface_embedded_struct.gno) cases.

Fixes gnolang#5217.
…ction checks

Split VerifyImplementedBy into a gas-free public method for compile-time
and debug paths, and an unexported verifyImplementedBy that takes a
precomputed perCheck cost for VM paths. This allows hoisting the
perInterfaceMethodCheckCost computation out of type-switch clause loops
and computing it lazily only when an interface case is encountered.
Remove the unused OpCPUSlopeTypeAssertIface constant.
…y wrappers

Inline the only two gas-free callers to verifyImplementedBy(nil, 0, ot)
and remove the method-level wrappers that just forwarded to it. The
package-level IsImplementedBy(it, ot) is kept as it folds the baseOf
type switch and is used by several gas-free callers.
@davd-gzl
davd-gzl marked this pull request as ready for review April 30, 2026 20:25
@davd-gzl
davd-gzl requested a review from notJoon April 30, 2026 20:25
@Gno2D2 Gno2D2 added the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label Apr 30, 2026

@jefft0 jefft0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR was approved by MikaelVallenet. Ready for core dev reiview.

@Gno2D2 Gno2D2 removed the review/triage-pending PRs opened by external contributors that are waiting for the 1st review label May 4, 2026
@lbrown2007

Copy link
Copy Markdown

@omarsy @notJoon this PR "At least 1 approving review is required to merge this pull request." Could one of you review?

Resolve doOpTypeSwitch conflict: keep master's deferred-default two-pass
structure, re-apply per-clause/case/interface-method gas metering via
verifyImplementedBy. Recalibrate typeswitch/typeassert gas goldens (+6
flat, from master's per-program overhead; metering unchanged).

@thehowl thehowl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few comments to improve but overall looks good!

Comment thread gnovm/pkg/gnolang/machine.go Outdated
Comment on lines +1466 to +1470
// OpCPUInterfaceMethodCheck is the per-probe cost of one
// findEmbeddedFieldType call when verifying interface satisfaction.
// TODO: calibrate via bench grid over interface method count (N) and
// concrete method-set size (M); 4 is a placeholder.
OpCPUInterfaceMethodCheck = 4

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The charge here is 4 × M, purely proportional to the method count. But each method check (findEmbeddedFieldType) also has a fixed cost that doesn't depend on M — mostly allocating a fresh seen map plus call overhead. Since we only bill the per-method part, that fixed part goes uncharged, and no single value of this constant can cover both a fixed and a per-method cost.

The benchmark data already in the repo shows the two parts. BenchmarkOpTypeAssert1_Interface_{1,10,100} (in gnovm/cmd/calibrate/op_bench_do_dedicated.txt, the same machine that produced the old fit: 348.9) measures roughly 197 / 1530 / 34000 ns, which works out to about 125 + 2.2×M ns per check (≈1 gas per ns). So 4×M covers the slope with headroom but charges nothing for the ~125 fixed part: we undercharge a lot at low method counts (~32× at M=1, ~3.7× at M=10), break even around M≈62, and slightly overcharge at M≥100.

To be clear, this isn't a reopened DoS — the MaxInterfaceMethods/MaxStructFields caps keep worst-case sustained amplification to ~2–4× once op and loop overhead are counted. It's more that we've lost accuracy in the common case compared to the old 349/method.

Fix is small: add a fixed base next to the slope, e.g. have perInterfaceMethodCheckCost return OpCPUIfaceProbeBase + OpCPUInterfaceMethodCheck*count with base ≈ 125. That matches all three measured points and only nudges the goldens.

@davd-gzl davd-gzl Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fix: aba6dc6

func main() {
var x interface{} = 1
switch x.(type) {
case int:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test doesn't exercise the per-clause charging it describes: case int matches on the first clause, so matchLoop breaks immediately and the OpCPUSwitchClause/OpCPUSwitchClauseCase charges for the other 7 clauses are never applied. The +102 gas vs typeswitch_clauses_small.gno is just source-size overhead — the runtime scan gas is identical in both tests.

Verified by moving case int to the end: gas becomes 5285 vs the committed 3916, a delta of 1369 ≈ 7 × (87 + 109), which is exactly the scan charge this golden is meant to pin down. Suggest reordering so the matching case is last (and updating the golden to 5285) so the test actually locks the per-clause metering.

@davd-gzl davd-gzl Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fix: aba6dc6

// Used to meter the O(N*M) cost of VerifyImplementedBy. Returns at least 1.
// The seen map guards against cyclic types (e.g. self-referential structs);
// pass nil to start a fresh walk.
func countTypeMethodsForGas(t Type, seen map[Type]struct{}) int64 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This walk is itself unmetered and recomputed on every doOpTypeAssert1/2 execution (e.g. every iteration of a loop doing x.(I)): each call allocates a fresh seen map and can visit up to ~128 embedded fields of free work per op. For a 1-method interface miss against a struct with 128 embedded fields in a tight loop, that's roughly 2.4µs of real work for ~600 gas (~4× undercharge), dominated by this uncharged walk.

Suggestion: memoize the count on the type — there's precedent right below with the effectiveInterfaceMethods cache — or fold a charge for the walk into the op cost. Fine as a follow-up to this PR.

@davd-gzl davd-gzl Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Left as a follow-up, will do in a next PR!

Comment on lines +1147 to +1151
case *DeclaredType:
n = int64(len(tt.Methods))
if st, ok := baseOf(tt).(*StructType); ok {
n += countEmbeddedFieldMethods(st, seen)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The *DeclaredType case undercounts when the base is an *InterfaceType — i.e. every named interface (error, io.Writer, fmt.Stringer, …). A named interface's methods live in its underlying *InterfaceType, not in DeclaredType.Methods (that slice holds concrete methods), so len(tt.Methods) is 0, the baseOf(tt).(*StructType) branch doesn't fire, and the function falls through to the n < 1 → return 1 clamp. It counts 1 regardless of the interface's real method count.

A bare named interface can't be ot directly (the assert handlers early-return on interface xt, and a type-switch xv.T is always concrete), but a concrete type that embeds a named interface hits this — the common type S struct { io.Writer } pattern. Verified with two equivalent 10-method structs in a case I type switch:

concrete type count perCheck (×4) verify (10 probes)
struct{ Inner } (embeds 10-method struct) 10 40 400
struct{ W } (embeds 10-method named iface) 1 4 40

Both do the same findEmbeddedFieldType work (each probe walks into the embedded field and scans its method set), so the O(N×M) cost is identical, but the embedded-interface case is charged ~10× less (measured totals 5829 vs 6376 gas). The bare-*InterfaceType case counts correctly; the gap is specifically named interfaces.

Suggested fix:

case *DeclaredType:
    n = int64(len(tt.Methods))
    switch bt := baseOf(tt).(type) {
    case *StructType:
        n += countEmbeddedFieldMethods(bt, seen)
    case *InterfaceType:
        n += countTypeMethodsForGas(bt, seen)
    }

Note this compounds with the 4×M slope issue above — even once the interface base is counted, the missing per-probe intercept still under-weights small/mid method counts.

@davd-gzl davd-gzl Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fix: aba6dc6

davd-gzl and others added 2 commits July 15, 2026 19:17
Resolve the types.go conflict from interface method-set flattening (gnolang#5739):
drop the now-dead embedded-interface recursion and cycle guard in
FindEmbeddedFieldType and verifyImplementedBy (Methods is flattened, so every
entry is a concrete method), keep this PR's per-probe gas metering and the
seen param rename, and adapt the new flattening test to the unexported method
name.

Also fold in the review response (thehowl):
- OpCPUIfaceProbeBase: charge a fixed base per probe, not only the slope
- countTypeMethodsForGas: count a DeclaredType's named-interface base, not
  just a struct base, so promoted interface methods are metered
- typeswitch_clauses_large: put the matching clause last so the per-clause
  scan is actually charged
- add typeswitch_iface_embedded_iface gas test
- simplify gas comments; follow up the VerifyImplementedBy -> verifyImplementedBy
  rename across remaining references
@davd-gzl
davd-gzl requested a review from thehowl July 15, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a/vm GnoVM, Security, Runtime team 📦 🤖 gnovm Issues or PRs gnovm related

Projects

Status: In Review
Status: In Progress

Development

Successfully merging this pull request may close these issues.

9 participants