switch to pallet_revive runtime (#40)

Signed-off-by: xermicus <cyrill@parity.io>
This commit is contained in:
Cyrill Leutwiler
2024-09-03 17:18:22 +02:00
committed by GitHub
parent 41c8d4e955
commit d2f76b645f
66 changed files with 3023 additions and 2911 deletions
+154 -197
View File
@@ -7,10 +7,11 @@
//! ```rust
//! use revive_runner::*;
//! use SpecsAction::*;
//! run_test(Specs {
//! Specs {
//! differential: false,
//! balances: vec![(ALICE, 1_000_000_000)],
//! actions: vec![Instantiate {
//! origin: ALICE,
//! origin: TestAccountId::Alice,
//! value: 0,
//! gas_limit: Some(GAS_LIMIT),
//! storage_deposit_limit: Some(DEPOSIT_LIMIT),
@@ -18,25 +19,34 @@
//! data: vec![],
//! salt: vec![],
//! }],
//! })
//! }
//! .run();
//! ```
use std::time::Duration;
use hex::{FromHex, FromHexError, ToHex};
use polkadot_sdk::*;
use polkadot_sdk::{
pallet_revive::{CollectEvents, ContractExecResult, ContractInstantiateResult, DebugInfo},
polkadot_runtime_common::BuildStorage,
polkadot_sdk_frame::testing_prelude::*,
sp_keystore::{testing::MemoryKeystore, KeystoreExt},
sp_runtime::AccountId32,
};
use serde::{Deserialize, Serialize};
mod runtime;
use crate::runtime::*;
mod specs;
pub const ALICE: AccountId32 = AccountId32::new([1u8; 32]);
pub const BOB: AccountId32 = AccountId32::new([2u8; 32]);
pub const CHARLIE: AccountId32 = AccountId32::new([3u8; 32]);
use crate::runtime::*;
pub use crate::specs::*;
pub const ALICE: AccountId = AccountId::new([1u8; 32]);
pub const BOB: AccountId = AccountId::new([2u8; 32]);
pub const CHARLIE: AccountId = AccountId::new([3u8; 32]);
const SPEC_MARKER_BEGIN: &str = "/* runner.json";
const SPEC_MARKER_END: &str = "*/";
/// Externalities builder
#[derive(Default)]
@@ -81,18 +91,57 @@ pub const DEPOSIT_LIMIT: Balance = 10_000_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyCallExpectation {
/// When provided, the expected gas consumed
gas_consumed: Option<Weight>,
pub gas_consumed: Option<Weight>,
/// When provided, the expected output
output: Option<Vec<u8>>,
#[serde(default, with = "hex")]
pub output: OptionalHex,
///Expected call result
success: bool,
pub success: bool,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OptionalHex(Option<Vec<u8>>);
impl FromHex for OptionalHex {
type Error = FromHexError;
fn from_hex<T: AsRef<[u8]>>(hex: T) -> Result<Self, Self::Error> {
let value = hex::decode(hex)?;
Ok(Self(Some(value)))
}
}
impl ToHex for &OptionalHex {
fn encode_hex<T: std::iter::FromIterator<char>>(&self) -> T {
match self.0.as_ref() {
None => T::from_iter("".chars()),
Some(data) => T::from_iter(hex::encode(data).chars()),
}
}
fn encode_hex_upper<T: std::iter::FromIterator<char>>(&self) -> T {
match self.0.as_ref() {
None => T::from_iter("".chars()),
Some(data) => T::from_iter(hex::encode_upper(data).chars()),
}
}
}
impl From<alloy_primitives::Bytes> for OptionalHex {
fn from(value: alloy_primitives::Bytes) -> Self {
if value.is_empty() {
OptionalHex(None)
} else {
OptionalHex(Some(value.into()))
}
}
}
impl Default for VerifyCallExpectation {
fn default() -> Self {
Self {
gas_consumed: None,
output: None,
output: OptionalHex(None),
success: true,
}
}
@@ -100,42 +149,51 @@ impl Default for VerifyCallExpectation {
impl VerifyCallExpectation {
/// Verify that the expectations are met
fn verify(self, result: CallResult) {
dbg!(&result);
assert_eq!(self.success, result.is_ok());
fn verify(self, result: &CallResult) {
assert_eq!(
self.success,
result.is_ok(),
"contract execution result mismatch: {result:?}"
);
if let Some(gas_consumed) = self.gas_consumed {
assert_eq!(gas_consumed, result.gas_consumed());
}
if let Some(output) = self.output {
assert_eq!(output, result.output());
if let OptionalHex(Some(data)) = self.output {
assert_eq!(data, result.output());
}
}
}
/// Result of a call
#[derive(Debug)]
enum CallResult {
Exec(ContractExecResult<Balance, EventRecord>),
Instantiate(ContractInstantiateResult<AccountId, Balance, EventRecord>),
#[derive(Clone, Debug)]
pub enum CallResult {
Exec {
result: ContractExecResult<Balance, EventRecord>,
wall_time: Duration,
},
Instantiate {
result: ContractInstantiateResult<AccountId, Balance, EventRecord>,
wall_time: Duration,
},
}
impl CallResult {
/// Check if the call was successful
fn is_ok(&self) -> bool {
match self {
Self::Exec(res) => res.result.is_ok(),
Self::Instantiate(res) => res.result.is_ok(),
Self::Exec { result, .. } => result.result.is_ok(),
Self::Instantiate { result, .. } => result.result.is_ok(),
}
}
/// Get the output of the call
fn output(&self) -> Vec<u8> {
match self {
Self::Exec(res) => res
Self::Exec { result, .. } => result
.result
.as_ref()
.map(|r| r.data.clone())
.unwrap_or_default(),
Self::Instantiate(res) => res
Self::Instantiate { result, .. } => result
.result
.as_ref()
.map(|r| r.result.data.clone())
@@ -145,8 +203,8 @@ impl CallResult {
/// Get the gas consumed by the call
fn gas_consumed(&self) -> Weight {
match self {
Self::Exec(res) => res.gas_consumed,
Self::Instantiate(res) => res.gas_consumed,
Self::Exec { result, .. } => result.gas_consumed,
Self::Instantiate { result, .. } => result.gas_consumed,
}
}
}
@@ -155,7 +213,9 @@ impl CallResult {
pub enum Code {
/// Compile a single solidity source and use the blob of `contract`
Solidity {
path: std::path::PathBuf,
path: Option<std::path::PathBuf>,
solc_optimizer: Option<bool>,
pipeline: Option<revive_solidity::SolcPipeline>,
contract: String,
},
/// Read the contract blob from disk
@@ -166,13 +226,32 @@ pub enum Code {
Hash(Hash),
}
impl Default for Code {
fn default() -> Self {
Self::Bytes(vec![])
}
}
impl From<Code> for pallet_revive::Code<Hash> {
fn from(val: Code) -> Self {
match val {
Code::Solidity { path, contract } => {
pallet_revive::Code::Upload(revive_solidity::test_utils::compile_blob(
contract.as_str(),
std::fs::read_to_string(path).unwrap().as_str(),
Code::Solidity {
path,
contract,
solc_optimizer,
pipeline,
} => {
let Some(path) = path else {
panic!("Solidity source of contract '{contract}' missing path");
};
let Ok(source_code) = std::fs::read_to_string(&path) else {
panic!("Failed to reead source code from {}", path.display());
};
pallet_revive::Code::Upload(revive_solidity::test_utils::compile_blob_with_options(
&contract,
&source_code,
solc_optimizer.unwrap_or(true),
pipeline.unwrap_or(revive_solidity::SolcPipeline::Yul),
))
}
Code::Path(path) => pallet_revive::Code::Upload(std::fs::read(path).unwrap()),
@@ -182,179 +261,56 @@ impl From<Code> for pallet_revive::Code<Hash> {
}
}
/// An action to perform in a contract test
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SpecsAction {
/// Instantiate a contract
Instantiate {
origin: AccountId,
#[serde(default)]
value: Balance,
#[serde(default)]
gas_limit: Option<Weight>,
#[serde(default)]
storage_deposit_limit: Option<Balance>,
code: Code,
#[serde(default)]
data: Vec<u8>,
#[serde(default)]
salt: Vec<u8>,
},
/// Call a contract
Call {
origin: AccountId,
dest: AccountId,
#[serde(default)]
value: Balance,
#[serde(default)]
gas_limit: Option<Weight>,
#[serde(default)]
storage_deposit_limit: Option<Balance>,
#[serde(default)]
data: Vec<u8>,
},
/// Verify the result of the last call, omitting this will simply ensure the last call was successful
VerifyCall(VerifyCallExpectation),
pub fn specs_from_comment(contract_name: &str, path: &str) -> Vec<Specs> {
let solidity = match std::fs::read_to_string(path) {
Err(err) => panic!("unable to read {path}: {err}"),
Ok(solidity) => solidity,
};
let mut json_string = String::with_capacity(solidity.len());
let mut is_reading = false;
let mut specs = Vec::new();
/// Verify the balance of an account
VerifyBalance {
origin: AccountId,
expected: Balance,
},
/// Verify the storage of a contract
VerifyStorage {
contract: AccountId,
key: Vec<u8>,
expected: Option<Vec<u8>>,
},
}
for line in solidity.lines() {
if line.starts_with(SPEC_MARKER_BEGIN) {
is_reading = true;
continue;
}
/// Specs for a contract test
#[derive(Default, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Specs {
/// List of endowments at genesis
pub balances: Vec<(AccountId, Balance)>,
/// List of actions to perform
pub actions: Vec<SpecsAction>,
}
impl Specs {
/// Get the list of actions to perform
/// A default [`SpecAction::VerifyCall`] is injected after each Instantiate or Call action when
/// missing
fn actions(&self) -> Vec<SpecsAction> {
self.actions
.iter()
.enumerate()
.flat_map(|(index, item)| {
let next_item = self.actions.get(index + 1);
if matches!(
item,
SpecsAction::Instantiate { .. } | SpecsAction::Call { .. }
) && !matches!(next_item, Some(SpecsAction::VerifyCall(_)))
{
return vec![
item.clone(),
SpecsAction::VerifyCall(VerifyCallExpectation::default()),
];
}
vec![item.clone()]
})
.collect()
}
}
/// Run a contract test
/// The test takes a [`Specs`] and executes the actions in order
pub fn run_test(specs: Specs) {
ExtBuilder::default()
.balance_genesis_config(specs.balances.clone())
.build()
.execute_with(|| {
use SpecsAction::*;
let mut res: Option<CallResult> = None;
let actions = specs.actions();
for action in actions {
match action {
Instantiate {
origin,
value,
gas_limit,
storage_deposit_limit,
code,
data,
salt,
} => {
res = Some(CallResult::Instantiate(Contracts::bare_instantiate(
RuntimeOrigin::signed(origin),
value,
gas_limit.unwrap_or(GAS_LIMIT),
storage_deposit_limit.unwrap_or(DEPOSIT_LIMIT),
code.into(),
data,
salt,
DebugInfo::Skip,
CollectEvents::Skip,
)));
}
Call {
origin,
dest,
value,
gas_limit,
storage_deposit_limit,
data,
} => {
res = Some(CallResult::Exec(Contracts::bare_call(
RuntimeOrigin::signed(origin),
dest,
value,
gas_limit.unwrap_or(GAS_LIMIT),
storage_deposit_limit.unwrap_or(DEPOSIT_LIMIT),
data,
DebugInfo::Skip,
CollectEvents::Skip,
)));
}
VerifyCall(expectation) => {
if let Some(res) = res.take() {
expectation.verify(res);
} else {
panic!("No call to verify");
}
}
VerifyBalance { origin, expected } => {
assert_eq!(Balances::free_balance(&origin), expected);
}
VerifyStorage {
contract,
key,
expected,
} => {
let Ok(storage) = Contracts::get_storage(contract, key) else {
panic!("Error reading storage");
};
assert_eq!(storage, expected);
}
if is_reading {
if line.starts_with(SPEC_MARKER_END) {
match serde_json::from_str::<Specs>(&json_string) {
Ok(mut spec) => {
spec.replace_empty_code(contract_name, path);
specs.push(spec);
}
Err(e) => panic!("invalid spec JSON: {e}"),
}
is_reading = false;
json_string.clear();
continue;
}
});
json_string.push_str(line)
}
}
assert!(!specs.is_empty(), "source does not contain any test spec");
specs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::*;
#[test]
fn instantiate_works() {
use SpecsAction::*;
run_test(Specs {
use specs::SpecsAction::*;
let specs = Specs {
differential: false,
balances: vec![(ALICE, 1_000_000_000)],
actions: vec![Instantiate {
origin: ALICE,
origin: TestAccountId::Alice,
value: 0,
gas_limit: Some(GAS_LIMIT),
storage_deposit_limit: Some(DEPOSIT_LIMIT),
@@ -362,12 +318,13 @@ mod tests {
data: vec![],
salt: vec![],
}],
})
};
specs.run();
}
#[test]
fn instantiate_with_json() {
let specs = serde_json::from_str::<Specs>(
serde_json::from_str::<Specs>(
r#"
{
"balances": [
@@ -376,7 +333,7 @@ mod tests {
"actions": [
{
"Instantiate": {
"origin": "5C62Ck4UrFPiBtoCmeSrgF7x9yv9mn38446dhCpsi2mLHiFT",
"origin": "Alice",
"value": 0,
"code": {
"Path": "fixtures/Baseline.pvm"
@@ -387,7 +344,7 @@ mod tests {
}
"#,
)
.unwrap();
run_test(specs);
.unwrap()
.run();
}
}
+428
View File
@@ -0,0 +1,428 @@
use std::time::Instant;
use revive_differential::{Evm, EvmLog};
use serde::{Deserialize, Serialize};
use crate::*;
use alloy_primitives::Address;
use revive_solidity::test_utils::*;
/// An action to perform in a contract test
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SpecsAction {
/// Instantiate a contract
Instantiate {
#[serde(default)]
origin: TestAccountId,
#[serde(default)]
value: Balance,
#[serde(default)]
gas_limit: Option<Weight>,
#[serde(default)]
storage_deposit_limit: Option<Balance>,
#[serde(default)]
code: Code,
#[serde(default, with = "hex::serde")]
data: Vec<u8>,
#[serde(default, with = "hex::serde")]
salt: Vec<u8>,
},
/// Call a contract
Call {
#[serde(default)]
origin: TestAccountId,
dest: TestAccountId,
#[serde(default)]
value: Balance,
#[serde(default)]
gas_limit: Option<Weight>,
#[serde(default)]
storage_deposit_limit: Option<Balance>,
#[serde(default, with = "hex::serde")]
data: Vec<u8>,
},
/// Verify the result of the last call, omitting this will simply ensure the last call was successful
VerifyCall(VerifyCallExpectation),
/// Verify the balance of an account
VerifyBalance {
origin: TestAccountId,
expected: Balance,
},
/// Verify the storage of a contract
VerifyStorage {
contract: TestAccountId,
#[serde(with = "hex::serde")]
key: Vec<u8>,
#[serde(default, with = "hex::serde")]
expected: Vec<u8>,
},
}
impl SpecsAction {
/// Derive verification actions from the EVM output log
pub fn derive_verification(
log: &EvmLog,
address_evm: Address,
account_pvm: TestAccountId,
) -> Vec<Self> {
let account = log
.state_dump
.accounts
.get(&address_evm)
.unwrap_or_else(|| panic!("account {address_evm} not in state dump"));
let mut actions = vec![
Self::VerifyCall(VerifyCallExpectation {
gas_consumed: None,
success: log.output.run_success(),
output: log.output.output.clone().into(),
}),
Self::VerifyBalance {
origin: account_pvm.clone(),
expected: account
.balance
.try_into()
.expect("balance should fit into u128"),
},
];
let Some(storage) = &account.storage else {
return actions;
};
for (key, expected) in storage {
let mut key = key.to_vec();
key.reverse();
let mut expected = expected.to_vec();
expected.reverse();
actions.push(Self::VerifyStorage {
contract: account_pvm.clone(),
key,
expected,
});
}
actions
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub enum TestAccountId {
/// The ALICE account
#[default]
Alice,
/// The BOB account
Bob,
/// The CHARLIE account
Charlie,
/// AccountID that was created during the nth call in this run.
Instantiated(usize),
/// Arbitrary AccountID
AccountId(AccountId),
}
impl TestAccountId {
fn to_account_id(&self, results: &[CallResult]) -> AccountId {
match self {
TestAccountId::Alice => ALICE,
TestAccountId::Bob => BOB,
TestAccountId::Charlie => CHARLIE,
TestAccountId::AccountId(account_id) => account_id.clone(),
TestAccountId::Instantiated(n) => match results
.get(*n)
.expect("should provide valid index into call results")
{
CallResult::Exec { .. } => panic!("call #{n} should be an instantiation"),
CallResult::Instantiate { result, .. } => result
.result
.as_ref()
.expect("call #{n} reverted")
.account_id
.clone(),
},
}
}
}
/// Specs for a contract test
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Specs {
/// Interpret EVM bytecode and assert output, storage and events
#[serde(default)]
pub differential: bool,
/// List of endowments at genesis
pub balances: Vec<(AccountId, Balance)>,
/// List of actions to perform
pub actions: Vec<SpecsAction>,
}
impl Default for Specs {
fn default() -> Self {
Self {
differential: false,
balances: vec![(ALICE, 1_000_000_000)],
actions: Default::default(),
}
}
}
impl Specs {
/// Get the list of actions to perform
/// A default [`SpecAction::VerifyCall`] is injected after each Instantiate or Call action when
/// missing and not in differential mode
pub fn actions(&self) -> Vec<SpecsAction> {
self.actions
.iter()
.enumerate()
.flat_map(|(index, item)| {
let next_item = self.actions.get(index + 1);
if matches!(
item,
SpecsAction::Instantiate { .. } | SpecsAction::Call { .. }
) && !matches!(next_item, Some(SpecsAction::VerifyCall { .. }))
&& !self.differential
{
return vec![
item.clone(),
SpecsAction::VerifyCall(VerifyCallExpectation::default()),
];
}
vec![item.clone()]
})
.collect()
}
/// Helper to allow not specifying the code bytes or path directly in the runner.json
/// - Replace `Code::Bytes(bytes)` if `bytes` are empty: read `contract_file`
/// - Replace `Code::Solidity{ path, ..}` if `path` is not provided: replace `path` with `contract_file`
pub fn replace_empty_code(&mut self, contract_name: &str, contract_path: &str) {
for action in self.actions.iter_mut() {
let SpecsAction::Instantiate { code, .. } = action else {
continue;
};
match code {
Code::Bytes(bytes) if bytes.is_empty() => {
let contract_source = match std::fs::read_to_string(contract_path) {
Err(err) => panic!("unable to read {contract_path}: {err}"),
Ok(solidity) => solidity,
};
*bytes = compile_blob(contract_name, &contract_source)
}
Code::Solidity { path, .. } if path.is_none() => *path = Some(contract_path.into()),
_ => continue,
}
}
}
/// Run a contract test
/// The test takes a [`Specs`] and executes the actions in order
pub fn run(self) -> Vec<CallResult> {
if self.differential {
self.run_on_evm()
} else {
self
}
.run_on_pallet()
}
fn run_on_evm(self) -> Self {
let mut derived_specs = Self {
actions: vec![],
..self
};
let mut evm = Evm::default();
let mut deployed_accounts = vec![];
for action in self.actions {
derived_specs.actions.push(action.clone());
use specs::SpecsAction::*;
match action {
Instantiate {
origin,
value,
gas_limit,
storage_deposit_limit,
code,
data,
salt,
} => {
let Code::Solidity {
path: Some(path),
solc_optimizer,
pipeline,
contract,
} = code
else {
panic!("the differential runner requires Code::Solidity source");
};
assert_ne!(solc_optimizer, Some(false), "solc_optimizer must be enabled in differntial mode");
assert_ne!(pipeline, Some(revive_solidity::SolcPipeline::EVMLA), "yul pipeline must be enabled in differntial mode");
assert!(storage_deposit_limit.is_none(), "storage deposit limit is not supported in differential mode");
assert!(salt.is_empty(), "salt is not supported in differential mode");
assert_eq!(origin, TestAccountId::default(), "configuring the origin is not supported in differential mode");
let deploy_code = match std::fs::read_to_string(&path) {
Ok(solidity_source) => compile_evm_deploy_code(&contract, &solidity_source),
Err(err) => panic!(
"failed to read solidity source\n . path: '{}'\n . error: {:?}",
path.display(),
err
),
};
let deploy_code = hex::encode(deploy_code);
let mut vm = evm.code_blob(deploy_code.as_bytes().to_vec()).sender(Address::default()).deploy(true);
if !data.is_empty() {
vm = vm.input(data.into());
}
if value > 0 {
vm = vm.value(value);
}
if let Some(gas) = gas_limit {
vm = vm.gas(gas.ref_time());
}
let mut log = vm.run();
log.output.output = Default::default(); // PVM will not have constructor output
let deployed_account = log.account_deployed.expect("no account was created");
let account_pvm = TestAccountId::Instantiated(deployed_accounts.len());
deployed_accounts.push(deployed_account);
derived_specs.actions.append(&mut SpecsAction::derive_verification(&log, deployed_account, account_pvm));
evm = Evm::from_genesis(log.state_dump.into());
}
Call {
origin,
dest,
value,
gas_limit,
storage_deposit_limit,
data,
} => {
assert_eq!(origin, TestAccountId::default(), "configuring the origin is not supported in differential mode");
assert!(storage_deposit_limit.is_none(), "storage deposit limit is not supported in differential mode");
let TestAccountId::Instantiated(n) = dest else {
panic!("the differential runner requires TestAccountId::Instantiated(n) as dest");
};
let address = deployed_accounts.get(n).unwrap_or_else(|| panic!("no account at index {n} "));
let mut vm = evm.receiver(*address).sender(Address::default());
if !data.is_empty() {
vm = vm.input(data.into());
}
if value > 0 {
vm = vm.value(value);
}
if let Some(gas) = gas_limit {
vm = vm.gas(gas.ref_time());
}
let log = vm.run();
derived_specs.actions.append(&mut SpecsAction::derive_verification(&log, *address, dest));
evm = Evm::from_genesis(log.state_dump.into());
}
_ => panic!("only instantiate and call action allowed in differential mode, got: {action:?}"),
}
}
derived_specs
}
fn run_on_pallet(self) -> Vec<CallResult> {
let mut results = vec![];
ExtBuilder::default()
.balance_genesis_config(self.balances.clone())
.build()
.execute_with(|| {
use specs::SpecsAction::*;
for action in self.actions() {
match action {
Instantiate {
origin,
value,
gas_limit,
storage_deposit_limit,
code,
data,
salt,
} => {
let origin = RuntimeOrigin::signed(origin.to_account_id(&results));
let time_start = Instant::now();
let result = Contracts::bare_instantiate(
origin,
value,
gas_limit.unwrap_or(GAS_LIMIT),
storage_deposit_limit.unwrap_or(DEPOSIT_LIMIT),
code.into(),
data,
salt,
DebugInfo::Skip,
CollectEvents::Skip,
);
results.push(CallResult::Instantiate {
result,
wall_time: time_start.elapsed(),
})
}
Call {
origin,
dest,
value,
gas_limit,
storage_deposit_limit,
data,
} => {
let origin = RuntimeOrigin::signed(origin.to_account_id(&results));
let dest = dest.to_account_id(&results);
let time_start = Instant::now();
let result = Contracts::bare_call(
origin,
dest,
value,
gas_limit.unwrap_or(GAS_LIMIT),
storage_deposit_limit.unwrap_or(DEPOSIT_LIMIT),
data,
DebugInfo::Skip,
CollectEvents::Skip,
);
results.push(CallResult::Exec {
result,
wall_time: time_start.elapsed(),
});
}
VerifyCall(expectation) => {
expectation.verify(results.last().expect("No call to verify"));
}
VerifyBalance { origin, expected } => {
let balance = Balances::usable_balance(origin.to_account_id(&results));
assert_eq!(balance, expected);
}
VerifyStorage {
contract,
key,
expected,
} => {
let Ok(storage) = Contracts::get_storage(
contract.to_account_id(&results),
key.clone(),
) else {
panic!("Error reading storage");
};
let Some(value) = storage else {
panic!("No value for storage key 0x{}", hex::encode(key));
};
assert_eq!(value, expected, "at key {}", hex::encode(&key));
}
}
}
});
results
}
}
pub trait SpecsRunner {
fn run_action(&mut self, spec: &mut Specs) -> Vec<CallResult>;
}