-
Notifications
You must be signed in to change notification settings - Fork 18
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
[StorageReading] Invoke external viewer contract via static delegatecall #31
Conversation
…ntext of base contract
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.
Looks very nice :)
Maybe having an example where the modified state is returned would be nice (as this is a cool feature that could be showcased ;) )
contracts/StorageAccessible.sol
Outdated
* @param targetContract Address of the contract containing the code to execute. | ||
* @param calldataPayload Calldata that should be sent to the target contract (encoded method name and arguments). | ||
*/ | ||
function executeStaticDelegatecallInternal( |
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.
I find this name misleading (minimal) :D ... there is no "staticcall" anywhere (I know that it is very well documented in the natspec :D ) ... but not there if there is a better name.
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.
How about simulateDelegatecall
?
calldataPayload | ||
); | ||
assembly { | ||
revert(add(response, 0x20), mload(response)) |
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.
Should we try to open a issue with the solidity team to get a way to do this without assembly (the revert of 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.
Clever! Do I understand this to mean that the response data is the revert message?
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.
So a revert message works different. Solidity encoded the revert message like a call to a function Error(string)
(see https://solidity.readthedocs.io/en/v0.5.3/control-structures.html#error-handling-assert-require-revert-and-exceptions). Currently it is not possible to return raw bytes (without any additional encoding). Therefore it is required to use assembly for this.
@fleupold if required I could open an issue on the solidity repo
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.
So the issue would request support for calling revert
with bytes as arguments inside solidity right? I'm not sure this is the intended use of revert (we are kind of shoehorning it a bit here) and thus I'm fine with using assembly for this.
Do you have another use case where we would legitimately want to return bytes from a revert?
Feel free to open issue regardless, we can see what the solidity community thinks about.
contracts/StorageAccessible.sol
Outdated
bytes memory calldataPayload | ||
) public returns (bytes memory) { | ||
bytes memory innerCall = abi.encodeWithSignature( | ||
"executeStaticDelegatecallInternal(address,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.
General question: is it more effective to hardcode the bytes, or does the compiler solve this? Might be worth considering it if this is only optimised when the optimiser is enabled.
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.
Good point. Will make this a constant
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.
Actually, since this call depends on calldata and targetContract I can't hardcode all of it. I can avoid doing the keccak hash an just append the arguments to 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.
Very cool! I guess this will be something to use for v2 of the exchange contracts since they need to be StorageAccessible
. Would there be any benefit to declaring something as explicitly private and demonstrating that the storage would still be "accessible"?
calldataPayload | ||
); | ||
assembly { | ||
revert(add(response, 0x20), mload(response)) |
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.
Clever! Do I understand this to mean that the response data is the revert message?
function replaceFoo(uint256 foo_) public returns (uint256) { | ||
uint256 tmp = foo; | ||
foo = foo_; | ||
return tmp; | ||
} |
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.
Why do you want to return the old value of foo here? I feel like you can demonstrate the desired functionality by returning anything really.
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.
Good point. Will change the example to reflect that!
test/storage_accessible.js
Outdated
const reader = await ExternalStorageReader.new() | ||
const replaceFooCall = reader.contract.methods.replaceFoo(69).encodeABI() | ||
let result = await instance.executeStaticDelegatecall.call(reader.address, replaceFooCall) | ||
assert.equal(42, fromHex(result)) | ||
|
||
const packed = await instance.getStorageAt(await instance.SLOT_FOOBAR(), 10) | ||
assert.equal(19, fromHex(packed.slice(2, 66))) | ||
assert.equal(21, fromHex(packed.slice(66, 130))) | ||
// Make sure foo is not actually changed | ||
const getFooCall = reader.contract.methods.getFoo().encodeABI() | ||
result = await instance.executeStaticDelegatecall.call(reader.address, getFooCall) | ||
assert.equal(42, fromHex(result)) |
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.
OK, so the value isn't changed... I guess this means you can temporarily (i.e. within a single transaction) emulate what would or could happen in the event of a state change without every actually changing it... Is this what one might want it for?
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.
Here is an example of we would like to do with it in the context of the safe contracts ;) https://github.com/gnosis/safe-contracts/blob/development/contracts/GnosisSafe.sol#L276
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.
Cool example. If every smart contract developer picked up this technique, doesn’t this mean half the transactions submitted would be expected to fail?
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.
This is great! ❤️
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.
Cool!
Did you consider writing the StorageAccess as a library instead of a contract?
If you would write it as a library, then you would not have to inherit the whole code, you would just need to add one "call function"
function allowStorageAccessLibraryReading(bytes calldata functionSignature, bytes[] calldata arguments) public returns (bytes memory){
libraryAddress.delegatecall(functionSignature, arguments)
}
This approach would make the deployment hopefully cheaper.
Too bad there is not a "view only" delegatecall, here it would be a nice fit for extra security.
second thought:
If you like inheriting, then you could also have a library and a very simple contract containing the allowStorageAccessLibraryReading
function. Then, this contract would only have to be inherited.
@josojo I didn't consider using a library. I would like to avoid for people to write a custom function to expose this functionality. Your second suggestion would work for that, but if I understand correctly it would still require linking the dapp contract against the StorageReadingLibary (on top of inheriting). I'm not sure if the gas cost for deployment is going to be the limiting factor here (in the end if you intend to use this library you are likely building a dapp which you don't intend to redeploy anytime soon). For use cases like the safe we'd be using a proxy in the first place. |
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 sauce! Maybe add an example of a view function reverting to document what happens in that case.
contracts/StorageAccessible.sol
Outdated
calldataPayload | ||
); | ||
(, bytes memory response) = address(this).call(innerCall); | ||
return response; |
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.
Aren't there slight semantic differences between reverting and just returning an encoded revert message? Should you check the response for the Error(string)
selector and revert here (to propagate the revert) or even encode if the call was successful or not?
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.
I now added a test case for delegateCalling a view function that reverts internally. It does get treated as a top level revert since as I assume the returned bytes are still the same. This is the behavior you expected, right?
Are you concerned about some return flag that would be set differently?
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.
This is the behavior you expected, right?
Kind of. So actually this behaviour is AFAIK node dependent. From what I remember from ethcontract
, Ganache returns a JSON RPC error when an eth_call
reverts but Geth doesn't. So I imagine Truffle is designed to work with both, and will see the Error(string)
selector in the response bytes and just assume it is a revert
(which is how Geth would behave anyway).
Are you concerned about some return flag that would be set differently?
So, I'm a bit more concerned about solidity code that calls simulateDelegatecall
since it might subvert expectations that a revert
in the targetContract
call gets caught and not propagated. One use case that immediately comes to mind is writing an external contract (called A) to integrate with some contract (called B) that inherits from StorageAccessible
and contract A needs to read some storage data from contract B for a transaction. The fact that simulateDelegatecall
does not propagate reverts might cause bugs because the developers aren't expecting this behaviour.
You can also argue that this is the caller's responsibility, in that case I would maybe add a bit more documentation about this.
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.
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.
This is exactly the behaviour I was expecting.
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.
Hmm, what happens if it revers without a reason?
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.
Sorry, I think I'm getting carried away 😛 , I think the way it is now is 👌
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.
test/storage_accessible.js
Outdated
assert.equal(21, fromHex(packed.slice(66, 130))) | ||
const reader = await ExternalStorageReader.new() | ||
const doRevertCall = reader.contract.methods.doRevert().encodeABI() | ||
truffleAssert.reverts(instance.simulateDelegatecall.call(reader.address), doRevertCall) |
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.
Shouldn't it be:
truffleAssert.reverts(instance.simulateDelegatecall.call(reader.address), doRevertCall) | |
truffleAssert.reverts(instance.simulateDelegatecall.call(reader.address, doRevertCall)) |
?
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.
Totally. Great catch. Still passes.
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.
This is one of those subtle issues that TypeScript would have helped find 😛
contracts/StorageAccessible.sol
Outdated
result.length > 4 && | ||
result[0] == 0x08 && | ||
result[1] == 0xc3 && | ||
result[2] == 0x79 && | ||
result[3] == 0xa0; |
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.
One tiny suggestion (but I'm not sure its worth doing) is maybe this check should be stricter, specifically:
- First four bytes must be 0x08c379a0
- Next 32-byte word must be 0x20 (string offset).
- Read next 32-byte word as
len
- The
result.length = 4 + k*32
such thatk == ceil(len / 32) + 2
But that might be overkill.
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.
Your approach is 100x better.
calldataPayload | ||
); | ||
(, bytes memory response) = address(this).call(innerCall); | ||
bool innerSuccess = response[response.length - 1] == 0x01; |
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.
This is 🤘
Awesome!
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.
Looks awesome I have absolutely no more concerns!
While #30 works great for reading specific one-off storage slots, it is not very convenient in case we want to add more sophisticated view function logic to an already deployed contract (e.g. to improve gas efficiency for view functions on Gnosis Protocol)
This PR allows anyone to deploy a smart contract with the same storage layout as our original (StorageReading subclassed) contract and implement arbitrary view functions on it. They can then invoke
executeStaticDelegatecall
on the existing contract, which will execute their code in its own context via a delegatecall (using the state of the original contract). Whatever happens in this call is reverted in the end, making it side-effect-less (static).The return value is returned as abi-encoded bytes for easier consumption in web3 as well as other smart contracts (encoding as revert message would suffice for the latter).
The cool thing is that we can even invoke methods that are non-view (i.e. have side effects) and thus simulate more complex invocations. All side effects will be reverted, the returned result remains.
This is similar to the state override feature in geth but make it client agnostic and available to other smart contracts.
Credit goes to @rmeissner who wrote this code before (I'm just making it generally accessible here)
Test Plan
Added unit test to demonstrate how a fixed contract can be amended with reader functionality.