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

Reduce gas/storage limits in nested calls #6890

Open
wants to merge 27 commits into
base: master
Choose a base branch
from

Conversation

rockbmb
Copy link
Contributor

@rockbmb rockbmb commented Dec 13, 2024

Closes #6846 .

@rockbmb rockbmb added T2-pallets This PR/Issue is related to a particular pallet. T7-smart_contracts This PR/Issue is related to smart contracts. T10-tests This PR/Issue is related to tests. labels Dec 13, 2024
@rockbmb rockbmb self-assigned this Dec 13, 2024
@rockbmb rockbmb force-pushed the evm-contracts-limit-nested-calls-gas branch 2 times, most recently from 376e944 to 4832380 Compare December 17, 2024 02:17
Also, a limit of 0 when determining a nested call's metering limit would
mean it was free to use all of the callee's resources.
Now, a limit of 0 means that the nested call will have an empty storage
meter.
In particular, this commit removes the usage of `0` as unlimited
metering in the following tests:

- `nonce`
- `last_frame_output_works_on_instantiate`
- `instantiation_from_contract`
- `immutable_data_set_works_only_once`
- `immutable_data_set_errors_with_empty_data`
- `immutable_data_access_checks_work`
@rockbmb rockbmb force-pushed the evm-contracts-limit-nested-calls-gas branch from ed1f312 to 8e66f50 Compare December 17, 2024 19:15
@rockbmb rockbmb marked this pull request as ready for review December 17, 2024 19:31
@athei athei requested review from athei and xermicus December 17, 2024 20:08
Copy link
Member

@xermicus xermicus left a comment

Choose a reason for hiding this comment

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

The basic idea of the 63/64 rule looks correct to me but there are many failing tests. I left some nits.

.min(self.gas_left);
self.gas_left -= amount;
GasMeter::new(amount)
// The reduction to 63/64 is to emulate EIP-150
Copy link
Member

Choose a reason for hiding this comment

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

This should be a doc-comment

substrate/frame/revive/src/storage/meter.rs Outdated Show resolved Hide resolved
debug_assert!(matches!(self.contract_state(), ContractState::Alive));

// Limit gas for nested call, per EIP-150.
Copy link
Member

Choose a reason for hiding this comment

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

This could go into the doc-comment (which is no no-longer correct I think)

This fixes, among other tests:
* `tests::gas_consumed_is_linear_for_nested_calls` test
* `tests::deposit_limit_in_nested_calls`
* `tests::transient_storage_limit_in_call`
* `tests::call_return_code`
* `test::chain_extension_temp_storage_works`
* `tests::origin_api_works`
* `tests::read_only_call_works`
@rockbmb
Copy link
Contributor Author

rockbmb commented Dec 19, 2024

@xermicus the meters in frame::contracts::{gas, storage::meter} also need to reflect this, correct?

@athei
Copy link
Member

athei commented Dec 19, 2024

Sorry if this wasn't clear: Only pallet_revive and its supporting crates should be changed. pallet_contracts should remain untouched. It does't receive any updates beyond critical bug fixes anymore. Can you roll back the changes there? Will have a deeper look then.

@rockbmb
Copy link
Contributor Author

rockbmb commented Dec 19, 2024

@athei frame/contracts changes have been reverted, thanks for explaining this.

Most of the previously failing tests were caused by the contract code of those tests using the now-removed '0 means "use all available gas"' semantic.

After fixing this, cargo test --package pallet-revive:0.1.0 --lib --all-features yields:

failures:
    benchmarking::benchmarks::bench_seal_instantiate
    tests::call_diverging_out_len_works
    tests::create1_with_value_works
    tests::delegate_call
    tests::deploy_and_call_other_contract
    tests::deposit_limit_in_nested_calls
    tests::deposit_limit_in_nested_instantiate
    tests::destroy_contract_and_transfer_funds
    tests::failed_deposit_charge_should_roll_back_call
    tests::gas_estimation_call_runtime
    tests::gas_estimation_for_subcalls
    tests::return_data_api_works
    tests::skip_transfer_works
    tests::storage_deposit_callee_works
    tests::test_debug::debugging_works

