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(forge): support multiple forks #1715

Merged
merged 148 commits into from
Jul 12, 2022
Merged

Conversation

mattsse
Copy link
Member

@mattsse mattsse commented May 24, 2022

Motivation

support multiple forks during tests

  • hotswap state via cheatcode
  • snapshot + revert
  • change execution context between forks

Ref #1649 #834 #1162 #748 #939

Closes #834
Closes #1162

Solution

This introduces new cheatcodes:

// Snapshot the current state of the evm.
// Returns the id of the snapshot that was created.
// To revert a snapshot use `evmRevert`
function snapshot() external returns(uint256);
// Revert the state of the evm to a previous snapshot
// takes the snapshot id to revert to. This deletes the snapshot and all snapshots taken after the given snapshot id.
function revertTo(uint256) external;
// manually enables forking mode for the current test
function setFork(string,uint256) external;
// manually enables forking mode for the current test with the latest block number
function setFork(string) external;
// forks the `block` variable from the given endpoint
function forkBlockVariable(string, uint256) external;

forkBlockVariable basically addresses this #748

atm it's expected that fork related cheat codes are called in setup and snapshots from within the test.

another idea would be to add support for storing multiple RPC endpoints along with their aliases so that RPC endpoints can be used by an alias. Probably best to add this in the configuration

@mattsse mattsse added T-feature Type: feature C-forge Command: forge A-cheatcodes Area: cheatcodes labels May 24, 2022
@mattsse mattsse mentioned this pull request May 25, 2022
3 tasks
@hexonaut
Copy link
Contributor

hexonaut commented Jun 2, 2022

Copying this over from Discord. Basically local storage, memory and stack should be persistent and external calls use latest state of a particular defined fork.

Here is my intuition for how this would work:

function test_my_xchain_e2e_test() public {
	// Ran forge test without rpc url
	uint256 stackVar = 123;
	MyStruct memory memoryVar = a;
	MyStruct storage storageVar = a;

	address contract1 = address(new MyContract());

	uint256 myLocalMainnetForkId = vm.defineFork("MAINNET RPC");
	uint256 myLocalOptimismForkId = vm.defineFork("OPTIMISM RPC");

	// Still on local testing environment
	// stackVar == 123 && memoryVar == a && storageVar == a

	contract1.someFunc();	// Valid

	vm.switchFork(myLocalMainnetForkId);

	// Switched to mainnet latest block - fresh fork
	// stackVar == 123 && memoryVar == a && storageVar == a

	address contract2 = address(new MyContract());

	contract1.someFunc();	// Contract deployed on non-forked env - this will fail - address is still set
	contract2.someFunc();	// Valid

	vm.switchFork(myLocalOptimismForkId);

	address contract3 = address(new MyContract());

	// Run code on optimism latest block - fresh fork
	// stackVar == 123 && memoryVar == a && storageVar == a

	contract1.someFunc();	// Fail
	contract2.someFunc();	// Fail
	contract3.someFunc();	// Valid

	vm.switchFork(myLocalMainnetForkId);

	// Back on mainnet with local changes active
	// stackVar == 123 && memoryVar == a && storageVar == a

	contract1.someFunc();	// Fail
	contract2.someFunc();	// Valid
	contract3.someFunc();	// Fail

	vm.switchFork(myLocalOptimismForkId);

	// Back on optimism with latest local changes active
	// stackVar == 123 && memoryVar == a && storageVar == a
	contract1.someFunc();	// Fail
	contract2.someFunc();	// Fail
	contract3.someFunc();	// Valid
}

For a more concrete implementation we are currently simulating xchain integration tests like this: https://github.com/makerdao/xdomain/blob/ed0435803597ccb4a2328713c0ef8fc0093b2093/packages/dss-bridge/src/tests/Integration.t.sol#L129

mcd is a storage variable referencing currently active MCD on Ethereum. Fork supplied by ETH_RPC_URL
rmcd is a simulated xchain deploy of the remote chain (say on Optimism). It's still all in the same environment as we can't do this yet.

We would like to be able to swap between forks like in the example above and call into mcd or rmcd accordingly.

@gakonst
Copy link
Member

gakonst commented Jul 9, 2022

Per @hexonaut's feedback / user testing I vote we get this merged and iterate on any bugs discovered, wdyt?

function selectFork(uint256) external;
// Updates the currently active fork to given block number
// This is similar to `roll` but for the fork
function rollFork(uint256) external;
Copy link
Member

Choose a reason for hiding this comment

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

Just a smol question, how does this work if you do not use any of the fork cheatcodes, but just use the CLI args? Still as if you had used one of the cheatcodes?

