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

[StorageReading] Invoke external viewer contract via static delegatecall #31

Merged
merged 11 commits into from
Jun 12, 2020

Conversation

fleupold
Copy link
Contributor

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.

Copy link

@rmeissner rmeissner left a 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 ;) )

* @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(

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.

Copy link
Contributor Author

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))

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)?

Copy link
Contributor

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?

Copy link

@rmeissner rmeissner Jun 1, 2020

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

Copy link
Contributor Author

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.

bytes memory calldataPayload
) public returns (bytes memory) {
bytes memory innerCall = abi.encodeWithSignature(
"executeStaticDelegatecallInternal(address,bytes)",

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.

Copy link
Contributor Author

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

Copy link
Contributor Author

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.

Copy link
Contributor

@bh2smith bh2smith left a 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))
Copy link
Contributor

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?

Comment on lines 65 to 69
function replaceFoo(uint256 foo_) public returns (uint256) {
uint256 tmp = foo;
foo = foo_;
return tmp;
}
Copy link
Contributor

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.

Copy link
Contributor Author

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!

Comment on lines 72 to 80
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))
Copy link
Contributor

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?

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

Copy link
Contributor

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?

Copy link
Contributor

@anxolin anxolin left a comment

Choose a reason for hiding this comment

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

This is great! ❤️

Copy link
Contributor

@josojo josojo left a 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.

@fleupold
Copy link
Contributor Author

fleupold commented Jun 5, 2020

Did you consider writing the StorageAccess as a library instead of a contract?

@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.

Copy link
Contributor

@nlordell nlordell left a 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.

calldataPayload
);
(, bytes memory response) = address(this).call(innerCall);
return response;
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

@nlordell nlordell Jun 5, 2020

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nlordell can you take a look if 8a8f2ae adds the behavior you were expecting?

Copy link
Contributor

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.

Copy link
Contributor

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?

Copy link
Contributor

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 👌

Copy link
Contributor Author

@fleupold fleupold Jun 12, 2020

Choose a reason for hiding this comment

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

Checkout 58f4ad2

This now also works with reverts that don't have a reason.

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't it be:

Suggested change
truffleAssert.reverts(instance.simulateDelegatecall.call(reader.address), doRevertCall)
truffleAssert.reverts(instance.simulateDelegatecall.call(reader.address, doRevertCall))

?

Copy link
Contributor Author

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.

Copy link
Contributor

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 😛

Comment on lines 74 to 78
result.length > 4 &&
result[0] == 0x08 &&
result[1] == 0xc3 &&
result[2] == 0x79 &&
result[3] == 0xa0;
Copy link
Contributor

@nlordell nlordell Jun 12, 2020

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 that k == ceil(len / 32) + 2

But that might be overkill.

Copy link
Contributor

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

This is 🤘

Awesome!

Copy link
Contributor

@nlordell nlordell left a 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!

@fleupold fleupold merged commit fd08cb5 into master Jun 12, 2020
@bh2smith bh2smith deleted the static_delegatecall branch December 7, 2020 10:26
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.

6 participants