The contracts the above tests rely on have removed usages of 0 as "use all", so their failures are somewhere else.

@rockbmb
Copy link
Contributor Author

rockbmb commented Dec 19, 2024

/bot fmt

Copy link
Member

@athei athei left a comment

Choose a reason for hiding this comment

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

Due to the changes here you can get rid of the fn enforce_subcall_limit() and enum Nested. They are no longer needed because we now always enforce the limit at the sub call boundry (calling fn enforce_limit unconditionally).

Apart from a few other comments it looks pretty much like what I expect. Thanks.

substrate/frame/revive/src/gas.rs Outdated Show resolved Hide resolved
substrate/frame/revive/src/gas.rs Outdated Show resolved Hide resolved
substrate/frame/contracts/src/benchmarking/mod.rs Outdated Show resolved Hide resolved
substrate/frame/revive/src/exec.rs Show resolved Hide resolved
@rockbmb
Copy link
Contributor Author

rockbmb commented Dec 20, 2024

Due to the changes here you can get rid of the fn enforce_subcall_limit() and enum Nested. They are no longer needed because we now always enforce the limit at the sub call boundry (calling fn enforce_limit unconditionally).

If I raze enum Nested, does it make sense to

  1. Remove RawMeter's 3rd type parameter S, so it becomes pub struct RawMeter<T: Config, E>
  2. move all methods in impl<T: Config, E: Ext<T>> RawMeter<T, E, Nested> into impl<T, E> RawMeter<T, E, Root>

?

@athei
Copy link
Member

athei commented Dec 20, 2024

The type stating is still useful for other functions than enforce_subcall_limit that are exist on the nested meter. I wasn't aware the enum I told you to delete was also for used that. So no, keep the third type parameter.

Just remove the variants of Nested (or convert to unit structure like Root) and removed the nested field from RawMeter. Just add S to the _phantom.

@rockbmb
Copy link
Contributor Author

rockbmb commented Dec 21, 2024

14 unit tests are still failing, among them

  1. tests::deposit_limit_in_nested_calls, and
  2. tests::deposit_limit_in_nested_instantiate

these 2 tests fail because of a ContractReverted error where StorageDepositLimitExhausted was expected.

The remaining tests fail with ContractTrapped when assert_oking the result of CallBuilder::build.
E.g. for tests::delegate_call:

assert_ok!(builder::call(caller_addr) <-- assert fails!
	.value(1337)
	.data((callee_addr, 0u64, 0u64).encode())
	.build());

⬆️ is context in case you have any insights to share - the tests all failing for the same reason must be a clue to the underlying problem.

I'll move fast so paritytech/revive#117 can continue.

Copy link
Member

@athei athei left a comment

Choose a reason for hiding this comment

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

Looks good. Just some nits.

Regarding the failing tests: Your code looks correct to me. However, it is a breaking change as it prevents sub calls from using all the remaining gas which was possible before. So I assume the tests rely in one way or another on this behavior and need to be changed.

substrate/frame/revive/src/exec.rs Outdated Show resolved Hide resolved
substrate/frame/revive/src/exec.rs Show resolved Hide resolved
substrate/frame/revive/src/gas.rs Outdated Show resolved Hide resolved
Comment on lines 482 to 485
///
/// We normally need to call this **once** for every call stack and not for every cross contract
/// call. However, if a dedicated limit is specified for a sub-call, this needs to be called
/// once the sub-call has returned. For this, the [`Self::enforce_subcall_limit`] wrapper is
/// used.
/// once the sub-call has returned.
Copy link
Member

Choose a reason for hiding this comment

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

