-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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): add cheatcodes vm.recordLogs() and vm.getRecordedLogs() #2161
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small comments, thanks for taking this on 😄
testdata/cheats/Cheats.sol
Outdated
// Captures all the transaction logs | ||
function captureLogs() external; | ||
// Gets (and consumes) the next captured transaction log | ||
function nextCapturedLog() external returns (bytes32[] memory topics,bytes memory data); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the primary reasoning for this cheatcode vs. something like vm.getLogs()
and popping/indexing off of the returned array?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Hey @onbjerg I really appreciate your early review of this draft!
- I'll do my best to be succint.
- Disclaimer I'll do once: I have less than a week of experience in the marriage of Rust+Solidity 😅 !!! There are many features or patterns I ignore, and I'm most likely to propose stuff you already discussed and discarded. But well, there's only one way to start any jouney.
Background
- Initially I wanted something very simple™ and give the tester all the parsing work. After all, what I wanted as a feature is the ability to capture logs, leaving the UX problem as a future issue.
Analysis
- What do we give to the tester if we invoke
vm.getLogs
?- Should it be a
bytes32[][]
. Maybe... But wait!data
is not necessarily constained to 32 bytes! - So you have somehow an array of
(bytes32[], bytes)
. Can we do that? 🤔 - If we can't do above, can we just take the content of a
RawLog
, do a(bytes32[], bytes)
tuple,abi.encode
it into abytes
field and then return abytes[]
? Seems cumbersome... Why don't we just go low-level and get the first log of the list in the simplest format we can afford?
- Should it be a
- So in the end, I ended up thinking in UX!
Proposal
- Having as cheatcodes
vm.captureLogs()
andvm.nextCapturedLog()
withinforge
.- Then you just, in a FIFO fashion, take the first log from the list, leaving the next one in queue.
vm.nextCapturedLog()
gives you back a(bytes32[], bytes)
. The tester needs to do some quick decoding such asabi.decode(data, (string))
.- Still working on how to work the Topic 0. For now its processing is to be at the tester discretion.
- Create later some wrapper at
forge-std
togetLogs()
. This or these function(s) would be iterating overnextCapturedLog
, taking care of indexing and decoding aspects.
Wrapping up
My final goal is to be able to port the Ethernaut
challenge into forge
. I need the feature of capturing (and then reading those captured) logs. Let's take advantage we currently have some capacity on implementing this, and let's look towards the best solution 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for this info! I had the same thought as @onbjerg when looking at this. IMO I think the best UX would be something like this:
interface Vm {
// Records all transaction logs so they can be retrieved later.
function recordLogs() external;
// Returns an array of the recorded logs. `accessLog` is consistent with `accesses`, even
// though semantics are a bit different. In general I don't feel strongly about all
// the cheatcode names
function accessLogs() external returns ((bytes32[] memory topics, bytes memory data)[]);
// Similar alternative.
struct Log {
bytes32[] topics;
bytes data;
}
function logs() external returns (Log[] memory);
}
I don't personally see a big need for a cheatcode to access the next log, as opposed to just returning the full array and having the user loop through it or access a particular index. Providing the whole array instead is also nice because you if you know the index of an expected event it's cleaner to access it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the write-up @bentobox19! Don't worry about being new to Rust + Solidity, we'll help you if you need 😄 Looks good so far.
I agree with @mds1's 2nd proposal here: it would be entirely feasible (and imo preferable) to return Log
structs. The nice thing about having it return all logs it captured between recordLogs
and logs
(or accessLogs
, not sure what the best name is) is that helper "cheatcodes" like nextLog
can be built on top in Forge Std or other libraries 😄
So I would definitely propose that you:
- Remove the
nextCapturedLog
cheatcode - Replace it with a
logs
oraccessLogs
(still not sure about naming) that returnsLog[]
A note on behavior of the logs
cheatcode: I think it should return all logs between the call to recordLogs
and the call to logs
. After this the internal buffer should be cleared - in other words, the combination of these two cheatcodes define the range in which the logs were emitted, if that makes sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @mds1 and @onbjerg onbjerg for your prompt feedback: I am renaming to
// Record all the transaction logs
function recordLogs() external;
// Gets all the recorded logs
function getRecordedLogs() external returns ((bytes32[] memory topics, bytes memory data)[]);
Let's pass on getLogs()
, to avoid any potential confusion of the testers with the RPC method eth_getLogs 🤷♂️
Hey @onbjerg I went a bit verbose here: #2161 (comment) Really open to discuss it until we converge into a solution. So far, it's been a very instructive experience where you have to reason in Rust, and low level Solidity all in a single package. |
In the end I went with struct Log {bytes32[] topics; bytes data;}
function recordLogs() external;
function getRecordedLogs() external returns (Log[] memory); The compilers weren't very friendly with function getRecordedLogs() external returns ((bytes32[] memory topics, bytes memory data)[]); So, the common flow is, for example cheats.recordLogs();
emitter.emitEvent(1, 2, 3, "some data");
Cheats.Log[] memory entries = cheats.getRecordedLogs();
assertEq(entries.length, 1);
assertEq(entries[0].topics.length, 4);
assertEq(entries[0].topics[0], keccak256("LogTopic123(uint256,uint256,uint256,bytes)"));
assertEq(entries[0].topics[1], bytes32(uint256(1)));
assertEq(entries[0].topics[2], bytes32(uint256(2)));
assertEq(entries[0].topics[3], bytes32(uint256(3)));
assertEq(abi.decode(entries[0].data, (string)), string("some data")); On the last assertion, even if the data is not a The PR!Alright. I'm setting this PR on:
Thank you. |
@bentobox19 this looks good - can you explain why we panic on anonymous events? Defer to @mds1 on the interface being exposed, I like it |
Hey @gakonst
This is my output when runing the test [ bentobox: testdata ]$ ../target/debug/forge test -c cheats/RecordLogs.t.sol --match-test testRecordOnAnonymousEvent -vvvv
[⠑] Compiling...
No files changed, compilation skipped
Running 1 test for cheats/RecordLogs.t.sol:RecordLogsTest
[PASS] testRecordOnAnonymousEvent() (gas: 46536)
The application panicked (crashed).
Message: index out of bounds: the len is 0 but the index is 0
Location: /Users/bentobox/foundry/evm/src/trace/decoder.rs:299
This is a bug. Consider reporting it at https://github.com/foundry-rs/foundry
Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small nits. Regarding my comments on the API/behavior, I would give @mds1 final word on it since he is more in touch with actually using it (I don't write much Solidity these days)
@@ -74,6 +74,9 @@ pub struct Cheatcodes { | |||
/// Recorded storage reads and writes | |||
pub accesses: Option<RecordAccess>, | |||
|
|||
/// Recorded logs | |||
pub recorded_logs: Option<RecordedLogs>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be better to have a boolean indicating whether we are recording or not so you can do something like:
vm.recordLogs();
// some calls (lets say call a, b and c)
vm.getRecordedLogs(); // gets logs for calls a, b and c
// perform call d and call e
vm.getRecordedLogs(); // gets logs for calls d and e
Additionally I think a call to vm.getRecordedLogs
should only return logs between the call to getRecordedLogs
and the previous call to recordLogs
or getRecordedLogs
to allow for the pattern above. Currently this is the behavior:
vm.recordLogs();
// some calls (lets say call a, b and c)
vm.getRecordedLogs(); // gets logs for calls a, b and c
// perform call d and call e
vm.getRecordedLogs(); // gets logs for calls a, b, c, d and e
To discard logs for the first 3 calls you would have to call recordLogs
again to reset the buffer
state.recorded_logs = Some(Default::default()); | ||
} | ||
|
||
fn get_recorded_logs(state: &mut Cheatcodes) -> Bytes { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Imo this should clear the state.recorded_logs
vec (see reasoning below)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will clone state.recorded_logs
and then call start_record_logs()
to reset them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can also use std::mem::take
instead
.map(|entry| { | ||
Token::Tuple( | ||
[ | ||
Token::Array( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Vec<H256>
and Vec<u8>
implement the Tokenizable
trait, which also means it implements the Tokenize
trait, so I think it should be possible to just call entry.topics.into_tokens()
and entry.data.into_tokens()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some hair pulling here, but managed to refashion this part of the code
ethers::abi::encode(
&recorded_logs
.entries
.iter()
.map(|entry| {
Token::Tuple(vec![
entry.topics.clone().into_token(),
Token::Bytes(entry.data.clone()),
])
})
.collect::<Vec<Token>>()
.into_tokens(),
)
.into()
Sample output object:
This maps to
(bytes32[], bytes)[]
orLog[]
withstruct Log {bytes32[] topics; bytes data;}
[ Tuple([
Array([
FixedBytes([97, 251, 125, ...]),
FixedBytes([... 0, 1])]),
Bytes([... 128, 64, 178, 137, 171, 233, 51, 52, 165, 227, ...])]),
Tuple([
Array([
FixedBytes([122, 249, 45, ...]),
FixedBytes([... 0, 2]),
FixedBytes([... 0, 3])]),
Bytes([... 171, 233, 51, 52, 165, 227, 102, 39, 81, 170, ...])]),
...
- Using both
.into_token()
andinto_tokens()
. into_tokens()
gives you as output a[Array[ ....
entry.data.clone().into_token()
will give youArray([Uint(0), Uint(0),
instead of the desiredBytes([0, 0, 0, 0, 0,
@bentobox19 The proposed API looks great to me, thank you! 👌 |
Yea to clarify I think the below VM interfaces look good: struct Log {bytes32[] topics; bytes data;}
function recordLogs() external;
function getRecordedLogs() external returns (Log[] memory); And I think the above suggestion of behavior from @onbjerg is good too: vm.recordLogs();
// some calls (lets say call a, b and c)
vm.getRecordedLogs(); // gets logs for calls a, b and c
// perform call d and call e
vm.getRecordedLogs(); // gets logs for calls d and e |
Great - let's address the @onbjerg code review comments, and given we like the API we can get it over the line! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
smallest nit (see https://doc.rust-lang.org/std/option/enum.Option.html#method.replace)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rebased, looks good to merge when CI passes 😄 Great job!
Nope, this LGTM! Thank you @bentobox19! 🎉 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome. Fantastic. Let's document it in the book?
@gakonst I can send a PR to the book repository today |
…oundry-rs#2161) * feat(forge): add cheatcodes vm.recordLogs() and vm.getRecordedLogs() * refashion response of vm.getRecordedLogs() * recorded logs get consumed after being read * chore: nits * chore: fmt Co-authored-by: Bjerg <onbjerg@users.noreply.github.com> Co-authored-by: Oliver Nordbjerg <hi@notbjerg.me>
Overview
The methods
vm.recordLogs()
andvm.getRecordedLogs()
provide the tester the ability to leverage information from the events emitted during the execution of a transaction.Ref: #543
Motivation
While porting solutions from the CTF Ethernaut to Foundry, got bumped by the fact that when you create a challenge, you are calling the
Ethernaut.sol
contract with the address of the factory of the challenge you want to solve.Ethernaut.sol
will invoke the factory, deploy a new challenge for you, and return the address of this new contract within an eventLevelInstanceCreatedLog(address indexed player, address instance)
.In practical terms, one can just modify the Ethernaut contract to return you the address instead of emitting such event, but, this is a motivation to just build the feature into Foundry!
Solution
vm.captureLogs()
: Function mirroringvm.record()
.vm.getRecordedLogs()
: Function mirroringvm.accesses()
.