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

test: Add auto-generating basic abi tests for solidity interfaces #2018

Open
wants to merge 18 commits into
base: master
Choose a base branch
from

Conversation

nddeluca
Copy link
Member

@nddeluca nddeluca commented Sep 6, 2024

This adds solidity source files and tests that document and assert EVM behavior for solidity function ABI's.

Contracts are added in ABI_BasicTests.sol that define the different function modifiers, special functions cases, etc and the abi_basic.test.ts file asserts all expected behavior dynamically by reading the contract ABI's.

This is currently only testing existing behavior and uses an interface approach to separate the defined ABI from the concrete implementation on chain since these tests will be expanded to use the same interfaces against precompile addresses when run against the kvtool network.

The mocks will be kept as each stateful precompile we add will have an associated mock with the same external behavior (basic input validation, events, etc) so integrating protocols can use them in their test suites without having to always use the kava binary, which is much slower.

Mocks may be extended in the future to support more complex testing or assertions, but for now they are basic and will first be used to verify the behavior of stateful precompiles implementing solidity interfaces matches the expected behavior of the EVM & solidity compiled bytecode.

The empty account and EOA tests are added to increase our e2e testing of EVM behavior through kava & etheremint (hardhat helps us verify we conform to ethereum standards) since these code paths are being modified to support stateful precompiles. They also serve as documentation for reference or learning.

In addition, PR feedback from @boodyvo, @pirtleshell, @downing034 in #2016 is included here.

The equal matcher is a bit clearer and more consistent.
This moves the viem extension to a more clear place and adds viem
support for deploy contract on kvtool.
This was changed in a previous commit, but forgot to add to the hardhat
config.
This is used by viem and required by the added test helpers as well as
typing for using ABI artifacts.
These tests assert and document the behavior of empty and eoa accounts
with value when called by external transactions with or without data.

In addition, they assert the gas usage is as expected and only depedent
on the base transaction cost plus calldata cost since no code is
executed.
These tests are added to exercise solidity function ABI's for function
and special function calling.

This is in preperation for expanding these tests to work against
stateful precompiles, ensuring that precompile contracts respect ABI
function and special function calling conventions.

These tests also serve to increase the test coverage of the Kava EVM
and document behavior for future code readers.
These were copied from the empty account tests but usage was removed.
Solhint previously failed due to no contracts, so this adds solhint to
CI now that contracts exist.

In addition, now that tyepscript uses abitypes from compiled contracts,
hardhat compile needs to be run before typescript lint.
A common typo on my part that prettier keeps catching.
This adds a comment to explain that this setting is required for
hardhat-viem to not throw an error when sending transactions that will
revert to match kava chain behavior.
This removes a comment reference to the tseslint recommended config that
is not needed as we are staying with stricter settings.
This adds a linter for smart validation of === vs ==.  The linter was
verified to fail the comparison in this commit and then verified it
passes after the change from == to ===.
This adds noFallthroughCasesInSwitch for switch statements and turns on
isolatedModules.
This sets the KAVA_TAG to correctly target the image built locally in CI
when running e2e-evm tests for kvtool testnet up and down.
This documents the commands for using act to run the github CI jobs
related to e2e-evm locally for lint and e2e tests.
I guess double spaces between sentances are not liked by prettier.
There is a race condition between the typechecking and compiler so we
add an explicit compile step here to ensure the race is not hit.
it("can not receive plain transfers", async function () {
const txData = { to: mockAddress, gas: 25000n, value: 1n };

const txHash = await walletClient.sendTransaction(txData);
Copy link
Member Author

Choose a reason for hiding this comment

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

I've been meaning to refactor this duplication out and create much more concise tests but I've deferred for now since I haven't landed on an API I like.

Copy link
Member

Choose a reason for hiding this comment

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

i could see having a helper method with closure over walletClient and publicClient like

async function sendTx(txData) {
  const hash = await walletClient.sendTransaction(txData);
  return publicClient.waitForTransactionReceipt({ hash })
}

but does not seem strictly necessary

});