Doc comment needs updating to reflect the new much simpler behavior: This has to be called every time a sub call returns. No special casing anymore for a 'dedicated limit'. We just removed this special casing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comment has been changed on my tree, and no longer contains any reference to enforce_subcall_limit.

Copy link
Member

Choose a reason for hiding this comment

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

The rest of the comment is still stale:

/// We normally need to call this **once** for every call stack and not for every cross contract
/// call. However, if a dedicated limit is specified for a sub-call, this needs to be called

There is no special limit anymore. Also, we never call it once per call stack as stated here. We call it for every sub call now.

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 was wrong, thanks for clarifying.

substrate/frame/revive/src/storage/meter.rs Outdated Show resolved Hide resolved
@athei
Copy link
Member

athei commented Jan 2, 2025

these 2 tests fail because of a ContractReverted error where StorageDepositLimitExhausted was expected.

This can be a bit finicky and requires some knowledge of how the error handling works in contracts to debug. The error you are asserting on here is what is returned from the root contract that is called from the transaction.

However, when this contract calls into another contract and the callee encounters an error like StorageDepositLimitExhausted it will just be returned as an error code to the caller. This is so that the caller can decide how to handle errors in sub calls rather than just reverting.

The change you made by removing enforce_subcall_limit can leadd to the situation that we fail in the sub calls context when running out of storage deposit. At least when we were passing 0. Before we delayed the check to the last frame returning and failed in the callers context. The caller probably just reverts on non-zero return codes from the sub calls. This is why you get the generic ContractReverted instead of the more detailed StorageDepositLimitExhausted.

This is actually expected and nice that it is covered by test cases. You should change the tests to adapt to the new behavior. You can see that the caller contract also returns the sub calls return code in addition to reverting. So you can check this return code in your test and are not at the mercy of the generic ContractReverted error.

Use `BareCallBuilder::bare_call` instead of
`CallBuilder::call` to allow scrutiny of contract return code with
`assert_return_code`, and disambiguate the cause of the
`ContractReverted` error that `CallBuilder::build` would have raised.
@rockbmb
Copy link
Contributor Author

rockbmb commented Jan 3, 2025

You should change the tests to adapt to the new behavior. You can see that the caller contract also returns the sub calls return code in addition to reverting. So you can check this return code in your test and are not at the mercy of the generic ContractReverted error.

Thanks for clarifying this - in acfcf66 and e0c0845, both tests that threw ContractReverted are corrected.

The remaining ones throw ContractTrapped - as you said, these tests may need to be changed.

Comment on lines 1133 to 1134
// If a special limit was set for the sub-call, we enforce it here.
// The sub-call will be rolled back in case the limit is exhausted.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// If a special limit was set for the sub-call, we enforce it here.
// The sub-call will be rolled back in case the limit is exhausted.
// We are only charging the storage deposit at the end of every call stack.
// To make sure that no sub call uses more than it is allowed we need to
// manually enforce the limit here.

