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 symbolic tests for IdRegistry and KeyRegistry invariants #275

Closed
wants to merge 17 commits into from

Conversation

daejunpark
Copy link
Contributor

@daejunpark daejunpark commented Jul 18, 2023

Motivation

The purpose of this PR is to illustrate and discuss the use of symbolic tests for verifying contract invariants. Symbolic tests offer several advantages over random testing (such as foundry fuzzing and invariant testing), and can be highly effective when used properly. The tests provided in this (draft) PR is not exhaustive, but should serve as a pointer to provide the idea of symbolic tests and help further discussion.

Change Summary

  • Install the halmos-cheatcode library. (This will be re-installed as submodule after the library is released next week.)
  • Add symbolic tests for IdRegistry and KeyRegistry.
  • Update CI to run the symbolic tests with Halmos.

Merge Checklist

Choose all relevant options below by adding an x now or at any time before submitting for review


PR-Codex overview

Focus of this PR:

This PR focuses on adding symbolic tests for the IdRegistry and KeyRegistry contracts.

Detailed summary:

  • Added symbolic tests for the IdRegistry contract.
  • Added symbolic tests for the KeyRegistry contract.
  • Added imports for necessary contracts and interfaces.
  • Added setup functions to initialize contract states.
  • Added helper functions for generating valid calldata.
  • Added assertions to verify invariants and state transitions.
  • Added SPDX license identifiers to the test files.

The following files were skipped due to too many changes: test/KeyRegistry/KeyRegistry.symbolic.t.sol

✨ Ask PR-Codex anything about this PR by commenting with /codex {your question}

@daejunpark daejunpark added the test Problems with the test suite label Jul 18, 2023
@horsefacts
Copy link
Collaborator

Thanks for all the setup here, @daejunpark! Halmos has come a long way and this is a very useful example.

@@ -41,6 +41,26 @@ jobs:
- name: Check forge snapshots
run: forge snapshot --check --match-contract Gas

symtest:
Copy link
Member

Choose a reason for hiding this comment

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

self: move to run only when merged to main

test/IdRegistry/IdRegistry.st.sol Outdated Show resolved Hide resolved

import {IdRegistryHarness} from "../Utils.sol";

contract IdRegistrySymTest is SymTest, Test {
Copy link
Member

Choose a reason for hiding this comment

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

self: can we use TestSuiteSetup here?

// Setup IdRegistry
idRegistry = new IdRegistryHarness(address(0));

trustedCaller = address(0x1000);
Copy link
Member

Choose a reason for hiding this comment

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

self: use the owner pattern that we use elsewhere, should get this for free if we can use TestSuiteSetup above

}
}

// Verify the IdRegistry invariants
Copy link
Member

Choose a reason for hiding this comment

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

self: clarify what invariants we are asserting here

// Verify invariant properties

// Ensure that there is no recovery address associated with fid 0.
assert(idRegistry.getRecoveryOf(0) == address(0));
Copy link
Member

Choose a reason for hiding this comment

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

self: do we actually care about this invariant? whats the worst that happens if this occurs?

test/IdRegistry/IdRegistry.st.sol Outdated Show resolved Hide resolved
.github/workflows/ci.yml Outdated Show resolved Hide resolved
assert(oldPaused == false);
assert(newIdCounter - oldIdCounter == 1);
assert(newIdX == oldIdX || oldIdX == 0);
assert(newIdX == oldIdX || newIdY == oldIdY);
Copy link
Member

@varunsrin varunsrin Jul 25, 2023

Choose a reason for hiding this comment

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

@daejunpark I found these last two lines hard to reason about, and rewrote them as follows. From the POV of symbolic verification, is there any difference between what we are asserting here?

            if (newIdX != oldIdX) {
                assert(oldIdX == 0 && newIdX == newIdCounter);
                assert(oldIdY == newIdY);
            } else if (newIdY != oldIdY) {
                assert(oldIdY == 0 && newIdY == newIdCounter);
                assert(oldIdX == newIdX);
            } else {
                assert(oldIdX == newIdX && oldIdY == newIdY);
            }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

they are implications. since solidity doesn't offer a logical implication operator, we use not A or B to specify A implies B.

so they mean:

  • if newIdX != oldIdX, then oldIdX == 0 should hold.
  • if newIdX != oldIdX, then newIdY == oldIdY should hold.

which can be also written as follows (with no difference from the logical reasoning perspective):

if (newIdX != oldIdX) {
    assert(oldIdX == 0);
    assert(newIdY == oldIdY);
}

your rewritten version should also works, but note that:

  • the assertion in the last else branch cannot be violated, because of the if-then-else semantics.
  • the second if branch is logically dual with the first if branch, as x and y are symmetric.

Copy link
Member

Choose a reason for hiding this comment

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

very helpful thanks.

  1. Agree that the last branch is unnecessary, and can be removed.
  2. What are the implications of logical duality here? Does it imply that this code is testing different things than the original logic you specified?

return abi.encodePacked(selector, args);
}

function check_transfer(address caller, address to, address other) public {
Copy link
Member

@varunsrin varunsrin Jul 25, 2023

Choose a reason for hiding this comment

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

@daejunpark we test transfer in two ways - via this specific test for transfer, and more generically in check_invariant, though we do not assert much there.

we test register only in one way - in the general check invariant, where we assert a lot of specific stuff.

is there a rationale for this design? or is this something that could be standardizes where we have a general invariant assertion test and specific ones for each function including register?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

initially, i had put everything in a single check_invariant test, but it seemed too complicated to read, so i split it into two tests for readability.

for readability reasons, yes, i usually separate invariant tests (where you call an arbitrary function), from tests for individual functions.

y = svm.createAddress("y");
}

