Skip to content

Commit

Permalink
feat(cast/storage): fetch address state if no slot is provided (#3335)
Browse files Browse the repository at this point in the history
* create separate args

* feat: storage, initial

* use tmp dir

* refactor: parsing, errors, cleanup

* feat: resolve proxy implementations

* chore: clippy

* chore: fmt

* chore: clippy

* fix: output selection

* feat: add warnings

* wip

* update Cargo.lock

* fix

* chore: clippy

* wip

* chore: update arg parsing, clean up debugging

* fix: rpc url

* feat: try recompile with newer solc

* add test

* feat: add initial storage fetcher

* fix test

* update TODOs

* other fixes

* ci: use etherscan api key in all tests

* Revert "ci: use etherscan api key in all tests"

This reverts commit 3d16d55cedaf86ee550f77d33a9f3635241fa9d4.

This was not the right fix as it triggered all integration tests to run.

* fix: add test to live ci

Co-authored-by: Georgios Konstantopoulos <[email protected]>
  • Loading branch information
DaniPopes and gakonst authored Dec 18, 2022
1 parent d9ef83b commit 18791df
Show file tree
Hide file tree
Showing 11 changed files with 377 additions and 62 deletions.
9 changes: 5 additions & 4 deletions .github/workflows/live-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ jobs:
TEST_PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY }}
steps:
- name: Checkout sources
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Install toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: Install nextest
uses: taiki-e/install-action@nextest
- uses: Swatinem/rust-cache@v1
with:
cache-on-failure: true

- name: cargo test
run: cargo test --package foundry-cli --test it -- verify::test_live_can_deploy_and_verify --exact --nocapture
- name: cargo nextest
run: cargo nextest run -p foundry-cli -E "!test(~fork) & test(~live)"
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions cast/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,35 @@ where
let res = self.provider.provider().request::<T, serde_json::Value>(method, params).await?;
Ok(serde_json::to_string(&res)?)
}

/// Returns the slot
///
/// # Example
///
/// ```no_run
/// use cast::Cast;
/// use ethers_providers::{Provider, Http};
/// use ethers_core::types::{Address, H256};
/// use std::{str::FromStr, convert::TryFrom};
///
/// # async fn foo() -> eyre::Result<()> {
/// let provider = Provider::<Http>::try_from("http://localhost:8545")?;
/// let cast = Cast::new(provider);
/// let addr = Address::from_str("0x00000000006c3852cbEf3e08E8dF289169EdE581")?;
/// let slot = H256::zero();
/// let storage = cast.storage(addr, slot, None).await?;
/// println!("{}", storage);
/// # Ok(())
/// # }
/// ```
pub async fn storage<T: Into<NameOrAddress> + Send + Sync>(
&self,
from: T,
slot: H256,
block: Option<BlockId>,
) -> Result<String> {
Ok(format!("{:?}", self.provider.get_storage_at(from, slot, block).await?))
}
}