@@ -126,7 +121,7 @@ pub struct RawMeter<T: Config, E, S: State + Default + Debug> {
/// limited by the maximum call depth.
charges: Vec<Charge<T>>,
/// We store the nested state to determine if it has a special limit for sub-call.
nested: S,
_nested: PhantomData<S>,
/// Type parameter only used in impls.
_phantom: PhantomData<E>,
Copy link
Member

Choose a reason for hiding this comment

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

You can remove the nested field.

Suggested change
_phantom: PhantomData<E>,
_phantom: PhantomData<(E, S)>,

@rockbmb rockbmb force-pushed the evm-contracts-limit-nested-calls-gas branch from 1844efa to b090e5f Compare January 6, 2025 14:26
@rockbmb
Copy link
Contributor Author

rockbmb commented Jan 6, 2025

Apologies for the force-push - I made a mistake in a commit prior to the master merge.

A limit of `0` no longer signifies "no limit", and must thus be changed
to `u64::MAX`.
@rockbmb
Copy link
Contributor Author

rockbmb commented Jan 6, 2025

/cmd fmt

Copy link

github-actions bot commented Jan 6, 2025

Command "fmt" has started 🚀 See logs here

Copy link

github-actions bot commented Jan 6, 2025

Command "fmt" has finished ✅ See logs here

A deposit limit of 0 no longer is a special case, and is treated as
no limit being available for use in a benchmarking function.
@rockbmb
Copy link
Contributor Author

rockbmb commented Jan 6, 2025

The only tests that still need to be addressed:

failures:
    tests::gas_estimation_for_subcalls
    tests::skip_transfer_works

Comment on lines 1007 to 1011
let deposit_limit = if deposit_ptr == SENTINEL {
U256::from(u64::MAX)
} else {
memory.read_u256(deposit_ptr)?
};
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let deposit_limit = if deposit_ptr == SENTINEL {
U256::from(u64::MAX)
} else {
memory.read_u256(deposit_ptr)?
};
memory.read_u256(deposit_ptr)?

Copy link
Member

Choose a reason for hiding this comment

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

We don't want any special handling here anymore.

Comment on lines 1097 to 1101
let deposit_limit: U256 = if deposit_ptr == SENTINEL {
U256::from(u64::MAX)
} else {
memory.read_u256(deposit_ptr)?
};
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let deposit_limit: U256 = if deposit_ptr == SENTINEL {
U256::from(u64::MAX)
} else {
memory.read_u256(deposit_ptr)?
};
memory.read_u256(deposit_ptr)?

Copy link
Contributor Author

@rockbmb rockbmb Jan 7, 2025

Choose a reason for hiding this comment

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

This raises OutOfBounds errors in many tests (it replaced the last 2 tests' failure ContractTrapped failure, which is progress).

Do you have an idea of what might be causing this?

Copy link
Member

Choose a reason for hiding this comment

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

The test fixtures need to be adapted to always pass a valid pointer. SENTINEL is used to indicate a None so to say.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For example, in substrate/frame/revive/fixtures/contracts/call.rs, the deposit limit is None - this is was we want, right

Copy link
Member

Choose a reason for hiding this comment

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

Yes. You need to change the wrapper in pallet-fixtures-uapi. It no longer takes an Option since the value is no longer optional. You then pass in U256::MAX where None was passes before.

Copy link
Contributor Author

@rockbmb rockbmb Jan 7, 2025

Choose a reason for hiding this comment

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

Where there is U256::MAX, do you mean &[u8::MAX; 32], since in trait HostFn one has deposit_limit: &[u8; 32],?

Furthermore, instead of &[u8::MAX; 32], won't we want something like &[u8::MAX; 8].concat([0u8; 24]? Equivalent to U256::from(u64::MAX).
Using &[u8::MAX; 32] will raise Error::BalanceConversionFailed when the value is read from memory and converted into a Balance<T>.

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 opted for (something similar to) &[0xFF; 8].concat([0u8; 24] to solve this issue.

@paritytech-workflow-stopper
Copy link

All GitHub workflows were cancelled due to failure one of the required jobs.
Failed workflow url: https://github.com/paritytech/polkadot-sdk/actions/runs/12653577226
Failed job name: fmt

@rockbmb
Copy link
Contributor Author

rockbmb commented Jan 7, 2025

@athei One question:

What is the meaning of value: 0u32.into()?
I.e. after the semantic change in the meaning of 0 introduced by this PR, should its default still be 0?

This is causing tests::gas_estimation_for_subcalls to fail when creating the storage meter for Pallet::bare_call by creating a meter with 0 limit, eventually leading to ContractTrapped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T2-pallets This PR/Issue is related to a particular pallet. T7-smart_contracts This PR/Issue is related to smart contracts. T10-tests This PR/Issue is related to tests.
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

Limit resources to 63/64 on sub calls and remove 0 as special case for "take all"
3 participants