Skip to content

Commit

Permalink
Debugger terminal UI (#16)
Browse files Browse the repository at this point in the history
* feat: expose `*vm.Contract` in `runopts.Configuration`

* feat: rough initial implementation of debugger terminal UI

* refactor: create SpecOps-agnostic `evmdebug` package, moving relevant code out of `runopts` (excluding UI)

* refactor: move UI into `evmdebug`

* doc: debugger UI in README
  • Loading branch information
aschlosberg authored Mar 4, 2024
1 parent 3a1642b commit 957efb7
Show file tree
Hide file tree
Showing 7 changed files with 360 additions and 25 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ bytecode unchanged.
- [ ] Standalone compiler
- [x] In-process EVM execution (geth)
- [x] Debugger
- [x] Single call frame (via `vm.EVMInterpreter`)
- [ ] Multiple call frames; i.e. support `*CALL` methods
* [x] Stepping
* [ ] Breakpoints
* [x] Programmatic inspection (e.g. native Go tests at opcode resolution)
* [x] Memory
* [x] Stack
* [ ] User interface
* [x] User interface
- [ ] Source mapping
- [ ] Coverage analysis
- [ ] Fork testing with RPC URL
Expand Down Expand Up @@ -81,6 +83,9 @@ result, err := code.Run(nil /*callData*/ /*, [runopts.Options]...*/)
// ...

// ----- DEBUG (Programmatic) -----
//
// ***** See below for the debugger's terminal UI *****
//

dbg, results := code.StartDebugging(nil /*callData*/ /*, Options...*/)
defer dbg.FastForward() // best practice to avoid resource leaks
Expand All @@ -104,6 +109,10 @@ result, err := results()
- [Monte Carlo approximation of pi](https://github.com/solidifylabs/specops/blob/41efe932c9a85e45ce705b231577447e6c944487/examples_test.go#L158)
- [`sqrt()`](https://github.com/solidifylabs/specops/blob/41efe932c9a85e45ce705b231577447e6c944487/examples_test.go#L246) as seen ~~on TV~~ in `prb-math` ([original](https://github.com/PaulRBerg/prb-math/blob/5b6279a0cf7c1b1b6a5cc96082811f7ef620cf60/src/Common.sol#L595))

### Debugger

![image](https://github.com/solidifylabs/specops/assets/519948/5057ad0f-bb6f-438b-a295-8b1f410d2330)

## Acknowledgements

Some of SpecOps was, of course, inspired by
Expand Down
25 changes: 17 additions & 8 deletions runopts/debugger.go → evmdebug/evmdebug.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
package runopts
// Package evmdebug provides debugging mechanisms for EVM contracts,
// intercepting opcode-level execution and allowing for inspection of data such
// as the VM's stack and memory.
package evmdebug

import (
"sync"
Expand All @@ -13,6 +16,8 @@ import (
// usually in a deferred function.
//
// Debugger.State().Err SHOULD be checked once Debugger.Done() returns true.
//
// NOTE: see the limitations described in the Debugger comments.
func NewDebugger() *Debugger {
started := make(chan started)
step := make(chan step)
Expand Down Expand Up @@ -49,8 +54,12 @@ type (
done struct{}
)

// A Debugger is an Option that intercepts opcode execution to allow inspection
// of the stack, memory, etc.
// A Debugger intercepts EVM opcode execution to allow inspection of the stack,
// memory, etc. The value returned by its Tracer() method should be placed
// inside a vm.Config before execution commences.
//
// Currently only a single frame is supported (i.e. no *CALL methods). This
// requires execution with a vm.EVMInterpreter.
type Debugger struct {
d *debugger

Expand All @@ -63,11 +72,11 @@ type Debugger struct {
done <-chan done
}

// Apply adds a VMConfig.Tracer to the Configuration, intercepting execution of
// every opcode.
func (d *Debugger) Apply(c *Configuration) error {
c.VMConfig.Tracer = d.d
return nil
// Tracer returns an EVMLogger that enables debugging, compatible with geth.
//
// TODO: add an example demonstrating how to access vm.Config.
func (d *Debugger) Tracer() vm.EVMLogger {
return d.d
}

// Wait blocks until the bytecode is ready for execution, but the first opcode
Expand Down
228 changes: 228 additions & 0 deletions evmdebug/ui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package evmdebug

import (
"fmt"

"github.com/ethereum/go-ethereum/core/vm"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)

// RunTerminalUI starts a UI that controls the Debugger and displays opcodes,
// memory, stack etc. Because of the current Debugger limitation of a single
// call frame, only that exact Contract can be displayed. The callData is
// assumed to be the same as passed to the execution environment.
//
// As the Debugger only has access via a vm.EVMLogger, it can't retrieve the
// final result. The `results` argument MUST return the returned buffer / error
// after d.Done() returns true.
func (d *Debugger) RunTerminalUI(callData []byte, results func() ([]byte, error), contract *vm.Contract) error {
t := &termDBG{
Debugger: d,
results: results,
}
t.initComponents()
t.initApp()
t.populateCallData(callData)
t.populateCode(contract)
return t.app.Run()
}

type termDBG struct {
*Debugger
app *tview.Application

stack, memory *tview.List
callData, result *tview.TextView

code *tview.List
pcToCodeItem map[uint64]int

results func() ([]byte, error)
}

func (*termDBG) styleBox(b *tview.Box, title string) *tview.Box {
return b.SetBorder(true).
SetTitle(title).
SetTitleAlign(tview.AlignLeft)
}

func (t *termDBG) initComponents() {
const codeTitle = "Code"
for title, l := range map[string]**tview.List{
"Stack": &t.stack,
"Memory": &t.memory,
codeTitle: &t.code,
} {
*l = tview.NewList()
(*l).ShowSecondaryText(false).
SetSelectedFocusOnly(title != codeTitle)
t.styleBox((*l).Box, title)
}

t.code.SetChangedFunc(func(int, string, string, rune) {
t.onStep()
})

for title, v := range map[string]**tview.TextView{
"calldata": &t.callData,
"Result": &t.result,
} {
*v = tview.NewTextView()
t.styleBox((*v).Box, title)
}
}

func (t *termDBG) initApp() {
t.app = tview.NewApplication().SetRoot(t.createLayout(), true)
t.app.SetInputCapture(t.inputCapture)
}

func (t *termDBG) createLayout() tview.Primitive {
// Components have borders of 2, which need to be accounted for in absolute
// dimensions.
const (
hStack = 2 + 16
wStack = 2 + 5 + 64 // w/ 4-digit decimal label & space
wMem = 2 + 3 + 64 // w/ 2-digit hex offset & space
)
middle := tview.NewFlex().
AddItem(t.code, 0, 1, false).
AddItem(t.stack, wStack, 0, false).
AddItem(t.memory, wMem, 0, false)

root := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(t.callData, 0, 1, false).
AddItem(middle, hStack, 0, false).
AddItem(t.result, 0, 1, false)

t.styleBox(root.Box, "SPEC0PS").SetTitleAlign(tview.AlignCenter)

return root
}

func (t *termDBG) populateCallData(cd []byte) {
t.callData.SetText(fmt.Sprintf("%x", cd))
}

func (t *termDBG) populateCode(c *vm.Contract) {
t.pcToCodeItem = make(map[uint64]int)

var skip int
for i, o := range c.Code {
if skip > 0 {
skip--
continue
}

var text string
switch op := vm.OpCode(o); {
case op == vm.PUSH0:
text = op.String()

case op.IsPush():
skip += int(op - vm.PUSH0)
text = fmt.Sprintf("%s %#x", op.String(), c.Code[i+1:i+1+skip])

default:
text = op.String()
}

t.pcToCodeItem[uint64(i)] = t.code.GetItemCount()
t.code.AddItem(text, "", 0, nil)
}

t.code.AddItem("--- END ---", "", 0, nil)
}

func (t *termDBG) highlightPC() {
t.code.SetCurrentItem(t.pcToCodeItem[t.State().PC] + 1)
}

// onStep is triggered by t.code's ChangedFunc.
func (t *termDBG) onStep() {
if !t.Done() {
return
}
t.result.SetText(t.resultToDisplay())
}

func (t *termDBG) resultToDisplay() string {
out, err := t.results()
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
return fmt.Sprintf("%x", out)
}

func (t *termDBG) inputCapture(ev *tcell.EventKey) *tcell.EventKey {
var propagate bool

switch ev.Key() {
case tcell.KeyCtrlC:
t.app.Stop()
return ev

case tcell.KeyEnd:
t.FastForward()
t.highlightPC()

case tcell.KeyEscape:
if t.Done() {
t.app.Stop()
}
} // switch ev.Key()

switch ev.Rune() {
case ' ':
if !t.Done() {
t.Step()
t.highlightPC()
}

case 'q':
if t.Done() {
t.app.Stop()
}
} // switch ev.Rune()

t.populateStack()
t.populateMemory()

if propagate {
return ev
}
return nil
}

func (t *termDBG) populateStack() {
stack := t.State().ScopeContext.Stack
data := stack.Data()

t.stack.Clear()
for i := len(data) - 1; i >= 0; i-- {
item := data[i]
buf := item.Bytes()
if item.IsZero() {
buf = []byte{0}
}
t.stack.AddItem(fmt.Sprintf("%4d %64x", len(data)-i, buf), "", 0, nil)
}

// Empty lines so real values are at the bottom
for t.stack.GetItemCount() < 16 {
t.stack.InsertItem(0, "", "", 0, nil)
}
}

func (t *termDBG) populateMemory() {
mem := t.State().ScopeContext.Memory
heap := mem.Data()

t.memory.Clear()
for i, n := 0, len(heap); i < n; i += 32 {
t.memory.AddItem(fmt.Sprintf("%02x %x", i, heap[i:32]), "", 0, nil)
heap = heap[n:]
}
}
10 changes: 9 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ go 1.20

require (
github.com/ethereum/go-ethereum v1.13.14
github.com/gdamore/tcell/v2 v2.7.1
github.com/google/go-cmp v0.5.9
github.com/holiman/uint256 v1.2.4
github.com/rivo/tview v0.0.0-20240225120200-5605142ca62e
)

require (
Expand All @@ -16,11 +18,17 @@ require (
github.com/crate-crypto/go-kzg-4844 v0.7.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/ethereum/c-kzg-4844 v0.4.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mmcloughlin/addchain v0.4.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/supranational/blst v0.3.11 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/term v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)
Loading

0 comments on commit 957efb7

Please sign in to comment.