Skip to content

Commit cbd35e2

Browse files
authored
internal/stack: add stack effect checker library (#23)
This adds library code for parsing stack comments and simulating their effect against a symbolic stack.
1 parent e3146cc commit cbd35e2

File tree

5 files changed

+685
-0
lines changed

5 files changed

+685
-0
lines changed

internal/stack/error.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright 2025 The go-ethereum Authors
2+
// This file is part of the go-ethereum library.
3+
//
4+
// The go-ethereum library is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// The go-ethereum library is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package stack
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
)
23+
24+
// Parse errors.
25+
26+
var (
27+
ErrNotStackComment = errors.New("not a stack comment, missing [")
28+
ErrEmptyComment = errors.New("empty comment")
29+
errIncompleteComment = errors.New("incomplete stack comment")
30+
errEmptyItem = errors.New("empty item in stack comment")
31+
errDoubleQuote = errors.New("double-quote not allowed in stack comment")
32+
)
33+
34+
type nestingError struct {
35+
opening, expected, found rune
36+
}
37+
38+
func (e nestingError) Error() string {
39+
return fmt.Sprintf("expected %c to close %c, found %c", e.opening, e.found, e.expected)
40+
}
41+
42+
// Analysis errors.
43+
44+
// ErrOpUnderflows is reported when an operation uses more items than are currently
45+
// available on the stack.
46+
type ErrOpUnderflows struct {
47+
Want int // how many slots the op consumes
48+
Have int // current stack depth
49+
}
50+
51+
func (e ErrOpUnderflows) Error() string {
52+
return fmt.Sprintf("stack underflow: op requires %d items, stack has %d", e.Want, e.Have)
53+
}
54+
55+
// ErrCommentUnderflows is reported when a stack comment declares more items than
56+
// are currently available on the stack.
57+
type ErrCommentUnderflows struct {
58+
Items []string // computed stack
59+
Want int // how many slots the comment declares
60+
}
61+
62+
func (e ErrCommentUnderflows) Error() string {
63+
return fmt.Sprintf("stack has %d items, comment declares %d", len(e.Items), e.Want)
64+
}
65+
66+
// ErrMismatch is reported when a stack comment declares a specific item should be
67+
// contained in a stack slot, but the stack is known to contain a different one at the
68+
// same position.
69+
type ErrMismatch struct {
70+
Items []string // computed stack
71+
Slot int // stack slot index
72+
Want string // what the comment has at that index
73+
}
74+
75+
func (e ErrMismatch) Error() string {
76+
return fmt.Sprintf("stack item %d differs (expected %q, have %q) in %s", e.Slot, e.Want, e.Items[e.Slot], render(e.Items))
77+
}
78+
79+
// ErrCommentRenamesItem is raised when the stack comment changes the name of an existing
80+
// item, i.e. one that wasn't produced by the current operation.
81+
type ErrCommentRenamesItem struct {
82+
Item string
83+
NewName string
84+
}
85+
86+
func (e ErrCommentRenamesItem) Error() string {
87+
return fmt.Sprintf("comment introduces new name %s for existing stack item %s", e.NewName, e.Item)
88+
}