if (fallbackFunction) {
it(`can ${fallbackFunction.stateMutability === "payable" ? "" : "not "}receive transfer with data or invalid function selector`, async function () {
Copy link
Member Author

Choose a reason for hiding this comment

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

I should create a helper here -- isPayable(fallbackFunction) that can be used with an ABI defined function

const isPayable = funcDesc.stateMutability === "payable";

it("can be called", async function () {
const txData = { to: mockAddress, data: funcSelector, gas: 25000n };
Copy link
Member Author

Choose a reason for hiding this comment

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

Default gas should be a constant

});

for (const funcDesc of abi) {
if (funcDesc.type !== "function") {
Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe deserves a comment?

hre.viem.deployContract = (async <CN extends string>(
contractName: ContractName<CN>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructorArgs: any[] = [],
Copy link
Member Author

Choose a reason for hiding this comment

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

any is used in the upstream type, so linter ignore was added instead of correcting it

@@ -58,6 +60,9 @@ jobs:
- name: Install npm dependencies
run: npm install
working-directory: tests/e2e-evm
- name: Run test suite against hardhat network
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
- name: Run test suite against hardhat network
- name: Compile contracts and create artifcats

Copy link
Member Author

@nddeluca nddeluca Sep 6, 2024

Choose a reason for hiding this comment

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

📠 🍝

@@ -0,0 +1,21 @@
import { Abi, AbiFallback, AbiReceive } from "abitype";

export function getAbiFallbackFunction(abi: Abi): AbiFallback | undefined {
Copy link
Member

Choose a reason for hiding this comment

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

ha such a golang guy rocking these for-loops

would recommend a single helper func like (i've converted to functional, keep your for loops if ya want 😄)

export const getAbiFunctionByType = (abi: Abi, funcType: string) => abi.find(f => f.type === funcName)

if you want to have the same funcs as here you can convert them to use that:

const getAbiFallbackFunction = (abi: Abi) => getAbiFunctionByType(abi, "fallback")
const getAbiReceiveFunction = (abi: Abi) => getAbiFunctionByType(abi, "receive")

Copy link
Member Author

@nddeluca nddeluca Sep 6, 2024

Choose a reason for hiding this comment

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

+1, I did a pretty fast extraction of those helpers and should be leveraging these other approaches. The golang habits are strong

// Test no receive + fallback scenarios
{ interface: "NoopNoReceivePayableFallback", mock: "NoopNoReceivePayableFallbackMock" },
{ interface: "NoopNoReceiveNonpayableFallback", mock: "NoopNoReceiveNonpayableFallbackMock" },
] as ContractTestCase[];
Copy link
Member

Choose a reason for hiding this comment

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

nit: i'd prefer to declare type this like const testCases: ContractTestCase[] = ... rather than cast with as.
it's a subtle difference, but i think of it like (declaring) "hey, compiler, these are supposed to be ContractTestCases, plz help" vs (casting) "hey, compiler, these ARE ContractTestCase"

Copy link
Member Author

Choose a reason for hiding this comment

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

I 100% agree and it was the first thing I tried, but typescript was unable to correctly infer contract name type for the mock property with an as ContractTestCase[], so didn't think it was worth duplicating.

Maybe there is another way to hint at the type? Explicitly typing the strings in mock also worked, but it has to be repeated for every element.

Copy link
Member

Choose a reason for hiding this comment

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

types are weird here yeah since they're generated in the artifacts? I tried some wonky thing like this which works for this section but causes some "Unsafe assignment of an error typed value." issues below when using hre.artifacts.readArtifactSync(tc.interface).abi;

interface ContractTestCase<T extends keyof ArtifactsMap> {
  interface: T;
  mock: ArtifactsMap[T]["contractName"];
}

// ABI_BasicTests assert ethereum + solidity transaction ABI interactions perform as expected.
describe("ABI_BasicTests", function () {
  const testCases: ContractTestCase<keyof ArtifactsMap>[] = [
    // Test function modifiers without receive & fallback
    { interface: "NoopNoReceiveNoFallback", mock: "NoopNoReceiveNoFallbackMock" },

    // Test receive + fallback scenarios
    { interface: "NoopReceiveNoFallback", mock: "NoopReceiveNoFallbackMock" },
    { interface: "NoopReceivePayableFallback", mock: "NoopReceivePayableFallbackMock" },
    { interface: "NoopReceiveNonpayableFallback", mock: "NoopReceiveNonpayableFallbackMock" },

    // Test no receive + fallback scenarios
    { interface: "NoopNoReceivePayableFallback", mock: "NoopNoReceivePayableFallbackMock" },
    { interface: "NoopNoReceiveNonpayableFallback", mock: "NoopNoReceiveNonpayableFallbackMock" },
  ];
...

Copy link
Member

Choose a reason for hiding this comment

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

heh, potentially hacky workaround that skirts the ContractName type troubles:

interface ContractTestCase {
  interface: keyof ArtifactsMap;
  mock: `${keyof ArtifactsMap}Mock`;
}

but not a major concern lol

describe("State", function () {
it("has code set", async function () {
const code = await publicClient.getCode({ address: mockAddress });
expect(code).to.not.equal(0);
Copy link
Member

Choose a reason for hiding this comment

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

can you confirm that an EOA or never-used address has code 0? i am not 💯 familiar with these operations by i just want to confirm getCode isn't returning a string like "0x" (which also is not equal to the number 0)

Copy link
Member Author

@nddeluca nddeluca Sep 6, 2024

Choose a reason for hiding this comment

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

You are probably correct here, I believe this is suppose to be checked against undefined -- Viem converts the "0x" response to undefined, I think I confused this assetion with balance or nonce

Copy link
Member

Choose a reason for hiding this comment

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

Could we compare it with an code match from the deployedBytecode in the abi instead of a non-match?

tests/e2e-evm/test/abi_basic.test.ts Show resolved Hide resolved

const txHash = await walletClient.sendTransaction(txData);
const txReceipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
expect(txReceipt.status).to.equal(isPayable ? "success" : "reverted");
Copy link
Member

Choose a reason for hiding this comment

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

i'd imagine "success" and "reverted" are constants in viem we could use. or create our own. would prevent potential test typos going forward

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea, especially since there is no typecheck on the status. I'll create constants since https://github.com/wevm/viem/blob/main/src/utils/formatters/transactionReceipt.ts#L23 are typed string constants that are not exported individually.

At some point I plan to refactor these things to custom chai matchers that will reduce a lot the boilerplate and make the tests much more readable.

- name: Run solhint
run: npm run solhint
working-directory: tests/e2e-evm
- name: Compile contracts and create artifcats
Copy link
Member

Choose a reason for hiding this comment

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

artif🐱🐱🐱🐱

Suggested change
- name: Compile contracts and create artifcats
- name: Compile contracts and create artifacts

// Test no receive + fallback scenarios
{ interface: "NoopNoReceivePayableFallback", mock: "NoopNoReceivePayableFallbackMock" },
{ interface: "NoopNoReceiveNonpayableFallback", mock: "NoopNoReceiveNonpayableFallbackMock" },
] as ContractTestCase[];
Copy link
Member

Choose a reason for hiding this comment

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

types are weird here yeah since they're generated in the artifacts? I tried some wonky thing like this which works for this section but causes some "Unsafe assignment of an error typed value." issues below when using hre.artifacts.readArtifactSync(tc.interface).abi;

interface ContractTestCase<T extends keyof ArtifactsMap> {
  interface: T;
  mock: ArtifactsMap[T]["contractName"];
}

// ABI_BasicTests assert ethereum + solidity transaction ABI interactions perform as expected.
describe("ABI_BasicTests", function () {
  const testCases: ContractTestCase<keyof ArtifactsMap>[] = [
    // Test function modifiers without receive & fallback
    { interface: "NoopNoReceiveNoFallback", mock: "NoopNoReceiveNoFallbackMock" },

    // Test receive + fallback scenarios
    { interface: "NoopReceiveNoFallback", mock: "NoopReceiveNoFallbackMock" },
    { interface: "NoopReceivePayableFallback", mock: "NoopReceivePayableFallbackMock" },
    { interface: "NoopReceiveNonpayableFallback", mock: "NoopReceiveNonpayableFallbackMock" },

    // Test no receive + fallback scenarios
    { interface: "NoopNoReceivePayableFallback", mock: "NoopNoReceivePayableFallbackMock" },
    { interface: "NoopNoReceiveNonpayableFallback", mock: "NoopNoReceiveNonpayableFallbackMock" },
  ];
...

describe("State", function () {
it("has code set", async function () {
const code = await publicClient.getCode({ address: mockAddress });
expect(code).to.not.equal(0);
Copy link
Member

Choose a reason for hiding this comment

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

Could we compare it with an code match from the deployedBytecode in the abi instead of a non-match?

Copy link
Member

@pirtleshell pirtleshell left a comment

Choose a reason for hiding this comment

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

great test additions to endure we don't regress / alter fundamental evm functionality & assumptions 👍

tests/e2e-evm/test/abi_basic.test.ts Show resolved Hide resolved
it("can not receive plain transfers", async function () {
const txData = { to: mockAddress, gas: 25000n, value: 1n };

const txHash = await walletClient.sendTransaction(txData);
Copy link
Member

Choose a reason for hiding this comment

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

i could see having a helper method with closure over walletClient and publicClient like

async function sendTx(txData) {
  const hash = await walletClient.sendTransaction(txData);
  return publicClient.waitForTransactionReceipt({ hash })
}

but does not seem strictly necessary

// Test no receive + fallback scenarios
{ interface: "NoopNoReceivePayableFallback", mock: "NoopNoReceivePayableFallbackMock" },
{ interface: "NoopNoReceiveNonpayableFallback", mock: "NoopNoReceiveNonpayableFallbackMock" },
] as ContractTestCase[];
Copy link
Member

Choose a reason for hiding this comment

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

heh, potentially hacky workaround that skirts the ContractName type troubles:

interface ContractTestCase {
  interface: keyof ArtifactsMap;
  mock: `${keyof ArtifactsMap}Mock`;
}

but not a major concern lol

This adds a test that creates an account via a transaction with non-zero
value.  We test that the account has a balance (is created) and that the
gas charged is the 21000 instrinsic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants