use std::collections::{BTreeMap, BTreeSet};
use colored::Colorize;
use error_stack::{Report, Result, ResultExt};
use crate::batbelt::evm::metadata::bat_metadata::{ContractMetadata, EvmBatMetadata};
use crate::batbelt::evm::miro::auto_deploy::matches_ignore;
use crate::batbelt::evm::miro::EvmMiroError;
use crate::batbelt::miro::client::MiroClient;
pub struct MapOptions {
pub dry_run: bool,
pub with_boundaries: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Relation {
Calls,
WiresAtConstruction,
Unresolved,
Boundary,
}
impl Relation {
fn label(self) -> &'static str {
match self {
Self::Calls => "calls",
Self::WiresAtConstruction => "wires at construction",
Self::Unresolved => "unresolved binding",
Self::Boundary => "leaves the code",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Edge {
pub from: String,
pub to: String,
pub relation: Relation,
}
pub async fn run(options: MapOptions) -> Result<(), EvmMiroError> {
let started = std::time::Instant::now();
let mut mark = |what: &str, since: &mut std::time::Instant| {
println!(" {:>6.1}s {what}", since.elapsed().as_secs_f64());
*since = std::time::Instant::now();
};
let mut phase = started;
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
mark("reading the metadata", &mut phase);
if let Some(count) = predates_vendored(&metadata) {
return Err(Report::new(EvmMiroError).attach_printable(format!(
"this scan predates the `vendored` flag: {count} contract(s) under lib/ are marked \
as in scope — run `bat-cli sonar` to rescan before drawing the map"
)));
}
let edges = relations(&metadata, options.with_boundaries);
mark("working out the relations", &mut phase);
if edges.is_empty() {
return Err(Report::new(EvmMiroError)
.attach_printable("no relations between contracts — run `bat-cli sonar` first"));
}
for line in report(&edges) {
println!("{line}");
}
let undeclared = undeclared_interfaces(&metadata);
if !undeclared.is_empty() {
println!();
println!(
"{} interface(s) of this repository nothing in scope declares to implement — \
treated as outside the code:",
undeclared.len()
);
for name in &undeclared {
println!(" {name:<28} if a contract here implements it: bat-cli resolve {name} <Contract>");
}
}
let untyped = untyped_receivers(&metadata);
if !untyped.is_empty() {
println!();
println!(
"{} call(s) whose receiver the scan could not type as a contract — not drawn:",
untyped.len()
);
for line in &untyped {
println!(" {line}");
}
}
if options.dry_run {
return Ok(());
}
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
mark("opening the Miro session", &mut phase);
let board = client.list_frames().await.change_context(EvmMiroError)?;
mark(&format!("reading the board ({} frames)", board.len()), &mut phase);
let obstacles: Vec<crate::batbelt::miro::layout::Rect> = board
.iter()
.map(|frame| crate::batbelt::miro::layout::Rect {
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
})
.collect();
let layout = layout_map(&edges);
mark("laying it out", &mut phase);
let (left, top) =
crate::batbelt::miro::layout::ColumnLayout::new().open_left(&obstacles, layout.frame_width);
let outcome = draw(&client, &edges, layout, left, top).await;
mark("drawing it", &mut phase);
println!(" {:>6.1}s in total", started.elapsed().as_secs_f64());
outcome
}
const NODE_HEIGHT: f64 = 320.0;
const NODE_FONT: usize = 120;
const NODE_CHAR: f64 = 70.0;
const NODE_PAD: f64 = 160.0;
const MAP_MARGIN: f64 = 600.0;
const EDGE_WIDTH: &str = "16";
fn node_width(name: &str) -> f64 {
name.chars().count() as f64 * NODE_CHAR + NODE_PAD * 2.0
}
fn stroke_of(relation: Relation) -> (&'static str, crate::batbelt::miro::client::ConnectorStroke) {
use crate::batbelt::miro::client::ConnectorStroke;
match relation {
Relation::Calls => ("#2d9bf0", ConnectorStroke::Solid),
Relation::WiresAtConstruction => ("#8fd14f", ConnectorStroke::Solid),
Relation::Unresolved => ("#f24726", ConnectorStroke::Dashed),
Relation::Boundary => ("#ff8c00", ConnectorStroke::Dotted),
}
}
fn layout_map(edges: &[Edge]) -> crate::batbelt::miro::layout::GraphLayout {
use crate::batbelt::miro::layout::{layout_graph, LayoutConfig, LayoutEdge, LayoutNode};
let mut names: Vec<String> = edges
.iter()
.flat_map(|edge| [edge.from.clone(), edge.to.clone()])
.collect();
names.sort();
names.dedup();
let nodes: Vec<LayoutNode> = names
.iter()
.map(|name| LayoutNode {
id: name.clone(),
width: node_width(name),
height: NODE_HEIGHT,
})
.collect();
let laid_edges: Vec<LayoutEdge> = edges
.iter()
.map(|edge| LayoutEdge {
from: edge.to.clone(),
to: edge.from.clone(),
from_line_fraction: 0.5,
})
.collect();
let depends_on_something: BTreeSet<&str> = edges.iter().map(|edge| edge.from.as_str()).collect();
let root = names
.iter()
.find(|name| !depends_on_something.contains(name.as_str()))
.cloned()
.unwrap_or_else(|| names[0].clone());
layout_graph(
&root,
&nodes,
&laid_edges,
LayoutConfig {
padding_x: MAP_MARGIN,
padding_y: MAP_MARGIN + (NODE_HEIGHT + 60.0) * 4.0,
..Default::default()
},
)
}
async fn draw(
client: &MiroClient,
edges: &[Edge],
layout: crate::batbelt::miro::layout::GraphLayout,
left: f64,
top: f64,
) -> Result<(), EvmMiroError> {
use crate::batbelt::miro::client::{ArrowEnd, ConnectorStyle, RelativeAnchor};
let frame_id = client
.create_frame(
"map: who talks to whom",
left + layout.frame_width / 2.0,
top + layout.frame_height / 2.0,
layout.frame_width,
layout.frame_height,
None,
)
.await
.change_context(EvmMiroError)?;
let mut legend = tokio::task::JoinSet::new();
for (index, relation) in [
Relation::Calls,
Relation::WiresAtConstruction,
Relation::Unresolved,
Relation::Boundary,
]
.into_iter()
.enumerate()
{
let (colour, _) = stroke_of(relation);
let text = relation.label();
let width = node_width(text);
let client = client.clone();
let frame_id = frame_id.clone();
let y = MAP_MARGIN / 2.0 + index as f64 * (NODE_HEIGHT + 60.0);
legend.spawn(async move {
client
.create_label(
text,
MAP_MARGIN / 2.0 + width / 2.0,
y,
width,
NODE_HEIGHT,
NODE_FONT,
colour,
Some(&frame_id),
)
.await
});
}
while let Some(joined) = legend.join_next().await {
joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
}
let mut placing = tokio::task::JoinSet::new();
for placed in &layout.nodes {
let client = client.clone();
let frame_id = frame_id.clone();
let name = placed.id.clone();
let (x, y) = (placed.x, placed.y);
placing.spawn(async move {
client
.create_label(
&name,
x,
y,
node_width(&name),
NODE_HEIGHT,
NODE_FONT,
"#ffffff",
Some(&frame_id),
)
.await
.map(|id| (name, id))
});
}
let mut boxes: BTreeMap<String, String> = BTreeMap::new();
while let Some(joined) = placing.join_next().await {
let (name, id) = joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
boxes.insert(name, id);
}
println!(" {} {} contract(s) drawn", "✓".green(), boxes.len());
let mut drawing = tokio::task::JoinSet::new();
for edge in edges {
let (Some(from), Some(to)) = (boxes.get(&edge.from), boxes.get(&edge.to)) else {
continue;
};
let (colour, stroke) = stroke_of(edge.relation);
let client = client.clone();
let (from, to) = (from.clone(), to.clone());
drawing.spawn(async move {
client
.create_connector(
&from,
RelativeAnchor::new(1.0, 0.5),
&to,
RelativeAnchor::new(0.0, 0.5),
ConnectorStyle {
stroke_color: colour.to_string(),
stroke_width: EDGE_WIDTH.to_string(),
stroke,
caption: None,
arrow: ArrowEnd::End,
},
)
.await
});
}
let mut drawn = 0usize;
while let Some(joined) = drawing.join_next().await {
joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
drawn += 1;
}
println!(" {} {drawn} relation(s) drawn", "✓".green());
println!(" {}", client.frame_url(&frame_id).blue());
Ok(())
}
pub fn relations(metadata: &EvmBatMetadata, with_boundaries: bool) -> Vec<Edge> {
let in_scope: BTreeSet<&str> = metadata
.contracts
.iter()
.filter(|contract| !contract.vendored)
.map(|contract| contract.name.as_str())
.collect();
let ignored = |name: &str, path: &str| {
metadata
.ignored_contracts
.iter()
.any(|pattern| matches_ignore(pattern, name, path))
};
let mut found: BTreeSet<Edge> = BTreeSet::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
if ignored(&contract.name, &contract.file_path) {
continue;
}
let mut record = |to: &str, relation: Relation| {
if to.is_empty() || to == contract.name {
return;
}
found.insert(Edge {
from: contract.name.clone(),
to: to.to_string(),
relation,
});
};
let aim = |type_name: &str, relation: Relation| -> Option<(String, Relation)> {
match classify_type(metadata, type_name) {
TypeTarget::Contract(target) => Some((target, relation)),
TypeTarget::Open(name) => Some((name, Relation::Unresolved)),
TypeTarget::Outside(name) if with_boundaries => Some((name, Relation::Boundary)),
TypeTarget::Outside(_) | TypeTarget::NotAContract => None,
}
};
for function in &contract.functions {
for call in &function.resolved_calls {
if in_scope.contains(call.contract.as_str()) {
record(&call.contract, Relation::Calls);
}
}
let typed = function
.unresolved_calls
.iter()
.map(|call| call.inferred_type.as_str())
.chain(
function
.unknown_external_calls
.iter()
.map(|call| call.inferred_type.as_str()),
);
for type_name in typed {
if let Some((to, relation)) = aim(type_name, Relation::Calls) {
record(&to, relation);
}
}
}
for wired in wired_at_construction(contract) {
if let Some((to, relation)) = aim(&wired, Relation::WiresAtConstruction) {
record(&to, relation);
}
}
}
let mut best: BTreeMap<(String, String), Relation> = BTreeMap::new();
for edge in found {
best.entry((edge.from.clone(), edge.to.clone()))
.and_modify(|existing| *existing = (*existing).min(edge.relation))
.or_insert(edge.relation);
}
best.into_iter()
.map(|((from, to), relation)| Edge { from, to, relation })
.collect()
}
fn wired_at_construction(contract: &ContractMetadata) -> Vec<String> {
let constructor_writes: BTreeSet<&str> = contract
.functions
.iter()
.filter(|function| function.is_constructor)
.flat_map(|function| function.storage_writes.iter())
.map(|write| write.split(['.', '[']).next().unwrap_or(write))
.collect();
contract
.state_variables
.iter()
.filter(|variable| {
variable.is_immutable || constructor_writes.contains(variable.name.as_str())
})
.map(|variable| variable.type_name.trim().to_string())
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TypeTarget {
Contract(String),
Open(String),
Outside(String),
NotAContract,
}
fn classify_type(metadata: &EvmBatMetadata, type_name: &str) -> TypeTarget {
use crate::batbelt::evm::types::EvmContractType;
let Some(name) = contract_type_name(type_name) else {
return TypeTarget::NotAContract;
};
let copies: Vec<&ContractMetadata> =
metadata.contracts.iter().filter(|c| c.name == name).collect();
if copies.is_empty() {
return TypeTarget::NotAContract;
}
let is_interface = copies
.iter()
.any(|c| c.contract_type == EvmContractType::Interface);
let in_scope = copies.iter().find(|c| !c.vendored);
let deployable_in_scope = in_scope
.is_some_and(|c| c.contract_type == EvmContractType::Contract);
if deployable_in_scope {
return TypeTarget::Contract(name.to_string());
}
if let Some(recorded) = metadata.resolutions.get(name) {
if metadata.contracts.iter().any(|c| !c.vendored && c.name == *recorded) {
return TypeTarget::Contract(recorded.clone());
}
}
let collapsible = is_interface
|| in_scope.is_some_and(|c| c.contract_type == EvmContractType::Abstract);
if !collapsible {
return TypeTarget::Outside(name.to_string());
}
let mut implementers = metadata
.contracts
.iter()
.filter(|c| !c.vendored && c.contract_type == EvmContractType::Contract)
.filter(|c| c.base_contracts.iter().any(|base| base == name));
match (implementers.next(), implementers.next()) {
(Some(only), None) => TypeTarget::Contract(only.name.clone()),
(Some(_), Some(_)) => TypeTarget::Open(name.to_string()),
(None, _) if is_interface => TypeTarget::Outside(name.to_string()),
(None, _) => TypeTarget::Open(name.to_string()),
}
}
fn contract_type_name(type_name: &str) -> Option<&str> {
let mut name = type_name.trim();
if name.is_empty() || name.starts_with("mapping(") {
return None;
}
while let Some(open) = name.rfind('[') {
if !name.ends_with(']') {
break;
}
name = name[..open].trim_end();
}
name.chars()
.next()
.filter(|first| first.is_ascii_uppercase())
.map(|_| name)
}
pub fn untyped_receivers(metadata: &EvmBatMetadata) -> Vec<String> {
let mut lines: BTreeSet<String> = BTreeSet::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
for function in &contract.functions {
let calls = function
.unresolved_calls
.iter()
.map(|call| (&call.receiver, &call.method, &call.inferred_type))
.chain(
function
.unknown_external_calls
.iter()
.map(|call| (&call.receiver, &call.method, &call.inferred_type)),
);
for (receiver, method, inferred_type) in calls {
if classify_type(metadata, inferred_type) == TypeTarget::NotAContract {
let shown = if inferred_type.is_empty() {
"untyped".to_string()
} else {
inferred_type.clone()
};
lines.insert(format!(
"{}.{}: {receiver}.{method} ({shown})",
contract.name, function.name
));
}
}
}
}
lines.into_iter().collect()
}
pub fn undeclared_interfaces(metadata: &EvmBatMetadata) -> Vec<String> {
use crate::batbelt::evm::types::EvmContractType;
let own_interfaces: BTreeSet<&str> = metadata
.contracts
.iter()
.filter(|c| !c.vendored && c.contract_type == EvmContractType::Interface)
.map(|c| c.name.as_str())
.collect();
let mut found: BTreeSet<String> = BTreeSet::new();
for contract in metadata.contracts.iter().filter(|c| !c.vendored) {
let referenced = contract
.functions
.iter()
.flat_map(|function| {
function
.unresolved_calls
.iter()
.map(|call| call.inferred_type.clone())
.chain(
function
.unknown_external_calls
.iter()
.map(|call| call.inferred_type.clone()),
)
})
.chain(wired_at_construction(contract));
for type_name in referenced {
if let TypeTarget::Outside(name) = classify_type(metadata, &type_name) {
if own_interfaces.contains(name.as_str()) {
found.insert(name);
}
}
}
}
found.into_iter().collect()
}
pub fn predates_vendored(metadata: &EvmBatMetadata) -> Option<usize> {
if metadata.contracts.iter().any(|contract| contract.vendored) {
return None;
}
let under_lib = metadata
.contracts
.iter()
.filter(|contract| {
contract
.file_path
.split('/')
.any(|segment| segment == "lib")
})
.count();
(under_lib > 0).then_some(under_lib)
}
pub fn report(edges: &[Edge]) -> Vec<String> {
let mut lines = vec![
format!(
"{} relation(s) between contracts — one per pair, whatever the number of calls",
edges.len()
),
String::new(),
];
let mut current = String::new();
for edge in edges {
if edge.from != current {
current = edge.from.clone();
lines.push(current.clone());
}
lines.push(format!(" → {:<28} {}", edge.to, edge.relation.label()));
}
let mut incoming: BTreeMap<&str, usize> = BTreeMap::new();
for edge in edges {
*incoming.entry(edge.to.as_str()).or_default() += 1;
}
let mut ranked: Vec<(&str, usize)> = incoming.into_iter().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
lines.push(String::new());
lines.push("reached by the most contracts:".to_string());
for (name, count) in ranked.iter().take(5) {
lines.push(format!(" {name:<28} {count} contract(s) talk to it"));
}
lines
}
#[cfg(test)]
mod map_test {
use super::{Edge, Relation};
#[test]
fn the_stronger_relation_wins_a_pair() {
assert!(Relation::Calls < Relation::WiresAtConstruction);
assert!(Relation::WiresAtConstruction < Relation::Unresolved);
}
#[test]
fn edges_group_by_their_source() {
let mut edges = vec![
Edge { from: "B".into(), to: "C".into(), relation: Relation::Calls },
Edge { from: "A".into(), to: "C".into(), relation: Relation::Calls },
Edge { from: "A".into(), to: "B".into(), relation: Relation::Calls },
];
edges.sort();
assert_eq!(edges[0].from, "A");
assert_eq!(edges[0].to, "B");
assert_eq!(edges[2].from, "B");
}
use super::{classify_type, predates_vendored, relations, undeclared_interfaces, TypeTarget};
use crate::batbelt::evm::metadata::bat_metadata::{
ContractMetadata, EvmBatMetadata, FunctionMetadata, UnresolvedCall,
};
use crate::batbelt::evm::types::{
EvmContractType, EvmFileItem, EvmFileItemKind, EvmMutability, EvmVisibility,
StorageVariable,
};
fn contract(
name: &str,
kind: EvmContractType,
bases: &[&str],
vendored: bool,
) -> ContractMetadata {
ContractMetadata {
metadata_id: name.to_string(),
name: name.to_string(),
using_libraries: Vec::new(),
file_path: format!("./{}/{name}.sol", if vendored { "lib/dep/src" } else { "src" }),
contract_type: kind,
base_contracts: bases.iter().map(|b| b.to_string()).collect(),
functions: Vec::new(),
state_variables: Vec::new(),
events: Vec::new(),
modifiers: Vec::new(),
line: 1,
vendored,
}
}
fn calling(inferred_type: &str) -> FunctionMetadata {
FunctionMetadata {
metadata_id: format!("f_{inferred_type}"),
name: "poke".to_string(),
contract_name: String::new(),
visibility: EvmVisibility::External,
mutability: EvmMutability::NonPayable,
modifiers: Vec::new(),
params: Vec::new(),
returns: Vec::new(),
line: 1,
end_line: 2,
is_constructor: false,
is_stub: false,
storage_writes: Vec::new(),
storage_write_sites: Vec::new(),
unresolved_calls: vec![UnresolvedCall {
receiver: "target".to_string(),
method: "poke".to_string(),
inferred_type: inferred_type.to_string(),
candidates: Vec::new(),
assigned_in: Vec::new(),
}],
unknown_external_calls: Vec::new(),
resolved_calls: Vec::new(),
}
}
fn immutable(type_name: &str) -> StorageVariable {
StorageVariable {
name: type_name.to_lowercase(),
type_name: type_name.to_string(),
visibility: EvmVisibility::Internal,
is_constant: false,
is_immutable: true,
line: 3,
}
}
fn pairs(edges: &[Edge]) -> Vec<(String, String, Relation)> {
edges
.iter()
.map(|edge| (edge.from.clone(), edge.to.clone(), edge.relation))
.collect()
}
#[test]
fn an_interface_extending_an_interface_does_not_block_the_collapse() {
let mut caller = contract("AscendCtoReceiver", EvmContractType::Contract, &[], false);
caller.functions.push(calling("IAscendLaunchpad"));
let metadata = EvmBatMetadata {
contracts: vec![
caller,
contract("AscendLaunchpad", EvmContractType::Contract, &["IAscendLaunchpad"], false),
contract("IAscendLaunchpad", EvmContractType::Interface, &[], false),
contract("IListingLaunchpad", EvmContractType::Interface, &["IAscendLaunchpad"], false),
],
..Default::default()
};
assert_eq!(
classify_type(&metadata, "IAscendLaunchpad"),
TypeTarget::Contract("AscendLaunchpad".to_string())
);
assert_eq!(
pairs(&relations(&metadata, true)),
vec![("AscendCtoReceiver".to_string(), "AscendLaunchpad".to_string(), Relation::Calls)]
);
}
#[test]
fn two_deployable_implementers_stay_an_open_question() {
let mut caller = contract("Router", EvmContractType::Contract, &[], false);
caller.functions.push(calling("IVault"));
let metadata = EvmBatMetadata {
contracts: vec![
caller,
contract("IVault", EvmContractType::Interface, &[], false),
contract("VaultA", EvmContractType::Contract, &["IVault"], false),
contract("VaultB", EvmContractType::Contract, &["IVault"], false),
],
..Default::default()
};
assert_eq!(classify_type(&metadata, "IVault"), TypeTarget::Open("IVault".to_string()));
assert_eq!(
pairs(&relations(&metadata, false)),
vec![("Router".to_string(), "IVault".to_string(), Relation::Unresolved)]
);
}
#[test]
fn a_struct_typed_immutable_is_not_a_relation() {
let mut vault = contract("AscendYieldVault", EvmContractType::Contract, &[], false);
vault.state_variables.extend([
immutable("Limits"),
immutable("CurveStep[]"),
immutable("mapping(Id => PendingUint192)"),
immutable("PriceFeed"),
]);
let metadata = EvmBatMetadata {
contracts: vec![vault, contract("PriceFeed", EvmContractType::Contract, &[], false)],
file_items: vec![EvmFileItem {
name: "Limits".to_string(),
kind: EvmFileItemKind::Struct,
file_path: "./src/AscendYieldVault.sol".to_string(),
line: 5,
end_line: 9,
vendored: false,
owner: "AscendYieldVault".to_string(),
}],
..Default::default()
};
assert_eq!(classify_type(&metadata, "Limits"), TypeTarget::NotAContract);
assert_eq!(classify_type(&metadata, "CurveStep[]"), TypeTarget::NotAContract);
assert_eq!(classify_type(&metadata, "mapping(Id => PendingUint192)"), TypeTarget::NotAContract);
assert_eq!(
pairs(&relations(&metadata, true)),
vec![(
"AscendYieldVault".to_string(),
"PriceFeed".to_string(),
Relation::WiresAtConstruction
)]
);
}
#[test]
fn a_vendored_contract_type_is_the_outside_not_its_in_scope_child() {
let mut token = contract("AscendCoreToken", EvmContractType::Contract, &["ERC20"], false);
token.state_variables.push(immutable("ERC20"));
let metadata = EvmBatMetadata {
contracts: vec![token, contract("ERC20", EvmContractType::Abstract, &["IERC20"], true)],
..Default::default()
};
assert_eq!(classify_type(&metadata, "ERC20"), TypeTarget::Outside("ERC20".to_string()));
assert_eq!(
pairs(&relations(&metadata, true)),
vec![("AscendCoreToken".to_string(), "ERC20".to_string(), Relation::Boundary)]
);
assert!(relations(&metadata, false).is_empty());
}
#[test]
fn an_in_scope_abstract_type_collapses_into_its_deployable_child() {
let metadata = EvmBatMetadata {
contracts: vec![
contract("BaseVault", EvmContractType::Abstract, &[], false),
contract("Vault", EvmContractType::Contract, &["BaseVault"], false),
],
..Default::default()
};
assert_eq!(classify_type(&metadata, "BaseVault"), TypeTarget::Contract("Vault".to_string()));
}
#[test]
fn an_own_interface_nothing_declares_is_named_as_a_question() {
let mut caller = contract("AscendCtoReceiver", EvmContractType::Contract, &[], false);
caller.functions.push(calling("IListingLaunchpad"));
let mut metadata = EvmBatMetadata {
contracts: vec![
caller,
contract("ListingDesk", EvmContractType::Contract, &["ReentrancyGuard"], false),
contract("IListingLaunchpad", EvmContractType::Interface, &[], false),
contract("IMirrorFactory", EvmContractType::Interface, &[], true),
],
..Default::default()
};
metadata.contracts[0].functions.push(calling("IMirrorFactory"));
assert_eq!(undeclared_interfaces(&metadata), vec!["IListingLaunchpad".to_string()]);
metadata
.resolutions
.insert("IListingLaunchpad".to_string(), "ListingDesk".to_string());
assert!(undeclared_interfaces(&metadata).is_empty());
assert!(pairs(&relations(&metadata, false)).contains(&(
"AscendCtoReceiver".to_string(),
"ListingDesk".to_string(),
Relation::Calls
)));
}
#[test]
fn a_scan_before_the_vendored_flag_is_refused() {
let mut stale = EvmBatMetadata {
contracts: vec![
contract("Vault", EvmContractType::Contract, &[], false),
contract("Test", EvmContractType::Abstract, &[], false),
],
..Default::default()
};
stale.contracts[1].file_path = "./lib/forge-std/src/Test.sol".to_string();
assert_eq!(predates_vendored(&stale), Some(1));
let mut current = stale.clone();
current.contracts[1].vendored = true;
assert_eq!(predates_vendored(¤t), None);
let bare = EvmBatMetadata {
contracts: vec![contract("Vault", EvmContractType::Contract, &[], false)],
..Default::default()
};
assert_eq!(predates_vendored(&bare), None);
}
}