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 6 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
59 changes: 59 additions & 0 deletions contracts/contracts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package contracts

import (
"testing"

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

//go:generate sh -c "solc --evm-version=paris --base-path=./ --include-path=./node_modules --combined-json=abi,bin contracts/**/*.sol | abigen --combined-json=- --pkg contracts | sed -E 's,github.com/ethereum/go-ethereum/(accounts|core)/,github.com/ava-labs/subnet-evm/\\1/,' > generated_test.go"

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

// The geth SimulatedBackend constructor doesn't allow for injection of
// the ChainConfig, instead using a global. They have recently overhauled
// the implementation so there's no point in sending a PR to allow for
// injection.
// TODO(arr4n): once we have upgraded to a geth version with the new
// simulated.Backend, change how we inject the precompiles.
copy := *params.TestChainConfig
defer func() {
params.TestChainConfig = &copy
}()
params.TestChainConfig.GenesisPrecompiles = genesis

return evmsim.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)
}
})
}
}
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
}
90 changes: 90 additions & 0 deletions testing/dstest/dstest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package dstest

import (
"context"
"fmt"
"testing"

"github.com/ava-labs/subnet-evm/core/types"
"github.com/ava-labs/subnet-evm/testing/evmsim"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

//go:generate sh -c "solc --evm-version=paris --base-path=./ --include-path=./internal --combined-json=abi,bin FakeTest.t.sol | abigen --pkg dstest --combined-json=- | sed -E 's,github.com/ethereum/go-ethereum/(accounts|core)/,github.com/ava-labs/subnet-evm/\\1/,' > generated_test.go"

func TestParseLogs(t *testing.T) {
ctx := context.Background()
sim := evmsim.NewWithNumKeys(t, 2)

addr, fake := evmsim.Deploy(t, sim, 0, DeployFakeTest)
sut := New(addr)
session := &FakeTestSession{
Contract: fake,
TransactOpts: *sim.From(0),
}

t.Run("inherit DSTest IS_TEST constant", func(t *testing.T) {
got := evmsim.Call(t, fake.ISTEST, nil)
if !got {
t.Errorf("%T.ISTEST() = false; want true", fake)
}
})

tests := []struct {
name string
tx func() (*types.Transaction, error)
want Logs
wantAsStr string
}{
{
name: "string (foo)",
tx: func() (*types.Transaction, error) {
return session.LogString("foo")
},
want: Logs{
{map[string]any{"arg0": "foo"}},
},
wantAsStr: "foo",
},
{
name: "string (bar)",
tx: func() (*types.Transaction, error) {
return session.LogString("bar")
},
want: Logs{
{map[string]any{"arg0": "bar"}},
},
wantAsStr: "bar",
},
{
name: "named address",
tx: func() (*types.Transaction, error) {
return session.LogNamedAddress("Gary", sim.Addr(1))
},
want: Logs{
{map[string]any{
"key": "Gary",
"val": sim.Addr(1),
}},
},
wantAsStr: fmt.Sprintf("Gary = %v", sim.Addr(1)),
},
{
name: "non-DSTest log",
tx: session.LogNonDSTest,
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tx, err := tt.tx()
require.NoErrorf(t, err, "bad test setup; sending %T", tx)

got := sut.ParseTB(ctx, t, tx, sim)
assert.Equalf(t, tt.want, got, "%T.ParseLogs() raw values", sut)
assert.Equalf(t, tt.wantAsStr, got.String(), "%T.ParseLogs() as string", sut)
})
}
}
Loading
Loading