From 85828bce22468cb0c327dc62978b9302f6ce66cd Mon Sep 17 00:00:00 2001 From: David Semakula Date: Tue, 11 Feb 2025 18:17:29 +0300 Subject: [PATCH] Generate Solidity compatible metadata (#1930) * Generate Solidity compatible metadata * Update changelog * Fix tests * Add test * Add support for structured primitive types * Use default constructor * Output copy * Add build info * Clarify NatSpec user docs * Update type mapping * Fix test * Refactor metadata artifacts representation * Fix test * Add more build info * Update Solidity metadata representation * Fix tests * Update type mapping * Notes on unit-only enums and doc comments for function params * ink dependency updates --- CHANGELOG.md | 1 + Cargo.lock | 109 ++++- crates/build/Cargo.toml | 4 + crates/build/README.md | 2 + crates/build/src/args.rs | 35 +- crates/build/src/crate_metadata.rs | 2 + crates/build/src/docker.rs | 77 +++- crates/build/src/lib.rs | 93 +++- crates/build/src/metadata.rs | 101 ++++- crates/build/src/solidity_metadata.rs | 291 +++++++++++++ crates/build/src/solidity_metadata/abi.rs | 401 ++++++++++++++++++ crates/build/src/solidity_metadata/natspec.rs | 264 ++++++++++++ crates/build/src/tests.rs | 190 ++++++++- .../build/templates/generate-metadata/main.rs | 2 +- crates/cargo-contract/src/cmd/build.rs | 6 + crates/cargo-contract/src/cmd/verify.rs | 5 +- crates/metadata/src/lib.rs | 2 +- 17 files changed, 1507 insertions(+), 78 deletions(-) create mode 100644 crates/build/src/solidity_metadata.rs create mode 100644 crates/build/src/solidity_metadata/abi.rs create mode 100644 crates/build/src/solidity_metadata/natspec.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f525954..fd9ab0100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased] ### Added +- Add option to generate Solidity compatible metadata (via `cargo contract build ---metadata `) - [1930](https://github.com/use-ink/cargo-contract/pull/1930) - Deny overflowing (and lossy) integer type cast operations - [#1895](https://github.com/use-ink/cargo-contract/pull/1895) ### Changed diff --git a/Cargo.lock b/Cargo.lock index c4cbaa6d0..371e4f85e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy-json-abi" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62cf1b25f5a50ca2d329b0b4aeb0a0dedeaf225ad3c5099d83b1a4c4616186e" +dependencies = [ + "alloy-primitives 0.8.20", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + [[package]] name = "alloy-primitives" version = "0.4.2" @@ -131,6 +143,33 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "alloy-primitives" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1360603efdfba91151e623f13a4f4d3dc4af4adc1cbd90bf37c81e84db4c77" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more 1.0.0", + "foldhash", + "hashbrown 0.15.2", + "indexmap 2.7.1", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand", + "ruint", + "rustc-hash 2.1.0", + "serde", + "sha3 0.10.8", + "tiny-keccak", +] + [[package]] name = "alloy-rlp" version = "0.3.11" @@ -170,13 +209,23 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "alloy-sol-type-parser" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a971129d242338d92009470a2f750d3b2630bc5da00a40a94d51f5d456b5712f" +dependencies = [ + "serde", + "winnow", +] + [[package]] name = "alloy-sol-types" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98d7107bed88e8f09f0ddcc3335622d87bfb6821f3e0c7473329fb1cfad5e015" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.4.2", "alloy-sol-macro", "const-hex", "serde", @@ -1756,6 +1805,9 @@ name = "bytes" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" +dependencies = [ + "serde", +] [[package]] name = "camino" @@ -2150,6 +2202,7 @@ dependencies = [ name = "contract-build" version = "6.0.0-alpha" dependencies = [ + "alloy-json-abi", "anyhow", "blake2", "bollard", @@ -2162,11 +2215,14 @@ dependencies = [ "heck 0.5.0", "hex", "impl-serde 0.5.0", + "ink_metadata", + "itertools 0.13.0", "parity-scale-codec", "polkavm-linker 0.18.0", "pretty_assertions", "regex", "rustc_version 0.4.1", + "scale-info", "semver 1.0.25", "serde", "serde_json", @@ -4473,6 +4529,7 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash", + "serde", ] [[package]] @@ -4504,6 +4561,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-conservative" @@ -4995,7 +5055,7 @@ checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" [[package]] name = "ink" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "alloy-rlp", "derive_more 1.0.0", @@ -5016,7 +5076,7 @@ dependencies = [ [[package]] name = "ink_allocator" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "cfg-if", ] @@ -5024,7 +5084,7 @@ dependencies = [ [[package]] name = "ink_codegen" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "blake2", "derive_more 1.0.0", @@ -5046,7 +5106,7 @@ dependencies = [ [[package]] name = "ink_engine" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "blake2", "derive_more 1.0.0", @@ -5061,7 +5121,7 @@ dependencies = [ [[package]] name = "ink_env" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "alloy-rlp", "blake2", @@ -5094,7 +5154,7 @@ dependencies = [ [[package]] name = "ink_ir" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "blake2", "either", @@ -5110,7 +5170,7 @@ dependencies = [ [[package]] name = "ink_macro" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "ink_codegen", "ink_ir", @@ -5125,7 +5185,7 @@ dependencies = [ [[package]] name = "ink_metadata" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "derive_more 1.0.0", "impl-serde 0.5.0", @@ -5141,7 +5201,7 @@ dependencies = [ [[package]] name = "ink_prelude" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "cfg-if", ] @@ -5149,7 +5209,7 @@ dependencies = [ [[package]] name = "ink_primitives" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "alloy-rlp", "cfg-if", @@ -5169,7 +5229,7 @@ dependencies = [ [[package]] name = "ink_storage" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "array-init", "cfg-if", @@ -5187,7 +5247,7 @@ dependencies = [ [[package]] name = "ink_storage_traits" version = "6.0.0-alpha" -source = "git+https://github.com/use-ink/ink?branch=master#bf546be877180ec7198a566baeee7844f7139fd0" +source = "git+https://github.com/use-ink/ink?branch=master#39157b3b63740d51f1337925d6f9cf6678a59ae3" dependencies = [ "ink_metadata", "ink_prelude", @@ -5472,6 +5532,16 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "keccak-asm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + [[package]] name = "keccak-hash" version = "0.11.0" @@ -9421,6 +9491,7 @@ dependencies = [ "libc", "rand_chacha", "rand_core", + "serde", ] [[package]] @@ -10689,6 +10760,16 @@ dependencies = [ "keccak 0.2.0-pre.0", ] +[[package]] +name = "sha3-asm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" +dependencies = [ + "cc", + "cfg-if", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -11088,7 +11169,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2e6a9d00e60e3744e6b6f0c21fea6694b9c6401ac40e41340a96e561dcf1935" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.4.2", "alloy-sol-types", "frame-benchmarking 38.0.0", "frame-support 38.2.0", diff --git a/crates/build/Cargo.toml b/crates/build/Cargo.toml index cdcb6772d..4369d9364 100644 --- a/crates/build/Cargo.toml +++ b/crates/build/Cargo.toml @@ -28,6 +28,7 @@ rustc_version = "0.4.1" scale = { package = "parity-scale-codec", version = "3.6.12", features = [ "derive", ] } +scale-info = "2.11.6" toml = "0.8.19" tracing = "0.1.41" semver = { version = "1.0.25", features = ["serde"] } @@ -42,10 +43,13 @@ tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.17" bollard = "0.18.1" crossterm = "0.28.1" +itertools = "0.13.0" +alloy-json-abi = "0.8.20" polkavm-linker = "0.18.0" contract-metadata = { version = "6.0.0-alpha", path = "../metadata" } +ink_metadata = { git = "https://github.com/use-ink/ink", branch = "master" } sha3 = "0.11.0-pre.4" [target.'cfg(unix)'.dependencies] diff --git a/crates/build/README.md b/crates/build/README.md index bcf1e65cb..b558083e8 100644 --- a/crates/build/README.md +++ b/crates/build/README.md @@ -12,6 +12,7 @@ use contract_build::{ BuildArtifacts, BuildMode, Features, + MetadataSpec, Network, OutputType, UnstableFlags, @@ -34,6 +35,7 @@ let args = contract_build::ExecuteArgs { output_type: OutputType::Json, skip_clippy_and_linting: false, image: ImageVariant::Default, + metadata_spec: MetadataSpec::Ink, }; contract_build::execute(args); diff --git a/crates/build/src/args.rs b/crates/build/src/args.rs index 42800bfa4..324620279 100644 --- a/crates/build/src/args.rs +++ b/crates/build/src/args.rs @@ -26,7 +26,7 @@ use std::{ path::Path, }; -#[derive(Default, Clone, Debug, Args)] +#[derive(Default, Clone, Copy, Debug, Args)] pub struct VerbosityFlags { /// No output printed to stdout #[clap(long)] @@ -272,3 +272,36 @@ impl Features { } } } + +/// Specification to use for contract metadata. +#[derive( + Debug, + Default, + Clone, + Copy, + PartialEq, + Eq, + clap::ValueEnum, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "lowercase")] +pub enum MetadataSpec { + /// ink! + #[clap(name = "ink")] + #[serde(rename = "ink!")] + #[default] + Ink, + /// Solidity + #[clap(name = "solidity")] + Solidity, +} + +impl fmt::Display for MetadataSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ink => write!(f, "ink"), + Self::Solidity => write!(f, "solidity"), + } + } +} diff --git a/crates/build/src/crate_metadata.rs b/crates/build/src/crate_metadata.rs index eaf67531b..4a70233b5 100644 --- a/crates/build/src/crate_metadata.rs +++ b/crates/build/src/crate_metadata.rs @@ -55,6 +55,7 @@ pub struct CrateMetadata { pub user: Option>, pub target_directory: PathBuf, pub target_file_path: PathBuf, + pub metadata_spec_path: PathBuf, } impl CrateMetadata { @@ -144,6 +145,7 @@ impl CrateMetadata { homepage, user, target_file_path: target_directory.join(".target").into(), + metadata_spec_path: target_directory.join(".metadata_spec").into(), target_directory: target_directory.into(), }; Ok(crate_metadata) diff --git a/crates/build/src/docker.rs b/crates/build/src/docker.rs index 23fc00def..dd5782457 100644 --- a/crates/build/src/docker.rs +++ b/crates/build/src/docker.rs @@ -220,21 +220,33 @@ fn update_build_result(host_folder: &Path, build_result: &mut BuildResult) -> Re // TODO: Clippy currently throws a false-positive here. The manual allow can be // removed after https://github.com/rust-lang/rust-clippy/pull/13609 has been released. #[allow(clippy::manual_inspect)] - build_result.metadata_result.as_mut().map(|m| { - m.dest_bundle = host_folder.join( - m.dest_bundle - .as_path() - .strip_prefix(MOUNT_DIR) - .expect("cannot strip prefix"), - ); - m.dest_metadata = host_folder.join( - m.dest_metadata - .as_path() - .strip_prefix(MOUNT_DIR) - .expect("cannot strip prefix"), - ); - m - }); + build_result + .metadata_result + .as_mut() + .map(|metadata_result| { + let to_host_path = |abs_path: &std::path::Path| { + host_folder.join( + abs_path + .strip_prefix(MOUNT_DIR) + .expect("cannot strip prefix"), + ) + }; + match metadata_result { + crate::MetadataArtifacts::Ink(ink_metadata_artifacts) => { + ink_metadata_artifacts.dest_metadata = + to_host_path(&ink_metadata_artifacts.dest_metadata); + ink_metadata_artifacts.dest_bundle = + to_host_path(&ink_metadata_artifacts.dest_bundle); + } + crate::MetadataArtifacts::Solidity(solidity_metadata_artifacts) => { + solidity_metadata_artifacts.dest_abi = + to_host_path(&solidity_metadata_artifacts.dest_abi); + solidity_metadata_artifacts.dest_metadata = + to_host_path(&solidity_metadata_artifacts.dest_metadata); + } + } + metadata_result + }); Ok(()) } @@ -246,12 +258,11 @@ async fn update_metadata( client: &Docker, ) -> Result<()> { if let Some(metadata_artifacts) = &build_result.metadata_result { - let mut metadata = ContractMetadata::load(&metadata_artifacts.dest_bundle)?; - let build_image = find_local_image(client, build_image.to_string()) .await? .context("Image summary does not exist")?; - // find alternative unique identifier of the image, otherwise grab the digest + // find alternative unique identifier of the image, otherwise grab the + // digest let image_tag = match build_image .repo_tags .iter() @@ -261,9 +272,33 @@ async fn update_metadata( None => build_image.id.clone(), }; - metadata.image = Some(image_tag); - - crate::metadata::write_metadata(metadata_artifacts, metadata, verbosity, true)?; + match metadata_artifacts { + crate::MetadataArtifacts::Ink(ink_metadata_artifacts) => { + let mut metadata = + ContractMetadata::load(&ink_metadata_artifacts.dest_bundle)?; + metadata.image = Some(image_tag); + + crate::metadata::write_metadata( + ink_metadata_artifacts, + metadata, + verbosity, + true, + )?; + } + crate::MetadataArtifacts::Solidity(solidity_metadata_artifacts) => { + let mut metadata = crate::solidity_metadata::load_metadata( + &solidity_metadata_artifacts.dest_metadata, + )?; + metadata.settings.ink.image = Some(image_tag); + + crate::metadata::write_solidity_metadata( + solidity_metadata_artifacts, + metadata, + verbosity, + true, + )?; + } + } } Ok(()) } diff --git a/crates/build/src/lib.rs b/crates/build/src/lib.rs index 082c40086..2987ff3f1 100644 --- a/crates/build/src/lib.rs +++ b/crates/build/src/lib.rs @@ -28,6 +28,7 @@ mod crate_metadata; mod docker; pub mod metadata; mod new; +mod solidity_metadata; #[cfg(test)] mod tests; pub mod util; @@ -35,13 +36,14 @@ mod validate_bytecode; mod workspace; #[deprecated(since = "2.0.2", note = "Use MetadataArtifacts instead")] -pub use self::metadata::MetadataArtifacts as MetadataResult; +pub use self::metadata::InkMetadataArtifacts as MetadataResult; pub use self::{ args::{ BuildArtifacts, BuildMode, Features, + MetadataSpec, Network, OutputType, Target, @@ -53,9 +55,11 @@ pub use self::{ crate_metadata::CrateMetadata, metadata::{ BuildInfo, + InkMetadataArtifacts, MetadataArtifacts, }, new::new_contract_project, + solidity_metadata::SolidityMetadataArtifacts, util::DEFAULT_KEY_COL_WIDTH, workspace::{ Lto, @@ -133,6 +137,7 @@ pub struct ExecuteArgs { pub output_type: OutputType, pub skip_clippy_and_linting: bool, pub image: ImageVariant, + pub metadata_spec: MetadataSpec, } /// Result of the build process. @@ -203,10 +208,18 @@ impl BuildResult { self.target_directory.display().to_string().bold(), ); if let Some(metadata_result) = self.metadata_result.as_ref() { - let bundle = format!( - " - {} (code + metadata)\n", - util::base_name(&metadata_result.dest_bundle).bold() - ); + let (dest, desc) = match metadata_result { + MetadataArtifacts::Ink(ink_metadata_artifacts) => { + (&ink_metadata_artifacts.dest_bundle, "code + metadata") + } + MetadataArtifacts::Solidity(solidity_metadata_artifacts) => { + ( + &solidity_metadata_artifacts.dest_metadata, + "Solidity compatible metadata", + ) + } + }; + let bundle = format!(" - {} ({})\n", util::base_name(dest).bold(), desc); out.push_str(&bundle); } if let Some(dest_binary) = self.dest_binary.as_ref() { @@ -217,9 +230,21 @@ impl BuildResult { out.push_str(&path); } if let Some(metadata_result) = self.metadata_result.as_ref() { + let (dest, desc) = match metadata_result { + MetadataArtifacts::Ink(ink_metadata_artifacts) => { + (&ink_metadata_artifacts.dest_metadata, "metadata") + } + MetadataArtifacts::Solidity(solidity_metadata_artifacts) => { + ( + &solidity_metadata_artifacts.dest_abi, + "Solidity compatible ABI", + ) + } + }; let metadata = format!( - " - {} (the contract's metadata)", - util::base_name(&metadata_result.dest_metadata).bold() + " - {} (the contract's {})", + util::base_name(dest).bold(), + desc ); out.push_str(&metadata); } @@ -680,6 +705,7 @@ pub fn execute(args: ExecuteArgs) -> Result { unstable_flags, extra_lints, output_type, + metadata_spec, .. } = &args; @@ -701,6 +727,8 @@ pub fn execute(args: ExecuteArgs) -> Result { let clean_metadata = || { fs::remove_file(crate_metadata.metadata_path()).ok(); fs::remove_file(crate_metadata.contract_bundle_path()).ok(); + fs::remove_file(solidity_metadata::abi_path(&crate_metadata)).ok(); + fs::remove_file(solidity_metadata::metadata_path(&crate_metadata)).ok(); }; let (opt_result, metadata_result, dest_binary) = match build_artifact { @@ -722,23 +750,46 @@ pub fn execute(args: ExecuteArgs) -> Result { clean_metadata(); })?; - let metadata_result = MetadataArtifacts { - dest_metadata: crate_metadata.metadata_path(), - dest_bundle: crate_metadata.contract_bundle_path(), + let metadata_artifacts = match metadata_spec { + MetadataSpec::Ink => { + MetadataArtifacts::Ink(InkMetadataArtifacts { + dest_metadata: crate_metadata.metadata_path(), + dest_bundle: crate_metadata.contract_bundle_path(), + }) + } + MetadataSpec::Solidity => { + MetadataArtifacts::Solidity(SolidityMetadataArtifacts { + dest_abi: solidity_metadata::abi_path(&crate_metadata), + dest_metadata: solidity_metadata::metadata_path(&crate_metadata), + }) + } }; - // skip metadata generation if contract unchanged and all metadata artifacts - // exist. + // skip metadata generation if contract is unchanged, metadata spec is + // unchanged, and all metadata artifacts exist. + let pre_metadata_spec = + fs::read_to_string(&crate_metadata.metadata_spec_path); + let is_unchanged_metadata_spec = + pre_metadata_spec.ok() == Some(metadata_spec.to_string()); if opt_result.is_some() - || !metadata_result.dest_metadata.exists() - || !metadata_result.dest_bundle.exists() + || !is_unchanged_metadata_spec + || !metadata_artifacts.exists() { + // Persists the current metadata spec used so we trigger regeneration + // when we switch + if !is_unchanged_metadata_spec { + fs::write( + &crate_metadata.metadata_spec_path, + metadata_spec.to_string(), + )?; + } + // if metadata build fails after a code build it might become stale clean_metadata(); metadata::execute( &crate_metadata, dest_binary.as_path(), - &metadata_result, + &metadata_artifacts, features, *network, *verbosity, @@ -746,7 +797,7 @@ pub fn execute(args: ExecuteArgs) -> Result { build_info, )?; } - (opt_result, Some(metadata_result), Some(dest_binary)) + (opt_result, Some(metadata_artifacts), Some(dest_binary)) } }; @@ -1024,8 +1075,10 @@ mod unit_tests { let raw_result = r#"{ "dest_binary": "/path/to/contract.polkavm", "metadata_result": { - "dest_metadata": "/path/to/contract.json", - "dest_bundle": "/path/to/contract.contract" + "Ink": { + "dest_metadata": "/path/to/contract.json", + "dest_bundle": "/path/to/contract.contract" + } }, "target_directory": "/path/to/target", "linker_size_result": { @@ -1040,10 +1093,10 @@ mod unit_tests { let build_result = BuildResult { dest_binary: Some(PathBuf::from("/path/to/contract.polkavm")), - metadata_result: Some(MetadataArtifacts { + metadata_result: Some(MetadataArtifacts::Ink(InkMetadataArtifacts { dest_metadata: PathBuf::from("/path/to/contract.json"), dest_bundle: PathBuf::from("/path/to/contract.contract"), - }), + })), target_directory: PathBuf::from("/path/to/target"), linker_size_result: Some(LinkerSizeResult { original_size: 64.0, diff --git a/crates/build/src/metadata.rs b/crates/build/src/metadata.rs index 15dfeda2f..6960bce98 100644 --- a/crates/build/src/metadata.rs +++ b/crates/build/src/metadata.rs @@ -17,6 +17,11 @@ use crate::{ code_hash, crate_metadata::CrateMetadata, + solidity_metadata::{ + self, + SolidityContractMetadata, + SolidityMetadataArtifacts, + }, util, verbose_eprintln, workspace::{ @@ -45,6 +50,7 @@ use contract_metadata::{ SourceLanguage, User, }; +use ink_metadata::InkProject; use semver::Version; use std::{ fs, @@ -57,7 +63,32 @@ use url::Url; /// Artifacts resulting from metadata generation. #[derive(serde::Serialize, serde::Deserialize)] -pub struct MetadataArtifacts { +pub enum MetadataArtifacts { + /// Artifacts resulting from ink! metadata generation. + Ink(InkMetadataArtifacts), + /// Artifacts resulting from Solidity compatible metadata generation. + Solidity(SolidityMetadataArtifacts), +} + +impl MetadataArtifacts { + /// Returns true if all metadata files exist. + pub(crate) fn exists(&self) -> bool { + match self { + MetadataArtifacts::Ink(ink_metadata_artifacts) => { + ink_metadata_artifacts.dest_metadata.exists() + && ink_metadata_artifacts.dest_bundle.exists() + } + MetadataArtifacts::Solidity(solidity_metadata_artifacts) => { + solidity_metadata_artifacts.dest_abi.exists() + && solidity_metadata_artifacts.dest_metadata.exists() + } + } + } +} + +/// Artifacts resulting from ink! metadata generation. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct InkMetadataArtifacts { /// Path to the resulting metadata file. pub dest_metadata: PathBuf, /// Path to the bundled file. @@ -155,11 +186,35 @@ pub fn execute( ); let output = cmd.stdout_capture().run()?; - let ink_meta: serde_json::Map = - serde_json::from_slice(&output.stdout)?; - let metadata = ContractMetadata::new(source, contract, None, user, ink_meta); + match metadata_artifacts { + MetadataArtifacts::Ink(ink_metadata_artifacts) => { + let ink_meta: serde_json::Map = + serde_json::from_slice(&output.stdout)?; + let metadata = + ContractMetadata::new(source, contract, None, user, ink_meta); + + write_metadata(ink_metadata_artifacts, metadata, &verbosity, false)?; + } + MetadataArtifacts::Solidity(solidity_metadata_artifacts) => { + let ink_project: InkProject = serde_json::from_slice(&output.stdout)?; + let sol_abi = solidity_metadata::generate_abi(&ink_project)?; + let metadata = solidity_metadata::generate_metadata( + &ink_project, + sol_abi, + source, + contract, + crate_metadata, + None, + )?; - write_metadata(metadata_artifacts, metadata, &verbosity, false)?; + write_solidity_metadata( + solidity_metadata_artifacts, + metadata, + &verbosity, + false, + )?; + } + } Ok(()) }; @@ -187,7 +242,7 @@ pub fn execute( } pub fn write_metadata( - metadata_artifacts: &MetadataArtifacts, + metadata_artifacts: &InkMetadataArtifacts, metadata: ContractMetadata, verbosity: &Verbosity, overwrite: bool, @@ -220,6 +275,40 @@ pub fn write_metadata( Ok(()) } +/// Writes Solidity compatible ABI and metadata files. +pub fn write_solidity_metadata( + metadata_artifacts: &SolidityMetadataArtifacts, + metadata: SolidityContractMetadata, + verbosity: &Verbosity, + overwrite: bool, +) -> Result<()> { + if overwrite { + verbose_eprintln!( + verbosity, + " {} {}", + "[==]".bold(), + "Updating Solidity compatible metadata".bright_cyan().bold() + ); + } else { + verbose_eprintln!( + verbosity, + " {} {}", + "[==]".bold(), + "Generating Solidity compatible metadata" + .bright_green() + .bold() + ); + } + + // Writes Solidity ABI file. + solidity_metadata::write_abi(&metadata.output.abi, &metadata_artifacts.dest_abi)?; + + // Writes Solidity Metadata file. + solidity_metadata::write_metadata(&metadata, &metadata_artifacts.dest_metadata)?; + + Ok(()) +} + /// Generate the extended contract project metadata fn extended_metadata( crate_metadata: &CrateMetadata, diff --git a/crates/build/src/solidity_metadata.rs b/crates/build/src/solidity_metadata.rs new file mode 100644 index 000000000..08359f53a --- /dev/null +++ b/crates/build/src/solidity_metadata.rs @@ -0,0 +1,291 @@ +// Copyright (C) ink! contributors. +// This file is part of cargo-contract. +// +// cargo-contract is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// cargo-contract is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with cargo-contract. If not, see . + +mod abi; +mod natspec; + +use std::{ + collections::HashMap, + fs::{ + self, + File, + }, + path::{ + Path, + PathBuf, + }, +}; + +use alloy_json_abi::JsonAbi; +use anyhow::{ + Context, + Result, +}; +use cargo_metadata::TargetKind; +use contract_metadata::{ + CodeHash, + Contract, + Source, +}; +use ink_metadata::InkProject; +use serde::{ + Deserialize, + Serialize, +}; +use serde_json::{ + Map, + Value, +}; + +use self::natspec::{ + DevDoc, + UserDoc, +}; +use crate::{ + code_hash, + CrateMetadata, +}; + +// Re-exports abi utilities. +pub use self::abi::{ + abi_path, + generate_abi, + write_abi, +}; + +/// Artifacts resulting from Solidity compatible metadata generation. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct SolidityMetadataArtifacts { + /// Path to the resulting ABI file. + pub dest_abi: PathBuf, + /// Path to the resulting metadata file. + pub dest_metadata: PathBuf, +} + +/// Solidity compatible smart contract metadata. +/// +/// Ref: +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SolidityContractMetadata { + /// Details about the compiler. + pub compiler: Compiler, + /// Source code language + pub language: String, + /// Generated information about the contract. + pub output: Output, + /// Compiler settings. + pub settings: Settings, + /// Compilation source files/source units, keys are file paths. + pub sources: HashMap, + /// The version of the metadata format. + pub version: u8, +} + +/// Details about the compiler. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Compiler { + /// Hash of the compiler binary which produced this output. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "keccak256")] + pub hash: Option, + /// Version of the compiler. + pub version: String, +} + +/// Generated information about the contract. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Output { + /// ABI definition of the contract. + /// Ref: + pub abi: JsonAbi, + /// NatSpec developer documentation of the contract. + /// Ref: + #[serde(rename = "devdoc")] + pub dev_doc: DevDoc, + /// NatSpec user documentation of the contract. + /// Ref: + #[serde(rename = "userdoc")] + pub user_doc: UserDoc, +} + +/// Compiler settings. +/// +/// **NOTE:** The Solidity metadata spec for this is very Solidity specific. +/// We include build info instead and namespace it under an "ink" key. + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Settings { + /// Extra Information about the contract and build environment. + pub ink: InkSettings, +} + +/// Extra Information about the contract and build environment. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct InkSettings { + /// The hash of the contract's binary. + pub hash: CodeHash, + /// If the contract is meant to be verifiable, + /// then the Docker image is specified. + pub image: Option, + /// Extra information about the environment in which the contract was built. + /// + /// Useful for producing deterministic builds. + /// + /// Equivalent to `source.build_info` in ink! metadata spec. + /// Ref: + pub build_info: Option>, +} + +/// Compilation source files/source units, keys are file paths. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SourceFile { + /// Contents of the source file. + pub content: String, + /// Hash of the source file. + #[serde(rename = "keccak256")] + pub hash: CodeHash, + /// SPDX license identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, +} + +impl SourceFile { + /// Creates a source file. + pub fn new(content: String, license: Option) -> Self { + let hash = code_hash(content.as_bytes()); + Self { + hash: CodeHash::from(hash), + content, + license, + } + } +} + +/// Generates a contract metadata file compatible with the Solidity metadata specification +/// for the ink! smart contract. +/// +/// Ref: +pub fn generate_metadata( + ink_project: &InkProject, + abi: JsonAbi, + source: Source, + contract: Contract, + crate_metadata: &CrateMetadata, + image: Option, +) -> Result { + let sources = source_files(crate_metadata)?; + let (dev_doc, user_doc) = natspec::generate_natspec(ink_project, contract)?; + let metadata = SolidityContractMetadata { + compiler: Compiler { + hash: None, + version: source.compiler.to_string(), + }, + language: source.language.to_string(), + output: Output { + abi, + dev_doc, + user_doc, + }, + sources, + settings: Settings { + ink: InkSettings { + hash: source.hash, + image, + build_info: source.build_info, + }, + }, + version: 1, + }; + + Ok(metadata) +} + +/// Get the path of the Solidity compatible contract metadata file. +pub fn metadata_path(crate_metadata: &CrateMetadata) -> PathBuf { + let metadata_file = format!("{}.json", crate_metadata.contract_artifact_name); + crate_metadata.target_directory.join(metadata_file) +} + +/// Writes a Solidity compatible metadata file. +/// +/// Ref: +pub fn write_metadata

(metadata: &SolidityContractMetadata, path: P) -> Result<()> +where + P: AsRef, +{ + let json = serde_json::to_string(metadata)?; + fs::write(path, json)?; + + Ok(()) +} + +/// Reads the file and tries to parse it as instance of [`SolidityContractMetadata`]. +pub fn load_metadata

(metadata_path: P) -> Result +where + P: AsRef, +{ + let path = metadata_path.as_ref(); + let file = File::open(path) + .context(format!("Failed to open metadata file {}", path.display()))?; + serde_json::from_reader(file).context(format!( + "Failed to deserialize metadata file {}", + path.display() + )) +} + +/// Returns compilation source file content, keys are relative file paths. +fn source_files(crate_metadata: &CrateMetadata) -> Result> { + let mut source_files = HashMap::new(); + + // Adds `Cargo.toml` source. + let manifest_path = &crate_metadata.manifest_path; + let project_dir = manifest_path.absolute_directory()?; + let manifest_path_buf = PathBuf::from(manifest_path.clone()); + let manifest_key = manifest_path_buf + .strip_prefix(&project_dir) + .unwrap_or_else(|_| &manifest_path_buf) + .to_string_lossy() + .into_owned(); + let manifest_content = fs::read_to_string(&manifest_path_buf)?; + source_files.insert( + manifest_key, + SourceFile::new( + manifest_content, + crate_metadata.root_package.license.clone(), + ), + ); + + // Adds `lib.rs` source. + let lib_src_path = &crate_metadata + .root_package + .targets + .iter() + .find_map(|target| { + (target.kind == [TargetKind::Lib]).then_some(target.src_path.clone()) + }) + .context("Couldn't find `lib.rs` path")?; + let lib_src_content = fs::read_to_string(lib_src_path)?; + let lib_src_key = lib_src_path + .strip_prefix(&project_dir) + .unwrap_or_else(|_| lib_src_path) + .to_string(); + source_files.insert( + lib_src_key, + SourceFile::new(lib_src_content, crate_metadata.root_package.license.clone()), + ); + + Ok(source_files) +} diff --git a/crates/build/src/solidity_metadata/abi.rs b/crates/build/src/solidity_metadata/abi.rs new file mode 100644 index 000000000..05ccf1615 --- /dev/null +++ b/crates/build/src/solidity_metadata/abi.rs @@ -0,0 +1,401 @@ +// Copyright (C) ink! contributors. +// This file is part of cargo-contract. +// +// cargo-contract is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// cargo-contract is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with cargo-contract. If not, see . + +use std::{ + collections::BTreeMap, + fs::{ + self, + }, + path::{ + Path, + PathBuf, + }, +}; + +use alloy_json_abi::{ + Constructor, + Event, + Function, + JsonAbi, +}; +use anyhow::Result; +use ink_metadata::{ + ConstructorSpec, + EventSpec, + InkProject, + MessageParamSpec, + MessageSpec, + ReturnTypeSpec, +}; +use itertools::Itertools; +use scale_info::{ + form::PortableForm, + PortableRegistry, + TypeDef, + TypeDefPrimitive, +}; + +use crate::CrateMetadata; + +/// Generates a Solidity-compatible ABI for the ink! smart contract (if possible). +/// +/// Ref: +pub fn generate_abi(ink_project: &InkProject) -> Result { + let registry = ink_project.registry(); + let spec = ink_project.spec(); + + // Solidity allows only one constructor, we choose the first one (or fallback to the + // first one). + let ctors = spec.constructors(); + let ctor = ctors + .iter() + .find_or_first(|ctor| ctor.default()) + .ok_or_else(|| { + anyhow::anyhow!("Expected at least one constructor in contract metadata") + })?; + if !ctor.default() && ctors.len() > 1 { + // Nudge the user to set a default constructor. + use colored::Colorize; + eprintln!( + "{} No default constructor set. \ + \n A default constructor is necessary to guarantee consistent Solidity compatible \ + metadata output across different rustc and cargo-contract releases. \ + \n Learn more at https://use.ink/6.x/macros-attributes/default/", + "warning:".yellow().bold() + ); + } + let ctor_abi = constructor(ctor, registry)?; + + let fn_abis: BTreeMap<_, _> = spec + .messages() + .iter() + .map(|msg| { + message(msg, registry).map(|fn_abi| (msg.label().clone(), vec![fn_abi])) + }) + .process_results(|iter| iter.collect())?; + + let event_abis: BTreeMap<_, _> = spec + .events() + .iter() + .map(|event_spec| { + event(event_spec, registry) + .map(|event_abi| (event_spec.label().clone(), vec![event_abi])) + }) + .process_results(|iter| iter.collect())?; + + Ok(JsonAbi { + constructor: Some(ctor_abi), + fallback: None, + receive: None, + functions: fn_abis, + events: event_abis, + errors: BTreeMap::new(), + }) +} + +/// Get the path of the Solidity compatible contract ABI file. +pub fn abi_path(crate_metadata: &CrateMetadata) -> PathBuf { + let metadata_file = format!("{}.abi", crate_metadata.contract_artifact_name); + crate_metadata.target_directory.join(metadata_file) +} + +/// Writes a Solidity compatible ABI file. +/// +/// Ref: +pub fn write_abi

(abi: &JsonAbi, path: P) -> Result<()> +where + P: AsRef, +{ + let json = serde_json::to_string(abi)?; + fs::write(path, json)?; + + Ok(()) +} + +/// Returns the constructor ABI representation for an ink! constructor. +fn constructor( + ctor: &ConstructorSpec, + registry: &PortableRegistry, +) -> Result { + let params = ctor + .args() + .iter() + .map(|param| { + param_decl(param, registry, &format!("constructor `{}`", ctor.label())) + }) + .process_results(|mut iter| iter.join(","))?; + + // NOTE: Solidity constructors don't expose a return type. + let abi_str = format!( + "constructor({params}){}", + if ctor.payable { " payable" } else { "" } + ); + Constructor::parse(&abi_str).map_err(|err| { + anyhow::anyhow!( + "Failed to parse abi for constructor `{}` : {err}", + ctor.label() + ) + }) +} + +/// Returns the function ABI representation for an ink! message. +fn message( + msg: &MessageSpec, + registry: &PortableRegistry, +) -> Result { + let name = msg.label(); + let params = msg + .args() + .iter() + .map(|param| param_decl(param, registry, &format!("message `{}`", name))) + .process_results(|mut iter| iter.join(","))?; + let ret_ty = return_ty(msg.return_type(), registry, &format!("message `{}`", name))?; + + let abi_str = format!( + "function {name}({params}) public{}{}{}", + // FIXME: (@davidsemakula) ink! does NOT currently enforce it's immutability + // claims for messages intrinsically (i.e at compile time). + // Ref: + if msg.mutates() { "" } else { " view" }, + if msg.payable() { " payable" } else { "" }, + if ret_ty.is_empty() || ret_ty == "()" { + String::new() + } else { + format!(" returns ({ret_ty})") + }, + ); + Function::parse(&abi_str).map_err(|err| { + anyhow::anyhow!("Failed to parse abi for message `{}` : {err}", msg.label()) + }) +} + +/// Returns the event ABI representation for an ink! event. +fn event( + event_spec: &EventSpec, + registry: &PortableRegistry, +) -> Result { + let name = event_spec.label(); + let params = event_spec + .args() + .iter() + .map(|param| { + let param_name = param.label(); + let ty_id = param.ty().ty().id; + let sol_ty = resolve_ty( + ty_id, + registry, + &format!("arg `{param_name}` for event `{name}`"), + ); + // TODO: (@davidsemakula) should we simply omit events with Solidity ABI + // incompatible types instead of bailing? + sol_ty.map(|ty| { + format!( + "{ty}{} {param_name}", + if param.indexed() { " indexed" } else { "" } + ) + }) + }) + .process_results(|mut iter| iter.join(","))?; + + let abi_str = format!( + "event {name}({params}){}", + if event_spec.signature_topic().is_none() { + " anonymous" + } else { + "" + } + ); + Event::parse(&abi_str).map_err(|err| { + anyhow::anyhow!( + "Failed to parse abi for event `{}` : {err}", + event_spec.label() + ) + }) +} + +/// Returns equivalent Solidity ABI declaration (if any) for an ink! constructor or +/// message parameter. +fn param_decl( + param: &MessageParamSpec, + registry: &PortableRegistry, + msg: &str, +) -> Result { + let name = param.label(); + let ty_id = param.ty().ty().id; + let sol_ty = resolve_ty(ty_id, registry, &format!("arg `{name}` for {}", msg)); + sol_ty.map(|ty| format!("{ty} {name}")) +} + +// Returns the "user-defined" return type for an ink! message. +// +// **NOTE:** The return type for ink! messages is `Result`, however, +// the ABI return type we're interested in is the "user-defined" `T` type. +fn return_ty( + ret_ty: &ReturnTypeSpec, + registry: &PortableRegistry, + msg: &str, +) -> Result { + let id = ret_ty.ret_type().ty().id; + let ty = registry + .resolve(id) + .unwrap_or_else(|| panic!("Failed to resolve return type `#{}` in {}", id, msg)); + if let TypeDef::Variant(type_def_variant) = &ty.type_def { + let ok_field = type_def_variant.variants.first().and_then(|v| { + (v.name == "Ok" && v.fields.len() == 1).then_some(&v.fields[0]) + }); + if let Some(field) = ok_field { + return resolve_ty(field.ty.id, registry, &format!("return type for {msg}")); + } + } + + anyhow::bail!( + "Expected `Result` return type for {}", + msg + ) +} + +/// Convenience macro for emitting errors for ink! types that are NOT compatible with any +/// Solidity ABI type. +macro_rules! incompatible_ty { + ($msg: expr, $ty_def: expr) => { + anyhow::bail!("Solidity ABI incompatible type in {}: {:?}", $msg, $ty_def) + }; +} + +/// Returns the equivalent Solidity ABI type (if any) for an ink! type (represented by the +/// given id in ink! project metadata). +/// +/// Ref: +pub fn resolve_ty(id: u32, registry: &PortableRegistry, msg: &str) -> Result { + let ty = registry + .resolve(id) + .unwrap_or_else(|| panic!("Failed to resolve type `#{}` in {}", id, msg)); + match &ty.type_def { + TypeDef::Composite(_) => { + let path_segments: Vec<_> = + ty.path.segments.iter().map(String::as_str).collect(); + let ty = match path_segments.as_slice() { + ["ink_primitives", "types", "AccountId"] + | ["ink_primitives", "types", "Hash"] + | ["primitive_types", "H256"] => "bytes32", + ["ink_primitives", "types", "Address"] => "address", + ["primitive_types", "H160"] => "bytes20", + ["primitive_types", "U256"] => "uint256", + // NOTE: `bytes1` sequences and arrays are "normalized" to `bytes` or + // `bytes` at wrapping `TypeDef::Sequence` or + // `TypeDef::Array` match arm (if appropriate). + ["ink_primitives", "types", "Byte"] => "bytes1", + _ => incompatible_ty!(msg, ty), + }; + Ok(ty.to_string()) + } + TypeDef::Variant(type_def_variant) => { + // Unit-only enums (i.e. enums that contain only unit variants) are + // represented as uint8. + // Ref: + // NOTE: This actually checks if an enum is field-less, however, field-less + // and unit-only enums have an identical representation in ink! metadata. + // Ref: + // Ref: + let contains_fields = type_def_variant + .variants + .iter() + .any(|variant| !variant.fields.is_empty()); + if !contains_fields { + Ok("uint8".to_string()) + } else { + incompatible_ty!(msg, ty) + } + } + TypeDef::Sequence(type_def_seq) => { + let elem_ty_id = type_def_seq.type_param.id; + let elem_ty = resolve_ty(elem_ty_id, registry, msg)?; + let normalized_ty = if elem_ty == "bytes1" { + // Normalize `bytes1[]` to `bytes`. + // Ref: + "bytes".to_string() + } else { + format!("{elem_ty}[]") + }; + Ok(normalized_ty) + } + TypeDef::Array(type_def_array) => { + let elem_ty_id = type_def_array.type_param.id; + let elem_ty = resolve_ty(elem_ty_id, registry, msg)?; + let len = type_def_array.len; + let normalized_ty = if elem_ty == "bytes1" && (1..=32).contains(&len) { + // Normalize `bytes1[N]` to `bytes` for `1 <= N <= 32`. + // Ref: + format!("bytes{len}") + } else { + format!("{elem_ty}[{len}]") + }; + Ok(normalized_ty) + } + TypeDef::Tuple(type_def_tuple) => { + let tys = type_def_tuple + .fields + .iter() + .map(|field| resolve_ty(field.id, registry, msg)) + .process_results(|mut iter| iter.join(","))?; + Ok(format!("({tys})")) + } + TypeDef::Primitive(type_def_primitive) => { + primitive_ty(type_def_primitive, msg).map(ToString::to_string) + } + TypeDef::Compact(_) | TypeDef::BitSequence(_) => { + incompatible_ty!(msg, ty) + } + } +} + +/// Returns the equivalent Solidity elementary type (if any) for an ink! primitive type +/// (represented by the given id in ink! project metadata). +/// +/// Ref: +fn primitive_ty(ty_def: &TypeDefPrimitive, msg: &str) -> Result<&'static str> { + let sol_ty = match ty_def { + TypeDefPrimitive::Bool => "bool", + // TODO: (@davidsemakula) can we represent char as a `bytes4` fixed-size + // array and interpret it in overlong encoding? + // Ref: + TypeDefPrimitive::Char => { + incompatible_ty!(msg, ty_def); + } + // NOTE: Rust strings are UTF-8, while solidity string literals + // only support ASCII characters, but Solidity also has unicode literals. + // However, the Solidity ABI spec uses `string` for both, and claims that `string` + // is a "dynamic sized unicode string assumed to be UTF-8 encoded", so presumably + // this is fine. + // Ref: + // Ref: + // Ref: + TypeDefPrimitive::Str => "string", + TypeDefPrimitive::U8 => "uint8", + TypeDefPrimitive::U16 => "uint16", + TypeDefPrimitive::U32 => "uint32", + TypeDefPrimitive::U64 => "uint64", + TypeDefPrimitive::U128 => "uint128", + TypeDefPrimitive::U256 => "uint256", + TypeDefPrimitive::I8 => "int8", + TypeDefPrimitive::I16 => "int16", + TypeDefPrimitive::I32 => "int32", + TypeDefPrimitive::I64 => "int64", + TypeDefPrimitive::I128 => "int128", + TypeDefPrimitive::I256 => "int256", + }; + Ok(sol_ty) +} diff --git a/crates/build/src/solidity_metadata/natspec.rs b/crates/build/src/solidity_metadata/natspec.rs new file mode 100644 index 000000000..4b7101bed --- /dev/null +++ b/crates/build/src/solidity_metadata/natspec.rs @@ -0,0 +1,264 @@ +// Copyright (C) ink! contributors. +// This file is part of cargo-contract. +// +// cargo-contract is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// cargo-contract is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with cargo-contract. If not, see . + +use std::collections::HashMap; + +use anyhow::Result; +use contract_metadata::Contract; +use ink_metadata::{ + EventSpec, + InkProject, + MessageSpec, +}; +use itertools::Itertools; +use scale_info::{ + form::PortableForm, + PortableRegistry, +}; +use serde::{ + Deserialize, + Serialize, +}; + +use super::abi; + +/// NatSpec developer documentation of the contract. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DevDoc { + /// The version of the NatSpec format. + pub version: u8, + /// Kind of NatSpec documentation (i.e. "dev"). + pub kind: NatSpecKind, + /// Author of the contract. + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + /// Describes the contract/interface. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Extra details for developers. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, + /// Storage developer documentation, keys are storage keys. + #[serde(rename = "stateVariables")] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub state_variables: HashMap, + /// Function developer documentation, keys are canonical function signatures. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub methods: HashMap, + /// Events developer documentation, keys are canonical event signatures. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub events: HashMap, + /// Errors developer documentation, keys are canonical error signatures. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub errors: HashMap, +} + +/// NatSpec user documentation of the contract. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct UserDoc { + /// The version of the NatSpec format. + pub version: u8, + /// Kind of NatSpec documentation (i.e. "user"). + pub kind: NatSpecKind, + /// Description for an end-user. + #[serde(skip_serializing_if = "Option::is_none")] + pub notice: Option, + /// Function user documentation, keys are canonical function signatures. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub methods: HashMap, + /// Events user documentation, keys are canonical event signatures. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub events: HashMap, + /// Errors user documentation, keys are canonical error signatures. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub errors: HashMap, +} + +/// Kind of NatSpec documentation (i.e. developer or user). +/// +/// Ref: +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum NatSpecKind { + /// Developer-focused documentation. + Dev, + /// End-user-facing documentation. + User, +} + +/// NatSpec item description for a developer. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ItemDevDoc { + /// Description for a developer. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, + /// Item parameter descriptions, keys are parameter names. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub params: HashMap, + /// Item return type descriptions (if any). + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub returns: HashMap, +} + +impl ItemDevDoc { + /// Creates a details-only developer documentation item. + fn details(docs: String) -> Self { + Self { + details: Some(docs), + params: HashMap::new(), + returns: HashMap::new(), + } + } + + /// Creates a details and params only developer documentation item (e.g. for events). + fn details_and_params(docs: String, params: HashMap) -> Self { + Self { + details: Some(docs), + params, + returns: HashMap::new(), + } + } +} + +/// NatSpec item description for an end-user. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ItemUserDoc { + /// Description for an end-user. + #[serde(skip_serializing_if = "Option::is_none")] + pub notice: Option, +} + +/// Generates a Solidity-compatible ABI for the ink! smart contract (if possible). +/// +/// Ref: +pub fn generate_natspec( + ink_project: &InkProject, + contract: Contract, +) -> Result<(DevDoc, UserDoc)> { + let registry = ink_project.registry(); + let spec = ink_project.spec(); + + let method_docs: HashMap<_, _> = spec + .messages() + .iter() + .filter_map(|msg| message(msg, registry)) + .collect(); + let event_docs: HashMap<_, _> = spec + .events() + .iter() + .filter_map(|event_spec| event(event_spec, registry)) + .collect(); + + let dev_doc = DevDoc { + version: 1, + kind: NatSpecKind::Dev, + author: concat_non_empty(&contract.authors, ", "), + title: contract.description.clone(), + details: concat_non_empty(spec.docs(), "\n"), + // FIXME: (@davidsemakula) add storage documentation. + state_variables: HashMap::new(), + methods: method_docs, + events: event_docs, + // TODO: (@davidsemakula) add errors documentation?. + errors: HashMap::new(), + }; + let user_doc = UserDoc { + version: 1, + kind: NatSpecKind::User, + notice: contract.description, + // NOTE: We assume ink!/Rust doc comments are developer docs, so we have no way of + // representing the equivalent of NatSpec user docs at the moment. + methods: HashMap::new(), + events: HashMap::new(), + errors: HashMap::new(), + }; + + Ok((dev_doc, user_doc)) +} + +/// Returns the function signature and developer documentation (if any). +fn message( + msg: &MessageSpec, + registry: &PortableRegistry, +) -> Option<(String, ItemDevDoc)> { + let name = msg.label(); + + // Bails if message has no docs. + let docs = concat_non_empty(msg.docs(), "\n")?; + + // Generates the function's canonical signature. + // NOTE: Bails if any parameter has a Solidity ABI incompatible type. + // NOTE: Rust doesn't currently support doc comments (i.e. rustdoc) for function + // parameters. + // Ref: + let param_tys = msg + .args() + .iter() + .map(|param| { + let param_name = param.label(); + let ty_id = param.ty().ty().id; + abi::resolve_ty( + ty_id, + registry, + &format!("arg `{param_name}` for message `{}`", name), + ) + }) + .process_results(|mut iter| iter.join(",")) + .ok()?; + let fn_sig = format!("{name}({param_tys})"); + + Some((fn_sig, ItemDevDoc::details(docs))) +} + +/// Returns the function signature and developer documentation (if any). +fn event( + event_spec: &EventSpec, + registry: &PortableRegistry, +) -> Option<(String, ItemDevDoc)> { + let name = event_spec.label(); + + // Bails if event has no docs. + let docs = concat_non_empty(event_spec.docs(), "\n")?; + + // Generates the event's canonical signature and param docs. + // NOTE: Bails if any parameter has a Solidity ABI incompatible type. + let mut param_tys = Vec::new(); + let mut param_docs = HashMap::new(); + for param in event_spec.args() { + let param_name = param.label(); + let ty_id = param.ty().ty().id; + + let ty = abi::resolve_ty( + ty_id, + registry, + &format!("arg `{param_name}` for event `{}`", name), + ) + .ok()?; + param_tys.push(ty); + if let Some(docs) = concat_non_empty(param.docs(), "\n") { + param_docs.insert(param_name.to_string(), docs); + } + } + let event_sig = format!("{name}({})", param_tys.join(",")); + + Some((event_sig, ItemDevDoc::details_and_params(docs, param_docs))) +} + +/// Given a slice of strings, returns a non-empty doc string that's a concatenation of +/// all the non-empty input strings. +fn concat_non_empty(input: &[String], sep: &str) -> Option { + (!input.is_empty()).then_some(input.join(sep)) +} diff --git a/crates/build/src/tests.rs b/crates/build/src/tests.rs index 6a2b4499a..19dd5b343 100644 --- a/crates/build/src/tests.rs +++ b/crates/build/src/tests.rs @@ -22,13 +22,17 @@ use crate::{ BuildResult, CrateMetadata, ExecuteArgs, + InkMetadataArtifacts, ManifestPath, + MetadataArtifacts, OutputType, + SolidityMetadataArtifacts, }; use anyhow::Result; use contract_metadata::*; use serde_json::{ Map, + Number, Value, }; use std::{ @@ -74,6 +78,7 @@ build_tests!( building_contract_with_build_rs_must_work, missing_linting_toolchain_installation_must_be_detected, generates_metadata, + generates_solidity_metadata, unchanged_contract_skips_optimization_and_metadata_steps, unchanged_contract_no_metadata_artifacts_generates_metadata ); @@ -331,6 +336,24 @@ fn missing_cargo_dylint_installation_must_be_detected( Ok(()) } +fn ink_metadata_artifacts(artifact: &MetadataArtifacts) -> Option<&InkMetadataArtifacts> { + match artifact { + MetadataArtifacts::Ink(ink_metadata_artifacts) => Some(ink_metadata_artifacts), + MetadataArtifacts::Solidity(_) => None, + } +} + +fn solidity_metadata_artifacts( + artifact: &MetadataArtifacts, +) -> Option<&SolidityMetadataArtifacts> { + match artifact { + MetadataArtifacts::Ink(_) => None, + MetadataArtifacts::Solidity(solidiy_metadata_artifacts) => { + Some(solidiy_metadata_artifacts) + } + } +} + fn generates_metadata(manifest_path: &ManifestPath) -> Result<()> { // add optional metadata fields let mut test_manifest = TestContractManifest::new(manifest_path.clone())?; @@ -362,13 +385,17 @@ fn generates_metadata(manifest_path: &ManifestPath) -> Result<()> { args.manifest_path = manifest_path.clone(); let build_result = crate::execute(args)?; - let dest_bundle = build_result - .metadata_result - .expect("Metadata should be generated") - .dest_bundle; + let dest_bundle = &ink_metadata_artifacts( + build_result + .metadata_result + .as_ref() + .expect("Metadata should be generated"), + ) + .expect("ink! Metadata should be generated") + .dest_bundle; let metadata_json: Map = - serde_json::from_slice(&fs::read(&dest_bundle)?)?; + serde_json::from_slice(&fs::read(dest_bundle)?)?; assert!( dest_bundle.exists(), @@ -453,6 +480,143 @@ fn generates_metadata(manifest_path: &ManifestPath) -> Result<()> { Ok(()) } +fn generates_solidity_metadata(manifest_path: &ManifestPath) -> Result<()> { + // add optional metadata fields + let mut test_manifest = TestContractManifest::new(manifest_path.clone())?; + test_manifest.add_package_value("description", "contract description".into())?; + test_manifest + .add_package_value("documentation", "http://documentation.com".into())?; + test_manifest.add_package_value("repository", "http://repository.com".into())?; + test_manifest.add_package_value("homepage", "http://homepage.com".into())?; + test_manifest.add_package_value("license", "Apache-2.0".into())?; + test_manifest.write()?; + + let crate_metadata = CrateMetadata::collect(manifest_path)?; + + // usually this file will be produced by a previous build step + let final_contract_binary_path = &crate_metadata.dest_binary; + fs::create_dir_all(final_contract_binary_path.parent().unwrap()).unwrap(); + fs::write(final_contract_binary_path, "TEST FINAL BINARY").unwrap(); + + let mut args = ExecuteArgs { + extra_lints: false, + metadata_spec: crate::MetadataSpec::Solidity, + ..Default::default() + }; + args.manifest_path = manifest_path.clone(); + + let build_result = crate::execute(args)?; + let metadata_result = solidity_metadata_artifacts( + build_result + .metadata_result + .as_ref() + .expect("Metadata should be generated"), + ) + .expect("Solidity Metadata should be generated"); + + let dest_abi = &metadata_result.dest_abi; + assert_eq!(dest_abi.extension().unwrap(), "abi"); + assert!( + dest_abi.exists(), + "Missing ABI file '{}'", + dest_abi.display() + ); + + let dest_metadata = &metadata_result.dest_metadata; + assert_eq!(dest_metadata.extension().unwrap(), "json"); + assert!( + dest_metadata.exists(), + "Missing metadata file '{}'", + dest_metadata.display() + ); + + let abi_json: Vec = serde_json::from_slice(&fs::read(dest_abi)?)?; + let metadata_json: Map = + serde_json::from_slice(&fs::read(dest_metadata)?)?; + + let compiler = metadata_json.get("compiler").expect("compiler not found"); + let compiler_version = compiler.get("version").expect("compiler.version not found"); + let expected_rustc_version = + semver::Version::parse(&rustc_version::version()?.to_string())?; + let expected_compiler = + SourceCompiler::new(Compiler::RustC, expected_rustc_version).to_string(); + assert_eq!(expected_compiler, compiler_version.as_str().unwrap()); + + let language = metadata_json.get("language").expect("language not found"); + let expected_language = + SourceLanguage::new(Language::Ink, crate_metadata.ink_version).to_string(); + assert_eq!(expected_language, language.as_str().unwrap()); + + let output = metadata_json.get("output").expect("output not found"); + let abi = output.get("abi").expect("output.abi not found"); + assert_eq!(abi, &Value::Array(abi_json)); + + let devdoc = output.get("devdoc").expect("output.devdoc not found"); + let version = devdoc + .get("version") + .expect("output.devdoc.version not found"); + assert_eq!(version, &Value::Number(Number::from_u128(1).unwrap())); + let kind = devdoc.get("kind").expect("output.devdoc.kind not found"); + assert_eq!(kind, &Value::String("dev".to_string())); + let author = devdoc + .get("author") + .expect("output.devdoc.author not found") + .as_str() + .expect("output.devdoc.author is a string"); + assert_eq!(crate_metadata.root_package.authors.join(", "), author); + let title = devdoc + .get("title") + .expect("output.devdoc.description not found"); + assert_eq!("contract description", title.as_str().unwrap()); + + let userdoc = output.get("userdoc").expect("output.userdoc not found"); + let version = userdoc + .get("version") + .expect("output.userdoc.version not found"); + assert_eq!(version, &Value::Number(Number::from_u128(1).unwrap())); + let kind = userdoc.get("kind").expect("output.userdoc.kind not found"); + assert_eq!(kind, &Value::String("user".to_string())); + + let settings = metadata_json.get("settings").expect("settings not found"); + let ink_settings = settings.get("ink").expect("settings.ink not found"); + let build_info = ink_settings + .get("build_info") + .expect("settings.ink.build_info not found"); + let build_mode = build_info + .get("build_mode") + .expect("settings.ink.build_info.build_mode not found"); + assert_eq!(build_mode, &Value::String("Debug".to_string())); + build_info + .get("cargo_contract_version") + .expect("settings.ink.build_info.cargo_contract_version not found"); + + // calculate binary hash + let hash = ink_settings + .get("hash") + .expect("settings.ink.hash not found"); + let fs_binary = fs::read(&crate_metadata.dest_binary)?; + let expected_hash = crate::code_hash(&fs_binary[..]); + assert_eq!(build_byte_str(&expected_hash[..]), hash.as_str().unwrap()); + + let sources = metadata_json + .get("sources") + .expect("sources not found") + .as_object() + .expect("sources is an object"); + for (src_path, contents) in sources { + assert!(src_path.ends_with("Cargo.toml") || src_path.ends_with("lib.rs")); + let license = contents + .get("license") + .expect("sources[].license not found"); + assert_eq!(license, &Value::String("Apache-2.0".to_string())); + } + + let version = metadata_json.get("version").expect("version not found"); + assert_eq!(version, &Value::Number(Number::from_u128(1).unwrap())); + + Ok(()) +} + fn unchanged_contract_skips_optimization_and_metadata_steps( manifest_path: &ManifestPath, ) -> Result<()> { @@ -472,10 +636,12 @@ fn unchanged_contract_skips_optimization_and_metadata_steps( "metadata_result should always be returned for a full build" ); let dest_binary_modified = file_last_modified(res.dest_binary.as_ref().unwrap()); + let metadata_artifacts = + ink_metadata_artifacts(res.metadata_result.as_ref().unwrap()).unwrap(); let metadata_result_modified = - file_last_modified(&res.metadata_result.as_ref().unwrap().dest_metadata); + file_last_modified(&metadata_artifacts.dest_metadata); let contract_bundle_modified = - file_last_modified(&res.metadata_result.as_ref().unwrap().dest_bundle); + file_last_modified(&metadata_artifacts.dest_bundle); ( dest_binary_modified, metadata_result_modified, @@ -533,16 +699,14 @@ fn unchanged_contract_no_metadata_artifacts_generates_metadata( // Code remains unchanged, but metadata artifacts are now generated assert_eq!(dest_binary_modified_pre, dest_binary_modified_post); + let metadata_artifacts = + ink_metadata_artifacts(res2.metadata_result.as_ref().unwrap()).unwrap(); assert!( - res2.metadata_result - .as_ref() - .unwrap() - .dest_metadata - .exists(), + metadata_artifacts.dest_metadata.exists(), "Metadata file should have been generated" ); assert!( - res2.metadata_result.as_ref().unwrap().dest_bundle.exists(), + metadata_artifacts.dest_bundle.exists(), "Contract bundle should have been generated" ); diff --git a/crates/build/templates/generate-metadata/main.rs b/crates/build/templates/generate-metadata/main.rs index c26034861..e0d4649c1 100644 --- a/crates/build/templates/generate-metadata/main.rs +++ b/crates/build/templates/generate-metadata/main.rs @@ -1,7 +1,7 @@ extern crate contract; extern "Rust" { - // Note: The ink! metdata codegen generates an implementation for this function, + // Note: The ink! metadata codegen generates an implementation for this function, // which is what we end up linking to here. fn __ink_generate_metadata() -> ink::metadata::InkProject; } diff --git a/crates/cargo-contract/src/cmd/build.rs b/crates/cargo-contract/src/cmd/build.rs index 7e837e091..8837a183d 100644 --- a/crates/cargo-contract/src/cmd/build.rs +++ b/crates/cargo-contract/src/cmd/build.rs @@ -23,6 +23,7 @@ use contract_build::{ Features, ImageVariant, ManifestPath, + MetadataSpec, Network, OutputType, UnstableFlags, @@ -98,6 +99,9 @@ pub struct BuildCommand { /// Specify a custom image for the verifiable build #[clap(long, default_value = None)] image: Option, + /// Which specification to use for contract metadata. + #[clap(long, default_value = "ink")] + metadata: MetadataSpec, } impl BuildCommand { @@ -148,6 +152,7 @@ impl BuildCommand { output_type, skip_clippy_and_linting: self.skip_clippy_and_linting, image, + metadata_spec: self.metadata, }; contract_build::execute(args) } @@ -181,6 +186,7 @@ impl CheckCommand { output_type: OutputType::default(), skip_clippy_and_linting: false, image: ImageVariant::Default, + metadata_spec: Default::default(), }; contract_build::execute(args) diff --git a/crates/cargo-contract/src/cmd/verify.rs b/crates/cargo-contract/src/cmd/verify.rs index 44e300aea..f1005913f 100644 --- a/crates/cargo-contract/src/cmd/verify.rs +++ b/crates/cargo-contract/src/cmd/verify.rs @@ -30,6 +30,7 @@ use contract_build::{ ExecuteArgs, ImageVariant, ManifestPath, + MetadataArtifacts, Verbosity, VerbosityFlags, }; @@ -261,7 +262,9 @@ impl VerifyCommand { ) .expect("decoding the `source.polkavm` hex failed"); let reference_code_hash = CodeHash(code_hash(&reference_polkavm_blob)); - let built_contract_path = if let Some(m) = build_result.metadata_result { + let built_contract_path = if let Some(MetadataArtifacts::Ink(m)) = + build_result.metadata_result + { m } else { // Since we're building the contract ourselves this should always be diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 9ba82433e..aa7e9122c 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -619,7 +619,7 @@ impl ContractBuilder { self } - /// Finalize construction of the [`ContractMetadata`]. + /// Finalize construction of the [`Contract`] metadata. /// /// Returns an `Err` if any required fields missing. pub fn build(&self) -> Result {