pub struct InterfaceSource {
Expand Down
1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ dunce = "1.0.2"
glob = "0.3.0"
globset = "0.4.8"
path-slash = "0.2.0"
tempfile = "3.3.0"

# misc
eyre = "0.6"
Expand Down
8 changes: 1 addition & 7 deletions cli/src/cast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,7 @@ async fn main() -> eyre::Result<()> {
println!("{}", serde_json::to_string(&value)?);
}
Subcommands::Rpc(cmd) => cmd.run()?.await?,
Subcommands::Storage { address, slot, rpc_url, block } => {
let rpc_url = try_consume_config_rpc_url(rpc_url)?;

let provider = try_get_http_provider(rpc_url)?;
let value = provider.get_storage_at(address, slot, block).await?;
println!("{value:?}");
}
Subcommands::Storage(cmd) => cmd.run().await?,

// Calls & transactions
Subcommands::Call(cmd) => cmd.run().await?,
Expand Down
1 change: 1 addition & 0 deletions cli/src/cmd/cast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ pub mod interface;
pub mod rpc;
pub mod run;
pub mod send;
pub mod storage;
pub mod wallet;
256 changes: 256 additions & 0 deletions cli/src/cmd/cast/storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
use crate::{
cmd::forge::build,
opts::cast::{parse_block_id, parse_name_or_address, parse_slot},
utils::try_consume_config_rpc_url,
};
use cast::Cast;
use clap::Parser;
use comfy_table::Table;
use ethers::{
abi::ethabi::ethereum_types::BigEndianHash, etherscan::Client, prelude::*,
solc::artifacts::StorageLayout,
};
use eyre::{ContextCompat, Result};
use foundry_common::{
abi::find_source,
compile::{compile, etherscan_project, suppress_compile},
try_get_http_provider, RetryProvider,
};
use foundry_config::Config;
use futures::future::join_all;
use semver::Version;

/// The minimum Solc version for outputting storage layouts.
///
/// https://github.com/ethereum/solidity/blob/develop/Changelog.md#065-2020-04-06
const MIN_SOLC: Version = Version::new(0, 6, 5);

#[derive(Debug, Clone, Parser)]
pub struct StorageArgs {
// Storage
#[clap(help = "The contract address.", value_parser = parse_name_or_address, value_name = "ADDRESS")]
address: NameOrAddress,
#[clap(
help = "The storage slot number (hex or decimal)",
value_parser = parse_slot,
value_name = "SLOT"
)]
slot: Option<H256>,
#[clap(long, env = "ETH_RPC_URL", value_name = "URL")]
rpc_url: Option<String>,
#[clap(
long,
short = 'B',
help = "The block height you want to query at.",
long_help = "The block height you want to query at. Can also be the tags earliest, latest, or pending.",
value_parser = parse_block_id,
value_name = "BLOCK"
)]
block: Option<BlockId>,

// Etherscan
#[clap(long, short, env = "ETHERSCAN_API_KEY", help = "etherscan API key", value_name = "KEY")]
etherscan_api_key: Option<String>,
#[clap(
long,
visible_alias = "chain-id",
env = "CHAIN",
help = "The chain ID the contract is deployed to.",
default_value = "mainnet",
value_name = "CHAIN"
)]
chain: Chain,

// Forge
#[clap(flatten)]
build: build::CoreBuildArgs,
}

impl StorageArgs {
pub async fn run(self) -> Result<()> {
let Self { address, block, build, rpc_url, slot, chain, etherscan_api_key } = self;

let rpc_url = try_consume_config_rpc_url(rpc_url)?;
let provider = try_get_http_provider(rpc_url)?;

let address = match address {
NameOrAddress::Name(name) => provider.resolve_name(&name).await?,
NameOrAddress::Address(address) => address,
};

// Slot was provided, perform a simple RPC call
if let Some(slot) = slot {
let cast = Cast::new(provider);
println!("{}", cast.storage(address, slot, block).await?);
return Ok(())
}

// No slot was provided
// Get deployed bytecode at given address
let address_code = provider.get_code(address, block).await?;
if address_code.is_empty() {
eyre::bail!("Provided address has no deployed code and thus no storage");
}

// Check if we're in a forge project
let mut project = build.project()?;
if project.paths.has_input_files() {
// Find in artifacts and pretty print
add_storage_layout_output(&mut project);
let out = compile(&project, false, false)?;
let match_code = |artifact: &ConfigurableContractArtifact| -> Option<bool> {
let bytes =
artifact.deployed_bytecode.as_ref()?.bytecode.as_ref()?.object.as_bytes()?;
Some(bytes == &address_code)
};
let artifact =
out.artifacts().find(|(_, artifact)| match_code(artifact).unwrap_or_default());
if let Some((_, artifact)) = artifact {
return fetch_and_print_storage(provider, address, artifact, true).await
}
}

// Not a forge project or artifact not found
// Get code from Etherscan
println!("No matching artifacts found, fetching source code from Etherscan...");
let api_key = etherscan_api_key.or_else(|| {
let config = Config::load();
config.get_etherscan_api_key(Some(chain))
}).wrap_err("No Etherscan API Key is set. Consider using the ETHERSCAN_API_KEY env var, or setting the -e CLI argument or etherscan-api-key in foundry.toml")?;
let client = Client::new(chain, api_key)?;
let source = find_source(client, address).await?;
let metadata = source.items.first().unwrap();
if metadata.is_vyper() {
eyre::bail!("Contract at provided address is not a valid Solidity contract")
}

let version = metadata.compiler_version()?;
let auto_detect = version < MIN_SOLC;

// Create a new temp project
// TODO: Cache instead of using a temp directory: metadata from Etherscan won't change
let root = tempfile::tempdir()?;
let root_path = root.path();
let mut project = etherscan_project(metadata, root_path)?;
add_storage_layout_output(&mut project);
project.auto_detect = auto_detect;

// Compile
let mut out = suppress_compile(&project)?;
let artifact = {
let (_, mut artifact) = out
.artifacts()
.find(|(name, _)| name == &metadata.contract_name)
.ok_or_else(|| eyre::eyre!("Could not find artifact"))?;

if is_storage_layout_empty(&artifact.storage_layout) && auto_detect {
// try recompiling with the minimum version
println!("The requested contract was compiled with {version} while the minimum version for storage layouts is {MIN_SOLC} and as a result the output may be empty.");
let solc = Solc::find_or_install_svm_version(MIN_SOLC.to_string())?;
project.solc = solc;
project.auto_detect = false;
if let Ok(output) = suppress_compile(&project) {
out = output;
let (_, new_artifact) = out
.artifacts()
.find(|(name, _)| name == &metadata.contract_name)
.ok_or_else(|| eyre::eyre!("Could not find artifact"))?;
artifact = new_artifact;
}
}

artifact
};

// Clear temp directory
root.close()?;

fetch_and_print_storage(provider, address, artifact, true).await
}
}

