fix(gnovm): meter gas correctly for switch case#5217
Conversation
…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.
🛠 PR Checks SummaryAll Automated Checks passed. ✅ Manual Checks (for Reviewers):
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) ☑️ Contributor Actions:
☑️ Reviewer Actions:
📚 Resources:Debug
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Should you add a filetest to compare gas usage depending on the size of switch case ? |
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.
|
And I added the filetests: 9a38c56 |
notJoon
left a comment
There was a problem hiding this comment.
Could you resolve the conflict? thank you!
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.
…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.
jefft0
left a comment
There was a problem hiding this comment.
This PR was approved by MikaelVallenet. Ready for core dev reiview.
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
left a comment
There was a problem hiding this comment.
A few comments to improve but overall looks good!
| // 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 |
There was a problem hiding this comment.
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.
| func main() { | ||
| var x interface{} = 1 | ||
| switch x.(type) { | ||
| case int: |
There was a problem hiding this comment.
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.
| // 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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Left as a follow-up, will do in a next PR!
| case *DeclaredType: | ||
| n = int64(len(tt.Methods)) | ||
| if st, ok := baseOf(tt).(*StructType); ok { | ||
| n += countEmbeddedFieldMethods(st, seen) | ||
| } |
There was a problem hiding this comment.
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.
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
fix:
Fix flat gas cost in
doOpTypeSwitchby adding per-clause, per-case, and per-interface-method gas charges using existing CPU constants.