-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Resolve "Reverted report validator transactions get added in block #10580" #10602
Resolve "Reverted report validator transactions get added in block #10580" #10602
Conversation
…tion checking + add call before transact to Validator contract
It looks like @VladLupashevskyi signed our Contributor License Agreement. 👍 Many thanks, Parity Technologies CLA Bot |
Like you said, I also think that the decoding of the revert message should be moved into it's own function. I'm not entirely sure what the best place for it is in the codebase, so maybe just make it a method of the |
@@ -58,6 +59,7 @@ impl ValidatorContract { | |||
|
|||
match client.as_full_client() { | |||
Some(c) => { | |||
c.call_contract_as_author(BlockId::Latest, self.contract_address.clone(), data.clone())?; |
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.
Two things here:
- Maybe I'm missing something, but why do we need to add this?
- I don't think the
.clone()
on the contract address is necessary
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.
- It's used exactly for checking whether the tx will be reverted or not and if yes it will fail with an error.
- Done :)
…nts + simplify code according to code review
…into 10580-estimate-gas-for-validator-contract-calls
…into 10580-estimate-gas-for-validator-contract-calls
…into 10580-estimate-gas-for-validator-contract-calls
let msg = self.try_decode_solidity_revert_msg(&output).unwrap_or_else(|| format!("{}", Token::Bytes(output))); | ||
error = format!("{} {}", error, msg); | ||
} | ||
Err(error) |
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.
To make this a little easier to follow I suggest:
if let Some(exception) = executed.exception {
match exception {
VmError::Reverted => {
…
Err(format!("{:?} {}", exception, msg))
},
_ => Err(exception.to_string()) // not sure if `From<String>` is impl'd for `VmError`
}
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.
A more general question I have is: why do this error munging here rather than in the Debug
impl for VmError
? Couldn't we add the decoding attempt there and essentially keep this code to a simple map_err(|e| format!("{:?}", e))
like it was before? Seems like this is spreading out the logic into several places and I'm not sure I understand why it's worth 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.
Unfortunately there is no access from VmError
to tx output
which we need for decoding error. Alternatively we could put all this error munging under Executed
in some function like format_error_and_decode_ouput
. What do you think about it @dvdplm ?
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.
Unfortunately there is no access from VmError to tx output
Can we change that maybe? Like, VmError::Reverted(tx_output)
. If not, then I'm not opposed to having a format_error_and_decode_ouput
helper like you suggest.
@@ -378,6 +378,9 @@ pub trait BlockChainClient : Sync + Send + AccountData + BlockChain + CallContra | |||
/// Schedule state-altering transaction to be executed on the next pending block. | |||
fn transact_contract(&self, address: Address, data: Bytes) -> Result<(), transaction::Error>; | |||
|
|||
/// Call contract as block author | |||
fn call_contract_as_author(&self, id: BlockId, address: Address, data: Bytes) -> Result<Bytes, String>; |
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.
Not sure I like this addition. Seems like a convenience method, but maybe I'm missing something. I'm hesitant to add new trait methods if we can avoid 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.
The issue of not using call_contract_from_sender
is because we can't get blocks author outside client in transact
method of ValidatorContract
. By avoiding usage of this method we would need to introduce getter for author or the whole miner instance (which is available but only for tests):
Honestly I'm not sure what would be the best solution here. @dvdplm any thoughts about that?
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.
Not sure either tbh. @tomusdrw?
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.
We already have CallContract
trait for calling contracts, we have put a lot of effort trying to make BlockChainClient
smaller, so adding any new methods to this trait is a no go.
If validator_set/contract.rs
needs to call a contract as author (what's questionable as well), it should get the author address from engine (where it is created or called)
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'm very unsure if that's he right approach. I see two other options (or combination of them) to what I believe would achieve the same goal as this PR:
- CLI to disable reports (then transactions are never produced)
- Parsing Revert reason when printing "Reverted ..." to display more friendly message about the call being reverted due to being not supported by the contract.
My reasoning behind this:
- We introduce additional call during the process (and calls are heavy!)
- The fact that we succesfuly call the contract doesn't guarantee that the transaction will pass (nor vice versa)
@@ -58,6 +59,7 @@ impl ValidatorContract { | |||
|
|||
match client.as_full_client() { | |||
Some(c) => { | |||
c.call_contract_as_author(BlockId::Latest, self.contract_address, data.clone())?; |
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 it seems that now we call the contract twice just to improve the logs?
I'm really hesitant about this change - why can't we decode the error code from the receipt of actual transaction? Now you have no guarantees that if the first call passes the transaction will actually succeed.
@@ -378,6 +378,9 @@ pub trait BlockChainClient : Sync + Send + AccountData + BlockChain + CallContra | |||
/// Schedule state-altering transaction to be executed on the next pending block. | |||
fn transact_contract(&self, address: Address, data: Bytes) -> Result<(), transaction::Error>; | |||
|
|||
/// Call contract as block author | |||
fn call_contract_as_author(&self, id: BlockId, address: Address, data: Bytes) -> Result<Bytes, String>; |
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.
We already have CallContract
trait for calling contracts, we have put a lot of effort trying to make BlockChainClient
smaller, so adding any new methods to this trait is a no go.
If validator_set/contract.rs
needs to call a contract as author (what's questionable as well), it should get the author address from engine (where it is created or called)
fn call_contract_from_sender(&self, block_id: BlockId, address: Address, sender: Address, data: Bytes) -> Result<Bytes, String>; | ||
|
||
/// Try to decode Solidity revert string | ||
fn try_decode_solidity_revert_msg(&self, data: &Bytes) -> Option<String> { |
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 doesn't suit the trait at all:
- You don't use
self
- It's should never be overwritten by the implementers.
This should be moved to a separate function that is use in the implementation.
use types::ids::BlockId; | ||
|
||
const ERROR_SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0]; | ||
|
||
/// Provides `call_contract` method | ||
pub trait CallContract { |
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 have considerations regarding the entire trait, extracting revert messages really calls for introducing a proper error type here (at least a two-value enum).
I think it would also be way better to introduce an Options
parameter (with a builder) instead of introducing another method:
pub enum CallError {
Reverted(String),
Other(String),
}
#[derive(derive_builder::Builder, Default, Clone)]
pub CallOptions {
contract_address: Address,
sender: Address,
data: Bytes,
value: U256,
gas: U256,
gas_price: U256,
}
impl CallOptions {
/// Convenience method for creating the most common use case.
pub fn new(contract: Address, data: Bytes) -> Self {
Self::default().contract_address(contract).data(data)
}
}
pub trait CallContract {
/// Executes a transient call to a contract at given `BlockId`.
///
/// The constructed transaction must be executed on top of state at block `id`,
/// any changes introduce by the call must be discarded.
/// Returns:
/// - A return data from the contract if the call was successful,
/// - A `CallError::Reverted(msg)` error in case the call was reverted with an exception and message.
/// - A `CallError::Other(msg)` in case the call did not succeed for other reasons.
fn call_contract(&self, id: BlockId, call_options: CallOptions) -> Result<Bytes, CallError>;
}
@@ -1223,15 +1224,19 @@ impl Client { | |||
// transaction for calling contracts from services like engine. | |||
// from the null sender, with 50M gas. | |||
fn contract_call_tx(&self, block_id: BlockId, address: Address, data: Bytes) -> SignedTransaction { | |||
let from = Address::zero(); | |||
self.contract_call_tx_from_sender(block_id, address, Address::zero(), 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.
Since it's repeated so many times it should be just default implementation, but instead best to have a look at the CallOptions
pattern above.
@tomusdrw Thank you very much for very informative comments! At the very beginning I was considering adding just CLI flags to disable report benign/malicious, however I thought it won't be that robust in cases when the conditions that report will be reverted might change. For example if we have the following pattern in the contract: function reportBenign(address sealingAddress, uint256 blockNumber) external {
require(reportBenignEnabled, "Report benign is disabled");
...
} the sealers configuration file would need to be updated every time However I have to admit that I haven't payed enough attention to the architectures where you cannot afford call checks before tx is mined due to constantly changing contract storage. Considering everything above I would propose to have the CLI flags (
I believe having such a choice everybody could choose the compromise between robustness and guarantees that every report is added to the block. As for the logging, I like the idea of adding logs when tx is mined, however it's not possible to get output of tx neither from tx nor from tx receipt, the only thing comes to my mind is trace module. Or did you mean just logging tx failure based on |
@VladLupashevskyi I like the idea of having it fully configurable, that would be quite amazing, but will probably require a bit more effort.
I see, although the question is which use case is dominant and we should definitely set the defaults according to this. I'd argue that calling the contract even though we know the reporting is always enabled is extremely wasteful and will negatively affect performance, even if it's just a tiny bit, the performance was always our focus and any slowness can also affect network stability (validator can miss it's slot).
Oh, I actually thought that revert reason is stored in the receipt, if that's not the case we could actually I'm still not a huge fan of BTW Thank you for the PR anyway! |
@tomusdrw Thank you for your arguments :) I have tried the idea of not including the reverted transaction as I think i would be 100% guaranteed that the tx is reverted at that point. So I did a little research in the code base and found this place where we get a transaction receipt, but the tx is not yet added to the block ( I added here these lines for checking ( if deny_reverted {
if let types::receipt::TransactionOutcome::StatusCode(status_code) = outcome.receipt.outcome {
if status_code == 0 {
self.block.state.dec_nonce(&t.sender());
return Err(TransactionError::Reverted.into());
}
}
}; This seems to work (with removing tx from the queue of course), however I realized then that there is actually no connection between Just to prove that I checked how many reports do I have on the chain with 25k reports where the reporter and the block author addresses differ. The result is 2k reports (10%). Therefore I came up with an idea to handle these cases more deterministically. In a nutshell I would add the function to the engine which would check whether applied tx should be removed from the block (so basically it's the check that happens after To implement this I would refactor the
In
The method So using this logic we could guarantee that no reverted tx will be placed in the block by any validator (given they have the same chain spec), and the performance won't be affected because of What do you think is it worth to try that or should I better implement configurable check using |
Thanks for the write-up @VladLupashevskyi. Having that fully fleshed-out as you did, I don't think we are going into the right direction. Altering the low-level block.rs and applying to state seems a bit too much hassle for this feature, sorry I suggested that and then retracted, I thought we could get away with something less involved and mostly isolated to Currently I'm more inclined towards implementing call + cache and having it configurable via CLI. @dvdplm what's your thoughts on this? |
Hmm. It's a tricky one. The complexity introduced to the code would be quite significant on the one hand; on the other now that I understand the use-case better I see @VladLupashevskyi side of things as well. (Excellent write up btw, thank you for that.) |
I'm closing this pr for now. Please reopen if the issues are resolved or the new approach is implemented |
Here I added the call check before
transact_contract
for Validators contract in order to prevent adding reverted report transactions to the block.For this I had to introduce
call_contract_from_sender
forCallContract
trait where I added processing of VM exception as well as decoding of revert message according to Solidity specs (Error(string)
function). I'm not sure whether the decoding logic should be moved into separate function, so I implemented everything incall_contract_from_sender
for now.So now having
revert("Benign reporting is disabled")
inreportBenign
function in ValidatorSet smart contract would lead to such messages in the console:In case if the revert result is not decodable pure bytes will be shown.
Closes #10580