async fn fetch_and_print_storage(
provider: RetryProvider,
address: Address,
artifact: &ConfigurableContractArtifact,
pretty: bool,
) -> Result<()> {
if is_storage_layout_empty(&artifact.storage_layout) {
println!("Storage layout is empty.");
Ok(())
} else {
let mut layout = artifact.storage_layout.as_ref().unwrap().clone();
fetch_storage_values(provider, address, &mut layout).await?;
print_storage(layout, pretty)
}
}

/// Overrides the `value` field in [StorageLayout] with the slot's value to avoid creating new data
/// structures.
async fn fetch_storage_values(
provider: RetryProvider,
address: Address,
layout: &mut StorageLayout,
) -> Result<()> {
// TODO: Batch request; handle array values;
let futures: Vec<_> = layout
.storage
.iter()
.map(|slot| {
let slot_h256 = H256::from_uint(&slot.slot.parse::<U256>()?);
Ok(provider.get_storage_at(address, slot_h256, None))
})
.collect::<Result<_>>()?;

for (value, slot) in join_all(futures).await.into_iter().zip(layout.storage.iter()) {
let value = value?.into_uint();
let t = layout.types.get_mut(&slot.storage_type).expect("Bad storage");
// TODO: Better format values according to their Solidity type
t.value = Some(format!("{value:?}"));
}

Ok(())
}

fn print_storage(layout: StorageLayout, pretty: bool) -> Result<()> {
if !pretty {
println!("{}", serde_json::to_string_pretty(&serde_json::to_value(layout)?)?);
return Ok(())
}

let mut table = Table::new();
table.set_header(vec!["Name", "Type", "Slot", "Offset", "Bytes", "Value", "Contract"]);

for slot in layout.storage {
let storage_type = layout.types.get(&slot.storage_type);
table.add_row(vec![
slot.label,
storage_type.as_ref().map_or("?".to_string(), |t| t.label.clone()),
slot.slot,
slot.offset.to_string(),
storage_type.as_ref().map_or("?".to_string(), |t| t.number_of_bytes.clone()),
storage_type
.as_ref()
.map_or("?".to_string(), |t| t.value.clone().unwrap_or_else(|| "0".to_string())),
slot.contract,
]);
}

println!("{table}");

Ok(())
}

fn add_storage_layout_output(project: &mut Project) {
project.artifacts.additional_values.storage_layout = true;
let output_selection = project.artifacts.output_selection();
project.solc_config.settings.push_all(output_selection);
}

fn is_storage_layout_empty(storage_layout: &Option<StorageLayout>) -> bool {
if let Some(ref s) = storage_layout {
s.storage.is_empty()
} else {
true
}
}
Loading

0 comments on commit 18791df

Please sign in to comment.