internal/stack/stack.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright 2025 The go-ethereum Authors
2+
// This file is part of the go-ethereum library.
3+
//
4+
// The go-ethereum library is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// The go-ethereum library is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package stack
18+
19+
import (
20+
"fmt"
21+
"slices"
22+
"strings"
23+
24+
"github.com/fjl/geas/internal/set"
25+
)
26+
27+
// Op is an operation that modifies the stack.
28+
type Op interface {
29+
StackIn(imm byte) []string // input items
30+
StackOut(imm byte) []string // output items
31+
}
32+
33+
// Stack is a symbolic EVM stack. It tracks the positions
34+
// of items and their symbolic names.
35+
type Stack struct {
36+
counter int // item counter
37+
stack []int
38+
39+
// item naming
40+
nameToItem map[string]int
41+
itemToName map[int]string
42+
43+
// buffers for apply
44+
opItems map[string]int
45+
opNewItems set.Set[int]
46+
}
47+
48+
func New() *Stack {
49+
return &Stack{
50+
nameToItem: make(map[string]int),
51+
itemToName: make(map[int]string),
52+
opItems: make(map[string]int),
53+
opNewItems: make(set.Set[int]),
54+
}
55+
}
56+
57+
// Init clears the stack and sets its contents.
58+
func (s *Stack) Init(names []string) {
59+
clear(s.nameToItem)
60+
clear(s.itemToName)
61+
s.stack = make([]int, 0, len(names))
62+
for _, name := range slices.Backward(names) {
63+
if item, ok := s.nameToItem[name]; ok {
64+
s.push(item)
65+
} else {
66+
item = s.newItem()
67+
s.push(item)
68+
s.setName(item, name)
69+
}
70+
}
71+
}
72+
73+
// Apply performs a stack manipulation.
74+
// The comment is checked for correctness if non-nil.
75+
func (s *Stack) Apply(op Op, imm byte, comment []string) error {
76+
// Drop consumed items, but remember them by name.
77+
clear(s.opItems)
78+
inputs := op.StackIn(imm)
79+
for i, name := range inputs {
80+
if _, ok := s.opItems[name]; ok {
81+
panic("BUG: op has duplicate input stack item " + name)
82+
}
83+
val, ok := s.get(i)
84+
if !ok {
85+
return ErrOpUnderflows{Want: len(inputs), Have: len(s.stack)}
86+
}
87+
s.opItems[name] = val
88+
}
89+
s.stack = s.stack[:len(s.stack)-len(inputs)]
90+
91+
// Add output items. If any names from the operation's input list are reused, their
92+
// item identifiers will be restored. For all other names, new items are created.
93+
outputs := op.StackOut(imm)
94+
clear(s.opNewItems)
95+
for i := len(outputs) - 1; i >= 0; i-- {
96+
if item, ok := s.opItems[outputs[i]]; ok {
97+
s.push(item)
98+
} else {
99+
item := s.newItem()
100+
s.push(item)
101+
s.opNewItems.Add(item)
102+
}
103+
}
104+
105+
// Check the comment, and apply its names to the stack.
106+
if comment == nil {
107+
return nil
108+
}
109+
for i, name := range comment {
110+
stackItem, ok := s.get(i)
111+
if !ok {
112+
return ErrCommentUnderflows{Items: s.Items(), Want: len(comment)}
113+
}
114+
if item, ok := s.nameToItem[name]; ok && item != stackItem {
115+
return ErrMismatch{Items: s.Items(), Slot: i, Want: name}
116+
}
117+
// The comment is not supposed to rename items that weren't produced by
118+
// this operation.
119+
if !s.opNewItems.Includes(stackItem) && s.nameToItem[name] == 0 {
120+
return ErrCommentRenamesItem{NewName: name, Item: s.itemToName[stackItem]}
121+
}
122+
// Rename the item according to the comment.
123+
s.setName(stackItem, name)
124+
}
125+
// By now the comment is known not to have more items than the stack, and all declared
126+
// names match the stack. Notably, there is no expectation that comments are complete,
127+
// i.e. it's OK if comments elide some items at the end.
128+
// Unfortunately, this also permits a sitation where items can be 'added back' if they
129+
// were dropped from the comment before.
130+
// Consider this example:
131+
//
132+
// push 1 ; [a]
133+
// push 2 ; [b, a]
134+
// push 3 ; [c, b] <-- a is lost here...
135+
// add ; [sum, a] <-- but now it's back! confusing!
136+
//
137+
// I'm not sure if this should be prevented somehow.
138+
139+
return nil
140+
}
141+
142+
// Items returns a list of current stack items.
143+
func (s *Stack) Items() []string {
144+
items := make([]string, len(s.stack))
145+
for i := range items {
146+
item, _ := s.get(i)
147+
items[i] = s.getName(item)
148+
}
149+
return items
150+
}
151+
152+
// String returns a description of the current stack.
153+
func (s *Stack) String() string {
154+
return render(s.Items())
155+
}
156+
157+
func render(stk []string) string {
158+
var out strings.Builder
159+
out.WriteByte('[')
160+
for i, name := range stk {
161+
if i > 0 {
162+
out.WriteString(", ")
163+
}
164+
out.WriteString(name)
165+
}
166+
out.WriteByte(']')
167+
return out.String()
168+
}
169+
170+
// push adds an item at the top of the stack.
171+
func (s *Stack) push(item int) {
172+
s.stack = append(s.stack, item)
173+
}
174+
175+
// get accesses item i (zero is top).
176+
func (s *Stack) get(i int) (val int, ok bool) {
177+
if i < 0 {
178+
panic("BUG: negative stack offset")
179+
}
180+
if i > len(s.stack)-1 {
181+
return 0, false
182+
}
183+
return s.stack[len(s.stack)-1-i], true
184+
}
185+
186+
// newItem creates a new item (but does not add it to the stack).
187+
func (s *Stack) newItem() int {
188+
s.counter++
189+
return s.counter
190+
}
191+
192+
// setName sets the name of a stack item.
193+
func (s *Stack) setName(item int, name string) {
194+
s.itemToName[item] = name
195+
s.nameToItem[name] = item
196+
}
197+
198+
// getName reports the known name of an item, or invents one.
199+
func (s *Stack) getName(item int) string {
200+
name, ok := s.itemToName[item]
201+
if ok {
202+
return name
203+
}
204+
return fmt.Sprintf("_%d", item)
205+
}

0 commit comments

Comments
 (0)