function verifyInvariants(bytes4 selector, address caller) public {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

note that the default prefix for halmos test functions is check_. if you want to use a different prefix, then you need to provide an additional option --function <prefix> to halmos, which is --function verify in this case. otherwise these functions will be omitted from testing, as you can see in the last ci run:
https://github.com/farcasterxyz/contracts/actions/runs/5671611054/job/15369109292

@horsefacts horsefacts force-pushed the test/halmos branch 2 times, most recently from 4977686 to ae04294 Compare August 10, 2023 19:27
@horsefacts
Copy link
Collaborator

horsefacts commented Aug 10, 2023

Thanks for all your help here and on tg, @daejunpark and @karmacoma-eth! I'm working on updating the tests here to account for some contract changes and have bumped into a counterexample that I don't understand.

I've updated the tests to account for a few changes to the key registry:

  • Add/remove no longer take a leading fid argument.
  • New addFor, trustedAdd, and removeFor functions.
  • Renamed pauseRegistration to pause.

Since addFor, trustedAdd, and removeFor all take at least one bytes argument, I added them to mk_calldata following the existing example. I also introduced new symbolic values for their arguments: address addr, uint256 deadline, and bytes sig.

I updated the invariants to account for these new functions. Since there are now multiple add/remove functions, there's now an extra check inside each post-state to detect which function was called and make any function-specific assertions. For example, before:

            if (newStateX == KeyRegistry.KeyState.REMOVED) {
                // For a transition to REMOVED, ensure that:
                // - The previous state must be ADD.
                // - The transition can only be made by remove(), where:
                //   - It must be called by the owner of fid x.
                assert(oldStateX == KeyRegistry.KeyState.ADDED);
                assert(selector == keyRegistry.remove.selector);
                assert(oldCallerId == x);
            } 

And after:

            if (newStateX == KeyRegistry.KeyState.REMOVED) {
                // For a transition to REMOVED, ensure that:
                // - The previous state must be ADD.
                assert(oldStateX == KeyRegistry.KeyState.ADDED);
                // - The transition can only be made by remove() or removeFor()
                if (selector == keyRegistry.remove.selector) {
                    //   - remove() must be called by the owner of fid x.
                    assert(oldCallerId == x);
                } else if (selector == keyRegistry.removeFor.selector) {
                  // No assertion here yet
                } else {
                    assert(false);
                }
            }

However, introducing the assert(oldCallerId == x) assertion here fails and produces a counterexample:

Counterexample:
    halmos_disableTrustedOnly?_bool_07 = 0x0
    halmos_gracePeriod_uint24_01 = 0x640001
    halmos_key1_bytes_02 = 0x0
    halmos_key2_bytes_03 = 0x1a
    halmos_key_bytes_13 = 0x1a
    halmos_migrateKeys?_bool_06 = 0x1
    halmos_pause?_bool_08 = 0x0
    halmos_timestamp2_uint64_09 = 0x0
    halmos_x_uint256_04 = 0x2
    halmos_xkey_bytes_05 = 0x1a
    p_caller_address = 0x8000000000000000000000000000000000000000
    p_selector_bytes4 = 0x58edef4c00000000000000000000000000000000000000000000000000000000

The function selector here is remove(bytes), and the FID removed is 2, which we assign to address(0x1002) in setup and use to register a key here.

If I'm reading the failure right, it indicates that address(0x8000000000000000000000000000000000000000) was able to call remove and transition FID 2 to REMOVED. But when I run this as a concrete test with the values from the counterexample, it reverts, since the caller is not the FID owner. This is what I'd expect intuitively, and what actually happens when I concretize this example.

Am I interpreting the counterexample correctly? Anything else look suspicious here?

You can see a few more counterexamples in the CI output here.

@karmacoma-eth
Copy link

ngl this is a chonky test, this is why we need a16z/halmos#130

@daejunpark
Copy link
Contributor Author

thanks for updating the tests! let me take a look at the failure.

@horsefacts
Copy link
Collaborator

ngl this is a chonky test

Ha, yes, and I'm afraid I made it chonkier.

Not sure either of these are relevant, but I noticed two other things here:

  • We create two key2 symbolic variables, one on L65 and one inside mk_calldata on L180.
  • In all the counterexamples, key2, key and xkey look like they are equal.

@daejunpark
Copy link
Contributor Author

it turned out that a known halmos bug was triggered by this test. i've temporarily adjusted the test to avoid the bug and will fix it quickly. will get back to this soon.

@daejunpark
Copy link
Contributor Author

daejunpark commented Aug 11, 2023

Ha, yes, and I'm afraid I made it chonkier.

No, this test is one of the good uses of halmos. What is now missing from halmos is the debugging info, such as the trace info mentioned by Karma. We will add it soon.

@daejunpark
Copy link
Contributor Author

it turned out that a known halmos bug was triggered by this test. i've temporarily adjusted the test to avoid the bug and will fix it quickly. will get back to this soon.

i fixed the halmos issue, and reverted the temporary adjustment to the test. i also rebased the pr on the main branch, and updated the test for the new interface. it should work again now. please let me know if you have any further questions.

@daejunpark
Copy link
Contributor Author

the tests passed locally, but seems too heavy for the github ci. let me try optimizing them.

@github-actions
Copy link

github-actions bot commented Sep 6, 2023

Coverage after merging test/halmos into main will be

98.66%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   Bundler.sol100%100%100%100%
   FnameResolver.sol100%100%100%100%
   IdRegistry.sol100%100%100%100%
   KeyRegistry.sol100%100%100%100%
   RecoveryProxy.sol100%100%100%100%
   StorageRegistry.sol100%100%100%100%
src/lib
   EIP712.sol50%100%50%50%19
   Signatures.sol100%100%100%100%
   TransferHelper.sol0%0%0%0%12, 17, 20, 20, 20
   TrustedCaller.sol100%100%100%100%
src/validators
   SignedKeyRequestValidator.sol100%100%100%100%

@horsefacts
Copy link
Collaborator

Splitting this into #397 and #398 so we can merge the ID registry tests separately. Will close this and move conversation to #398.

@horsefacts horsefacts closed this Sep 6, 2023
@daejunpark daejunpark deleted the test/halmos branch September 12, 2023 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
test Problems with the test suite
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants