Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: precompile Solidity testing in pure Go #1234

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1065aed
feat: `dstest` Go package for parsing `DSTest` Solidity logs
ARR4N Jul 12, 2024
63213be
feat: `evmsim` package
ARR4N Jul 12, 2024
726f095
test(dstest): use `evmsim` to test parsing of `DSTest` logs
ARR4N Jul 12, 2024
be80db4
test(allowlist): port some of the precompile's Hardhat test to Go
ARR4N Jul 12, 2024
64e7070
chore: fix linter issue caused by earlier refactoring
ARR4N Jul 12, 2024
114005e
fix: revert deliberate breakage of `ExampleTxAllowListTest`
ARR4N Jul 12, 2024
ef5c48b
refactor: move genesis precompiles into `evmsim` constructor config
ARR4N Jul 16, 2024
31459e8
feat: `scripts/abigen` command for cleaner `//go:generate` directives
ARR4N Jul 16, 2024
a55b0ff
refactor: remove duplicate `contract Example` and replace with a sing…
ARR4N Jul 16, 2024
30931a4
fix: add regex anchor of double-quote to signal Go import
ARR4N Jul 16, 2024
ddc31fb
fix: escape . in regex
ARR4N Jul 16, 2024
b01791a
Merge branch 'master' into arr4n/precompile-go-tests
ARR4N Jul 16, 2024
34f98fd
chore: CI check that `abigen` output is up to date
ARR4N Jul 16, 2024
66e4cbd
fix(in theory): run `go generate` directly, without installing `abige…
ARR4N Jul 16, 2024
6253369
chore: bump default `solc` to 0.8.26 to match `apt`
ARR4N Jul 16, 2024
9f7aa8f
chore: run `abigen` update check inside precompile-test job
ARR4N Jul 16, 2024
d6e5780
I hate GitHub Actions. I'm not committing anything, just complaining.
ARR4N Jul 16, 2024
06d91ec
fix: install `abigen` in CI
ARR4N Jul 16, 2024
dfc0694
fix: use `subnet-evm/cmd/abigen` instead of `geth` version in CI
ARR4N Jul 17, 2024
93b6301
chore: `submodules: true` in `actions/checkout` config for `e2e_preco…
ARR4N Jul 17, 2024
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
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "testing/dstest/internal/ds-test"]
path = testing/dstest/internal/ds-test
url = https://github.com/dapphub/ds-test.git
50 changes: 50 additions & 0 deletions contracts/contracts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package contracts

import (
"testing"

"github.com/ava-labs/subnet-evm/params"
"github.com/ava-labs/subnet-evm/testing/evmsim"
)

//go:generate sh -c "go run $(git rev-parse --show-toplevel)/scripts/abigen --solc.include-path=./node_modules --abigen.pkg=contracts contracts/**/*.sol > generated_test.go"

func newEVMSim(tb testing.TB, genesis params.Precompiles) *evmsim.Backend {
tb.Helper()

cfg := &evmsim.Config{
GenesisPrecompiles: genesis,
}
return cfg.NewWithHexKeys(tb, keys())
}

// keys returns the hex-encoded private keys of the testing accounts; these
// identically match the accounts used in the Hardhat config.
func keys() []string {
return []string{
"0x56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027",
"0x7b4198529994b0dc604278c99d153cfd069d594753d471171a1d102a10438e07",
"0x15614556be13730e9e8d6eacc1603143e7b96987429df8726384c2ec4502ef6e",
"0x31b571bf6894a248831ff937bb49f7754509fe93bbd2517c9c73c4144c0e97dc",
"0x6934bef917e01692b789da754a0eae31a8536eb465e7bff752ea291dad88c675",
"0xe700bdbdbc279b808b1ec45f8c2370e4616d3a02c336e68d85d4668e08f53cff",
"0xbbc2865b76ba28016bc2255c7504d000e046ae01934b04c694592a6276988630",
"0xcdbfd34f687ced8c6968854f8a99ae47712c4f4183b78dcc4a903d1bfe8cbf60",
"0x86f78c5416151fe3546dece84fda4b4b1e36089f2dbc48496faf3a950f16157c",
"0x750839e9dbbd2a0910efe40f50b2f3b2f2f59f5580bb4b83bd8c1201cf9a010a",
}
}

