use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::ExitCode,
};
use anyhow::{Context, Result, bail};
use clap::Parser;
use miden_protocol::{
account::AccountId,
address::{Address, AddressId, NetworkId},
note::NoteId,
};
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Parser, Debug)]
#[command(
name = "miden-verify",
version,
about = "Verify Miden accounts & notes",
long_about = None
)]
struct Args {
#[arg(value_name = "RESOURCE_ID")]
resource_id: String,
#[arg(long, default_value = "mtst", value_name = "NETWORK_ID")]
network_id: String,
#[arg(long, default_value = ".", value_name = "PROJECT_PATH")]
project_path: PathBuf,
#[arg(long, default_value = ".", value_name = "ENTRYPOINT")]
entrypoint: String,
#[arg(
long,
default_value = "https://miden-source-code-verification-api-registry.walnut.dev",
value_name = "VERIFIER_URL"
)]
verifier_url: String,
}
const SOURCE: &str = "miden-verify";
#[derive(Debug, Serialize)]
struct VerifyAccountRequestBody {
#[serde(rename = "accountId")]
account_id: String,
files: BTreeMap<String, String>,
entrypoint: String,
source: String,
}
#[derive(Debug, Serialize)]
struct VerifyNoteRequestBody {
#[serde(rename = "noteId")]
note_id: String,
files: BTreeMap<String, String>,
entrypoint: String,
source: String,
}
#[derive(Debug, Deserialize)]
struct VerifyResponse {
verified: bool,
}
enum Resource {
Account {
network_id: Option<NetworkId>,
account_id: AccountId,
},
Note(NoteId),
}
fn parse_resource_id(resource_id: &str) -> Result<Resource> {
if let Ok((account_id, network_id)) = AccountId::parse(resource_id) {
return Ok(Resource::Account {
network_id,
account_id,
});
}
if let Ok((network_id, address)) = Address::decode(resource_id) {
let AddressId::AccountId(account_id) = address.id() else {
bail!("address '{}' does not contain an account ID", resource_id);
};
return Ok(Resource::Account {
network_id: Some(network_id),
account_id,
});
}
if let Ok(note_id) = NoteId::try_from_hex(resource_id) {
return Ok(Resource::Note(note_id));
}
bail!("'{}' is not a valid account address, account ID, or note ID", resource_id)
}
fn relative_key(rel: &Path) -> Option<String> {
let components =
rel.components().map(|c| c.as_os_str().to_str()).collect::<Option<Vec<_>>>()?;
Some(components.join("/"))
}
fn is_included(rel: &Path) -> bool {
let components = rel.components().filter_map(|c| c.as_os_str().to_str()).collect::<Vec<_>>();
let Some((file_name, parents)) = components.split_last() else {
return false;
};
if parents.contains(&"src") {
return true;
}
if matches!(
*file_name,
"Cargo.toml" | "Cargo.lock" | "build.rs" | "miden-project.toml" | "rust-toolchain.toml"
) {
return true;
}
if *file_name == "config.toml" && parents.last() == Some(&".cargo") {
return true;
}
false
}
fn collect_files(dir: &Path, base: &Path, files: &mut BTreeMap<String, String>) -> Result<()> {
let entries =
fs::read_dir(dir).with_context(|| format!("failed to read directory {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let name = entry.file_name();
let name = name.to_string_lossy();
if file_type.is_dir() {
if name == "target" || (name.starts_with('.') && name != ".cargo") {
continue;
}
collect_files(&path, base, files)?;
} else if file_type.is_file() {
let rel = path.strip_prefix(base).expect("walked path must be under base");
if is_included(rel) {
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
if let Some(key) = relative_key(rel) {
files.insert(key, content);
}
}
}
}
Ok(())
}
fn build_files_map(project_dir: &Path) -> Result<BTreeMap<String, String>> {
let mut files = BTreeMap::new();
collect_files(project_dir, project_dir, &mut files)?;
Ok(files)
}
async fn post_verify<B: Serialize + ?Sized>(client: &Client, url: &str, body: &B) -> Result<bool> {
let response = client
.post(url)
.json(body)
.send()
.await
.context("failed to send verification request")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
bail!("verifier returned {}: {}", status, text);
}
let VerifyResponse { verified } =
response.json().await.context("failed to parse verifier response")?;
Ok(verified)
}
async fn verify_account_component(
client: &Client,
network_id: &NetworkId,
account_id: &AccountId,
project_dir: &Path,
entrypoint: &str,
verifier_url: &str,
) -> Result<bool> {
println!(
"Verifying account {} on network {}, project: {}, entrypoint: {}",
account_id,
network_id,
project_dir.display(),
entrypoint
);
let body = VerifyAccountRequestBody {
account_id: account_id.to_hex(),
files: build_files_map(project_dir)?,
entrypoint: entrypoint.to_string(),
source: SOURCE.to_string(),
};
let url = format!("{}/v1/{}/verified-accounts", verifier_url, network_id.as_str());
post_verify(client, &url, &body).await
}
async fn verify_note(
client: &Client,
network_id: &NetworkId,
note_id: &NoteId,
project_dir: &Path,
entrypoint: &str,
verifier_url: &str,
) -> Result<bool> {
println!(
"Verifying note {} on network {}, project: {}, entrypoint: {}",
note_id,
network_id,
project_dir.display(),
entrypoint
);
let body = VerifyNoteRequestBody {
note_id: note_id.to_hex(),
files: build_files_map(project_dir)?,
entrypoint: entrypoint.to_string(),
source: SOURCE.to_string(),
};
let url = format!("{}/v1/{}/verified-notes", verifier_url, network_id.as_str());
post_verify(client, &url, &body).await
}
#[tokio::main]
async fn main() -> Result<ExitCode> {
let args = Args::parse();
let fallback_network_id = NetworkId::new(&args.network_id).context("invalid --network-id")?;
let project_dir = args.project_path.as_path();
if !project_dir.is_dir() {
bail!("'{}' is not a directory", project_dir.display());
}
let client = Client::new();
let (verified, kind) = match parse_resource_id(&args.resource_id)? {
Resource::Account {
network_id,
account_id,
} => {
let network_id = network_id.unwrap_or(fallback_network_id);
let verified = verify_account_component(
&client,
&network_id,
&account_id,
project_dir,
&args.entrypoint,
&args.verifier_url,
)
.await?;
(verified, "Account component")
}
Resource::Note(note_id) => {
let verified = verify_note(
&client,
&fallback_network_id,
¬e_id,
project_dir,
&args.entrypoint,
&args.verifier_url,
)
.await?;
(verified, "Note script")
}
};
if verified {
println!("{} successfully verified", kind);
Ok(ExitCode::SUCCESS)
} else {
eprintln!("{} could not be verified", kind);
Ok(ExitCode::FAILURE)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn template_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("project-template")
}
struct Fixture(PathBuf);
impl Fixture {
fn new(name: &str, files: &[(&str, &str)]) -> Self {
let root =
std::env::temp_dir().join(format!("miden-verify-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (rel, content) in files {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
Self(root)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn single_package_root() {
let dir = template_dir().join("counter-contract");
let files = build_files_map(&dir).expect("build_files_map");
let keys: Vec<&str> = files.keys().map(String::as_str).collect();
assert_eq!(
keys,
vec![
".cargo/config.toml",
"Cargo.lock",
"Cargo.toml",
"build.rs",
"miden-project.toml",
"rust-toolchain.toml",
"src/lib.rs"
],
"unexpected file set for single-package project_path"
);
assert!(!files.contains_key(".DS_Store"));
let expected = fs::read_to_string(dir.join("src/lib.rs")).unwrap();
assert_eq!(files["src/lib.rs"], expected);
let expected = fs::read_to_string(dir.join("build.rs")).unwrap();
assert_eq!(files["build.rs"], expected);
assert!(!files["Cargo.toml"].is_empty());
}
#[test]
fn multi_package_root() {
let dir = template_dir();
let files = build_files_map(&dir).expect("build_files_map");
for pkg in ["counter-contract", "counter-note"] {
for suffix in [
"Cargo.toml",
"Cargo.lock",
"build.rs",
"miden-project.toml",
"rust-toolchain.toml",
".cargo/config.toml",
"src/lib.rs",
] {
let key = format!("{pkg}/{suffix}");
assert!(files.contains_key(&key), "missing expected key {key}");
}
}
}
#[test]
fn includes_lockfile_and_excludes_artifacts_and_hidden_files() {
let fixture = Fixture::new(
"collect",
&[
("Cargo.toml", "[package]"),
("Cargo.lock", "version = 4"),
("build.rs", "fn main() {}"),
("miden-project.toml", "[package]"),
("rust-toolchain.toml", "[toolchain]"),
(".cargo/config.toml", "[build]"),
("src/lib.rs", "// lib"),
("src/nested/mod.rs", "// nested"),
("README.md", "# readme"),
(".DS_Store", ""),
("target/package/pkg-0.1.0/Cargo.toml", "[package]"),
("target/package/pkg-0.1.0/Cargo.lock", "version = 4"),
("target/package/pkg-0.1.0/src/lib.rs", "// lib"),
],
);
let files = build_files_map(&fixture.0).expect("build_files_map");
let keys: Vec<&str> = files.keys().map(String::as_str).collect();
assert_eq!(
keys,
vec![
".cargo/config.toml",
"Cargo.lock",
"Cargo.toml",
"build.rs",
"miden-project.toml",
"rust-toolchain.toml",
"src/lib.rs",
"src/nested/mod.rs",
],
"unexpected file set"
);
assert_eq!(files["Cargo.lock"], "version = 4");
}
fn verified_or_already_verified(result: Result<bool>, already_verified: &str) -> bool {
match result {
Ok(verified) => verified,
Err(err) if err.to_string().contains(already_verified) => true,
Err(err) => panic!("verification request: {err:#}"),
}
}
#[tokio::test]
#[ignore = "requires a local verifier running at http://localhost:8081"]
async fn verifies_account_against_local_verifier() {
let project_dir = template_dir().join("counter-contract");
let network_id = NetworkId::new("mtst").expect("network id");
let Resource::Account { account_id, .. } =
parse_resource_id("0x858c680a7a66d2916230cc8c2a6c98").expect("parse resource id")
else {
panic!("expected an account resource");
};
let result = verify_account_component(
&Client::new(),
&network_id,
&account_id,
&project_dir,
".",
"http://localhost:8081",
)
.await;
let verified = verified_or_already_verified(result, "account component already verified");
assert!(verified, "account should be verified by the local verifier");
}
#[tokio::test]
#[ignore = "requires a local verifier running at http://localhost:8081"]
async fn verifies_note_against_local_verifier() {
let project_dir = template_dir();
let network_id = NetworkId::new("mtst").expect("network id");
let Resource::Note(note_id) =
parse_resource_id("0x5a1fdb8754d741e53246d24e437eff26029fe4b7be66f7fa9a1a66ed1ba787a2")
.expect("parse resource id")
else {
panic!("expected a note resource");
};
let result = verify_note(
&Client::new(),
&network_id,
¬e_id,
&project_dir,
"counter-note",
"http://localhost:8081",
)
.await;
let verified = verified_or_already_verified(result, "note already verified");
assert!(verified, "note should be verified by the local verifier");
}
}