Copy link
Member Author

Choose a reason for hiding this comment

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

exactly, initializing with CLI args will be the same as creating and selecting during setup

// Creates a new fork with the given endpoint and the latest block and returns the identifier of the fork
function createFork(string calldata) external returns(uint256);
// takes a fork identifier created by `createFork` and changes the state
function selectFork(uint256) external;
Copy link
Member

Choose a reason for hiding this comment

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

Another question here actually: if you pass a fork URL using the CLI, then do createFork etc., how would you revert back to the initial fork passed via CLI? Is it possible? If not, should we discourage passing --rpc-url?

Copy link
Member Author

Choose a reason for hiding this comment

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

if we launch in fork mode (via CLI) this fork will always have id 0, I added another cheatcode activeFork()(uint256) that returns the currently active fork, so the id of the CLI fork can be retrieved as well

Copy link
Member

Choose a reason for hiding this comment

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

Great! 😄

Copy link
Member

@onbjerg onbjerg left a comment

Choose a reason for hiding this comment

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

Two small questions but LGTM and I think we should merge and iterate. No need to make any changes to the PR to address the questions I posed, just important to keep in mind re: documentation and so on

@mattsse
Copy link
Member Author

mattsse commented Jul 11, 2022

Per @hexonaut's feedback / user testing I vote we get this merged and iterate on any bugs discovered, wdyt?

+1 on that, writing docs for the book now

Copy link
Member

@gakonst gakonst left a comment

Choose a reason for hiding this comment

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

LGTM - some nits/qs, beyond that feel free to merge whenever.

Comment on lines +89 to +96
// sending and receiving data from the remote client(s)
let _ = std::thread::Builder::new()
.name("multi-fork-backend-thread".to_string())
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create multi-fork-backend-thread tokio runtime");
Copy link
Member

Choose a reason for hiding this comment

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

what's the difference between this and a tokio::spawn?

Copy link
Member Author

Choose a reason for hiding this comment

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

the runs handler in a standalone runtime on a separate thread

Copy link
Member

@gakonst gakonst Jul 11, 2022

Choose a reason for hiding this comment

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

ya i guess what's the benefit? spawning a tokio future in a multithreaded runtime vs spawning a thread and launching a new runtime (is it just better? cuz you launch a new multithreaded runtime in a new thread?)

Copy link
Member Author

Choose a reason for hiding this comment

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

that's a dedicated thread in which the runtime's driving the single future. this rt doesn't spawn any threads but uses the current thread it's in.

tokio::spawn requires an existing runtime and we don't actually have any other async stuff when tests are executed.

Comment on lines +57 to +67
// modify state
bytes32 value = bytes32(uint(1));
// "0x3617319a054d772f909f7c479a2cebe5066e836a939412e32403c99029b92eff" is the slot storing the balance of zero address for the weth contract
// `cast index address uint 0x0000000000000000000000000000000000000000 3`
bytes32 zero_address_balance_slot = 0x3617319a054d772f909f7c479a2cebe5066e836a939412e32403c99029b92eff;
cheats.store(WETH_TOKEN_ADDR, zero_address_balance_slot, value);
assertEq(WETH.balanceOf(0x0000000000000000000000000000000000000000), 1, "Cheatcode did not change value at the storage slot.");

// switch forks and ensure local modified state is persistent
cheats.selectFork(forkB);
assertEq(WETH.balanceOf(0x0000000000000000000000000000000000000000), 1, "Cheatcode did not change value at the storage slot.");
Copy link
Member

Choose a reason for hiding this comment

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

confused a bit here, why is the WETH balance the same in both forks? shouldn't it be 1 in just forkA and still be neq 1 in forkB?

Copy link
Member Author

@mattsse mattsse Jul 11, 2022

Choose a reason for hiding this comment

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

writes are persistent across fork swaps, only the remote half is swapped.

I tried to explain this here https://github.com/foundry-rs/book/pull/442/files#diff-ef0012936dece3a24d02ea8d8e27bf208801f8be2e0689cb74a053c36351c05aR35

Copy link
Member

Choose a reason for hiding this comment

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

Choose a reason for hiding this comment

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

any chance of changing the behaviour here? would be great if it's possible to have separate states for writes as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

looking at this currently, should be possible.

testdata/cheats/RpcUrls.t.sol Outdated Show resolved Hide resolved
forge/src/multi_runner.rs Show resolved Hide resolved
@gakonst
Copy link
Member

gakonst commented Jul 12, 2022

lfg

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-cheatcodes Area: cheatcodes C-forge Command: forge T-feature Type: feature
Projects
None yet
7 participants