// Convenience labels for using an account by name instead of number.
const (
admin = iota
_
_
_
_
_
_
_
_
allowlistOther
)
28,701 changes: 28,701 additions & 0 deletions contracts/generated_test.go

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions contracts/txallowlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package contracts

import (
"context"
"testing"

"github.com/ava-labs/subnet-evm/core/types"
"github.com/ava-labs/subnet-evm/params"
"github.com/ava-labs/subnet-evm/testing/dstest"
"github.com/ava-labs/subnet-evm/testing/evmsim"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"

"github.com/ava-labs/subnet-evm/precompile/contracts/txallowlist"
_ "github.com/ava-labs/subnet-evm/precompile/registry"
)

func TestAllowList(t *testing.T) {
// This is a demonstration of a Go implementation of the Hardhat tests:
// https://github.com/ava-labs/subnet-evm/blob/dc1d78da/contracts/test/tx_allow_list.ts

ctx := context.Background()

sim := newEVMSim(t, params.Precompiles{
txallowlist.ConfigKey: txallowlist.NewConfig(
new(uint64),
[]common.Address{common.HexToAddress("0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC")},
nil, nil,
),
})

allow := evmsim.Bind(t, sim, NewIAllowList, txallowlist.ContractAddress)
allowSess := &IAllowListSession{
Contract: allow,
TransactOpts: *sim.From(admin),
}

sutAddr, sut := evmsim.Deploy(t, sim, admin, DeployExampleTxAllowListTest)
sutSess := &ExampleTxAllowListTestSession{
Contract: sut,
TransactOpts: *sim.From(admin),
}
parser := dstest.New(sutAddr)

_, err := allowSess.SetAdmin(sutAddr)
require.NoErrorf(t, err, "%T.SetAdmin(%T address)", allow, sut)

_, err = sutSess.SetUp()
require.NoErrorf(t, err, "%T.SetUp()", sut)

// TODO: This table of steps is purely to demonstrate a *direct*
// reimplementation of the Hardhat tests in Go. I (arr4n) believe we should
// refactor the tests before a complete translation, primarily to reduce the
// number of calls that have to happen here. Also note that the original
// tests use a `beforeEach()` whereas the above preamble is equivalent to a
// `beforeAll()`.
for _, step := range []struct {
name string
fn (func() (*types.Transaction, error))
}{
{"should add contract deployer as admin", sutSess.StepContractOwnerIsAdmin},
{"precompile should see admin address has admin role", sutSess.StepPrecompileHasDeployerAsAdmin},
{"precompile should see test address has no role", sutSess.StepNewAddressHasNoRole},
} {
t.Run(step.name, func(t *testing.T) {
tx, err := step.fn()
require.NoError(t, err, "running step")

// TODO(arr4n): DSTest can be used for general logging, so the
// following pattern is only valid when we don't use it as such.
// Failing assertions, however, set a private `failed` boolean,
// which we need to expose. Alternatively, they also hook into HEVM
// cheatcodes if they're implemented; having these would be useful
// for testing in general.
failures := parser.ParseTB(ctx, t, tx, sim)
if len(failures) > 0 {
t.Errorf("Assertion failed:\n%s", failures)
}
})
}
}
106 changes: 106 additions & 0 deletions scripts/abigen/abigen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// The abigen command runs `solc | abigen` and writes the generated bindings to
// stdout.
package main

import (
"bytes"
"context"
"flag"
"fmt"
"io"
"os"
"os/exec"
"regexp"
)

func main() {
var c config

flag.StringVar(&c.solc.version, "solc.version", "0.8.24", "Version of solc expected; the version used will be sourced from $PATH")
flag.StringVar(&c.solc.evmVersion, "solc.evm-version", "paris", "solc --evm-version flag")
flag.StringVar(&c.solc.basePath, "solc.base-path", "./", "solc --base-path flag")
flag.StringVar(&c.solc.includePath, "solc.include-path", "", "solc --include-path flag; only propagated if not empty")
flag.StringVar(&c.solc.output, "solc.output", "abi,bin", "solc --combined-json flag")
flag.StringVar(&c.abigen.pkg, "abigen.pkg", "", "abigen --pkg flag")

help := flag.Bool("help", false, "Print usage message")
flag.Parse()
if *help {
flag.Usage()
os.Exit(0)
}

if err := c.run(context.Background(), os.Stdout, os.Stderr); err != nil {
fmt.Fprint(os.Stderr, err)
os.Exit(1)
}
}

type config struct {
solc struct {
version, evmVersion, basePath, includePath, output string
}
abigen struct {
pkg string
}
}

func (cfg *config) run(ctx context.Context, stdout, stderr io.Writer) error {
solcV := exec.CommandContext(ctx, "solc", "--version")
buf, err := solcV.CombinedOutput()
if err != nil {
return fmt.Errorf("solc --version: %v", err)
}
if !bytes.Contains(buf, []byte(cfg.solc.version)) {
fmt.Fprintf(stderr, "solc --version:\n%s", buf)
return fmt.Errorf("solc version mismatch; not %q", cfg.solc.version)
}

args := append(nonEmptyArgs(map[string]string{
"--evm-version": cfg.solc.evmVersion,
"--base-path": cfg.solc.basePath,
"--include-path": cfg.solc.includePath,
"--combined-json": cfg.solc.output,
}), flag.Args()...)

solc := exec.CommandContext(ctx, "solc", args...)
// Although we could use io.Pipe(), it's much easier to reason about
// non-concurrent processes, and solc doesn't create huge outputs.
var solcOut bytes.Buffer
solc.Stdout = &solcOut
solc.Stderr = stderr
if err := solc.Run(); err != nil {
return fmt.Errorf("solc: %w", err)
}

abigen := exec.CommandContext(ctx, "abigen", nonEmptyArgs(map[string]string{
"--combined-json": "-", // stdin
"--pkg": cfg.abigen.pkg,
})...)
abigen.Stdin = &solcOut
var abigenOut bytes.Buffer
abigen.Stdout = &abigenOut
abigen.Stderr = stderr
if err := abigen.Run(); err != nil {
return fmt.Errorf("abigen: %w", err)
}

re := regexp.MustCompile(`github.com/ethereum/go-ethereum/(accounts|core)/`)

Check failure

Code scanning / CodeQL

Missing regular expression anchor High

When this is used as a regular expression on a URL, it may match anywhere, and arbitrary hosts may come before or after it.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

Check failure

Code scanning / CodeQL

Incomplete regular expression for hostnames High

This regular expression has an unescaped dot before 'com', so it might match more hosts than expected when the regular expression is used.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

_, err = stdout.Write(re.ReplaceAll(
abigenOut.Bytes(),
[]byte(`github.com/ava-labs/subnet-evm/${1}/`)),
)
return err
}

// nonEmptyArgs returns a slice of arguments suitable for use with exec.Command.
// Any empty values are skipped.
func nonEmptyArgs(from map[string]string) []string {
var args []string
for k, v := range from {
if v != "" {
args = append(args, k, v)
}
}
return args
}
20 changes: 20 additions & 0 deletions testing/dstest/FakeTest.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

import {DSTest} from "ds-test/src/test.sol";

contract FakeTest is DSTest {
event NotFromDSTest();

function logNonDSTest() external {
emit NotFromDSTest();
}

function logString(string memory s) external {
emit log(s);
}

function logNamedAddress(string memory name, address addr) external {
emit log_named_address(name, addr);
}
}
129 changes: 129 additions & 0 deletions testing/dstest/dstest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Package dstest implements parsing of [DSTest] Solidity-testing errors.
//
// [DSTest]: https://github.com/dapphub/ds-test
package dstest

import (
"context"
"fmt"
"strings"
"testing"

"github.com/ava-labs/subnet-evm/accounts/abi"
"github.com/ava-labs/subnet-evm/accounts/abi/bind"
"github.com/ava-labs/subnet-evm/core/types"
"github.com/ava-labs/subnet-evm/testing/dstest/internal/dstestbindings"
"github.com/ethereum/go-ethereum/common"
)

// New returns a new `Parser` with the provided addresses already
// `Register()`ed.
func New(tests ...common.Address) *Parser {
p := &Parser{
tests: make(map[common.Address]bool),
}
for _, tt := range tests {
p.Register(tt)
}
return p
}

// A Parser inspects transaction logs of `Register()`ed test addresses, parsing
// those that correspond to [DSTest] error logs.
//
// [DSTest]: https://github.com/dapphub/ds-test
type Parser struct {
tests map[common.Address]bool
}

// Register marks the provided `Address` as being a test that inherits from the
// [DSTest contract].
//
// [DSTest contract]: https://github.com/dapphub/ds-test/blob/master/src/test.sol
func (p *Parser) Register(test common.Address) {
p.tests[test] = true
}

// A Log represents a Solidity event emitted by the `DSTest` contract. Although
// all assertion failures result in an event, not all logged events correspond
// to failures.
type Log struct {
unpacked map[string]any
}

// String returns `l` as a human-readable string.
//
// The format is not guaranteed to be stable and the returned value SHOULD NOT
// be parsed.
func (l Log) String() string {
switch u := l.unpacked; len(u) {
case 1:
return fmt.Sprintf("%v", u["arg0"])
case 2:
return fmt.Sprintf("%s = %v", u["key"], u["val"])
case 3:
return fmt.Sprintf("%s = %v (%v decimals)", u["key"], u["val"], u["decimals"])
default:
// The above cases are exhaustive at the time of writing; if the default
// is reached then they need to be updated.
return fmt.Sprintf("%+v", u)
}
}

type Logs []*Log

// String() returns `ls` as a human-readable string.
func (ls Logs) String() string {
s := make([]string, len(ls))
for i, l := range ls {
s[i] = l.String()
}
return strings.Join(s, "\n")
}

// Parse finds all [types.Log]s emitted by test contracts in the provided
// `Transaction`, filters them to keep only those corresponding to `DSTest`
// events, and returns the unpacked data.
func (p *Parser) Parse(ctx context.Context, tx *types.Transaction, b bind.DeployBackend) (Logs, error) {
r, err := b.TransactionReceipt(ctx, tx.Hash())
if err != nil {
return nil, err
}

var logs []*Log
for _, l := range r.Logs {
if !p.tests[l.Address] {
continue
}
ev, err := dstestbindings.EventByID(l.Topics[0])
if err != nil /* not found */ {
continue
}

l, err := unpack(ev, l)
if err != nil {
return nil, err
}
logs = append(logs, l)
}
return logs, nil
}

func unpack(ev *abi.Event, l *types.Log) (*Log, error) {
unpacked := make(map[string]any)
if err := dstestbindings.UnpackLogIntoMap(unpacked, ev.Name, *l); err != nil {
return nil, err
}
return &Log{unpacked}, nil
}

// ParseTB is identical to [Parse] except that it reports all errors on
// [testing.TB.Fatal].
func (p *Parser) ParseTB(ctx context.Context, tb testing.TB, tx *types.Transaction, b bind.DeployBackend) Logs {
tb.Helper()
l, err := p.Parse(ctx, tx, b)
if err != nil {
tb.Fatalf("%T.Parse(): %v", p, err)
}
return l
}
Loading
Loading