diff --git a/Cargo.toml b/Cargo.toml index 13e7242..b224b5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,18 @@ [workspace] resolver = "2" -members = ["src/contract", "src/lib/cdm/rust", "src/lib/cdm/rust-macros"] +members = ["src/contract", "src/lib/cdm/rust", "src/lib/cdm/rust-macros", "src/lib/cdm/import-test"] [workspace.package] version = "0.1.0" authors = ["Parity Technologies"] edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/paritytech/contract-dependency-manager" [workspace.dependencies] -pvm_contract = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "charles/cdm-integration" } +pvm-contract-sdk = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "sm/cdm", features = ["alloc"] } +pvm-cdm = { path = "src/lib/cdm/rust-macros/pvm-cdm" } polkavm-derive = "0.31" -parity-scale-codec = { version = "3.7", default-features = false, features = ["derive"] } picoalloc = "5.2" cdm = { path = "src/lib/cdm/rust" } cdm-macros = { path = "src/lib/cdm/rust-macros" } @@ -20,3 +22,4 @@ proc-macro2 = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" home = "0.5" +keccak-const = "0.2" diff --git a/README.md b/README.md index e7f1d0f..66b35d0 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,12 @@ bun run src/apps/cli/src/cli.ts --help make frontend # Run tests -make test +make test # unit tests only (fast) +pnpm test:e2e # end-to-end: spawns revive-dev-node, deploys + # the registry, exercises every method. + # Requires `revive-dev-node` and `bun` on $PATH: + # cargo install --git https://github.com/paritytech/polkadot-sdk --bin revive-dev-node + # curl -fsSL https://bun.sh/install | bash # Build native binary make compile diff --git a/package.json b/package.json index f693c5e..f15ad6e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "lint": "turbo lint", "clean": "turbo clean", "test": "vitest run", + "test:e2e:setup": "pnpm --filter @dotdm/utils --filter @dotdm/env --filter @dotdm/contracts build", + "test:e2e": "pnpm test:e2e:setup && vitest run --config vitest.e2e.config.ts", "format": "biome format --write .", "format:check": "biome format ." }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37250df..1239057 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,6 +385,9 @@ importers: '@dotdm/utils': specifier: workspace:* version: link:../utils + '@parity/product-sdk-contracts': + specifier: 'catalog:' + version: 0.5.0(@novasamatech/host-api@0.7.8)(@novasamatech/product-sdk@0.7.8(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.3)(rxjs@7.8.2))(@polkadot/api@16.5.6)(@polkadot/util@14.0.3)(esbuild@0.27.3)(rxjs@7.8.2)(typescript@5.9.3)(zod@4.3.6) devDependencies: '@types/bun': specifier: 'catalog:' diff --git a/src/contract/Cargo.toml b/src/contract/Cargo.toml index c1e932b..94a745d 100644 --- a/src/contract/Cargo.toml +++ b/src/contract/Cargo.toml @@ -3,10 +3,15 @@ name = "contract-registry" version.workspace = true edition.workspace = true +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[package.metadata.cdm-package] +name = "@cdm/contract-registry" + [dependencies] -pvm_contract.workspace = true +pvm-contract-sdk.workspace = true polkavm-derive.workspace = true -parity-scale-codec.workspace = true picoalloc.workspace = true [lib] diff --git a/src/contract/src/lib.rs b/src/contract/src/lib.rs index 0cbc646..dd8d82d 100644 --- a/src/contract/src/lib.rs +++ b/src/contract/src/lib.rs @@ -1,255 +1,259 @@ -#![no_main] -#![no_std] - -use alloc::string::String; -use core::ops::Bound; -use parity_scale_codec::{Decode, Encode}; -use pvm::storage::{Mapping, OrderedIndex}; -use pvm::{Address, ReturnFlags, caller}; -use pvm_contract as pvm; - -fn revert(msg: &[u8]) -> ! { - pvm::api::return_value(ReturnFlags::REVERT, msg) -} +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] -pub type Version = u32; -const MAX_CONTRACT_NAME_LEN: usize = 64; -const MAX_SEARCH_LIMIT: u32 = 100; - -/// A published contract version in the registry. -#[derive(Clone, Encode, Decode)] -pub struct PublishedContract { - /// The address of the published contract. - pub address: Address, - /// Bulletin chain IPFS URI pointing to this contract version's metadata. - pub metadata_uri: String, -} +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 65536)] +mod contract_registry { + use alloc::string::String; + use alloc::vec::Vec; + use pvm_contract_sdk::{Address, HostApi, Lazy, Mapping}; + + pub type Version = u32; + + const MAX_CONTRACT_NAME_LEN: usize = 64; + const MAX_SEARCH_LIMIT: u32 = 100; + + pvm_contract_sdk::sol_revert_enum! { + pub enum Error { + Unauthorized(Unauthorized), + VersionOverflow(VersionOverflow), + ContractCountOverflow(ContractCountOverflow), + ContractNameEmpty(ContractNameEmpty), + ContractNameTooLong(ContractNameTooLong), + ContractNameInvalid(ContractNameInvalid), + } + } -#[derive(Default, Clone, Encode, Decode)] -pub struct NamedContractInfo { - /// The owner of the contract name - pub owner: Address, - /// The number of versions published under this contract name. - /// `version_count - 1` refers to the latest published version - pub version_count: Version, -} + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct Unauthorized; -#[derive(Default, pvm::SolAbi)] -pub struct ContractNameSearchPage { - pub names: alloc::vec::Vec, - pub next_offset: u32, - pub done: bool, -} + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct VersionOverflow; -fn validate_contract_name(contract_name: &String) { - if contract_name.is_empty() { - revert(b"ContractNameEmpty"); - } - if contract_name.as_bytes().len() > MAX_CONTRACT_NAME_LEN { - revert(b"ContractNameTooLong"); - } - if !contract_name.is_ascii() { - revert(b"ContractNameInvalid"); - } -} + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct ContractCountOverflow; -fn prefix_upper_bound(prefix: &String) -> Option { - let mut bytes = prefix.as_bytes().to_vec(); + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct ContractNameEmpty; - while bytes.last() == Some(&0xff) { - bytes.pop(); - } + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct ContractNameTooLong; + + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct ContractNameInvalid; - if let Some(last) = bytes.last_mut() { - *last = last.saturating_add(1); - Some(String::from_utf8_lossy(&bytes).into_owned()) - } else { - None + #[derive(Clone, pvm_contract_sdk::SolType)] + pub struct NamedContractInfo { + /// The owner of the contract name. + pub owner: Address, + /// `version_count - 1` is the latest published version index. + /// Zero means the name is unregistered. + pub version_count: u32, } -} -#[pvm::storage] -struct Storage { - /// Count of registered contract names - contract_name_count: u32, - /// Maps index to contract name (simulates StorageVec) - contract_name_at: Mapping, - /// Sorted index of contract names for prefix search. - contract_name_index: OrderedIndex, - /// Stores all published versions of named contracts where the key for - /// an individual versioned contract is given by `(contract_name, version)` - published_address: Mapping<(String, Version), Address>, - published_metadata_uri: Mapping<(String, Version), String>, - /// Stores info about each registered contract name - info: Mapping, -} + impl Default for NamedContractInfo { + fn default() -> Self { + Self { + owner: Address::ZERO, + version_count: 0, + } + } + } -#[pvm::contract] -mod contract_registry { - use super::*; + #[derive(Clone, pvm_contract_sdk::SolType)] + pub struct ContractNameSearchPage { + pub names: Vec, + pub next_offset: u32, + pub done: bool, + } - #[pvm::constructor] - pub fn new() -> Result<(), Error> { + fn validate_contract_name(contract_name: &str) -> Result<(), Error> { + if contract_name.is_empty() { + return Err(ContractNameEmpty.into()); + } + if contract_name.len() > MAX_CONTRACT_NAME_LEN { + return Err(ContractNameTooLong.into()); + } + if !contract_name.is_ascii() { + return Err(ContractNameInvalid.into()); + } Ok(()) } - /// Publish the latest version of a contract registered under name `contract_name` - /// - /// The caller only has permission to publish a new version of `contract_name` if - /// either the name is available or they are already the owner of the name. - #[pvm::method] - pub fn publish_latest(contract_name: String, contract_address: Address, metadata_uri: String) { - validate_contract_name(&contract_name); - - let caller = caller(); - - // Get existing info or register new `contract_name` with caller as owner - let mut info = match Storage::info().get(&contract_name) { - Some(info) => info, - None => { - let info = NamedContractInfo { - owner: caller, - version_count: 0, - }; - // Append to contract names list - let count = Storage::contract_name_count().get().unwrap_or(0); - Storage::contract_name_at().insert(&count, &contract_name); - Storage::contract_name_index().insert(&contract_name, &count); - Storage::contract_name_count().set( - &count - .checked_add(1) - .unwrap_or_else(|| revert(b"ContractCountOverflow")), - ); - info + pub struct ContractRegistry { + /// Count of registered contract names. + #[slot(0)] + contract_name_count: Lazy, + /// Maps index to contract name (simulates a StorageVec). + #[slot(1)] + contract_name_at: Mapping, + /// `(contract_name, version) → address` for every published version. + #[slot(2)] + published_address: Mapping<(String, Version), Address>, + /// `(contract_name, version) → metadata_uri` for every published version. + #[slot(3)] + published_metadata_uri: Mapping<(String, Version), String>, + /// `contract_name → (owner address, version count)`. + #[slot(4)] + info: Mapping, + } + + impl ContractRegistry { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) { + self.contract_name_count.set(&0); + } + + /// Publish the latest version of a contract registered under `contract_name`. + /// + /// The caller can publish a new version only if the name is unregistered + /// (in which case caller becomes the owner) or the caller is the current + /// owner of the name. + #[pvm_contract_sdk::method] + pub fn publish_latest( + &mut self, + contract_name: String, + contract_address: Address, + metadata_uri: String, + ) -> Result<(), Error> { + validate_contract_name(&contract_name)?; + + let caller = self.caller(); + let mut info = self.info.get(&contract_name); + if info.version_count == 0 { + // First-time registration: claim the name. + info.owner = caller; + let count = self.contract_name_count.get(); + self.contract_name_at.insert(&count, &contract_name); + self.contract_name_count + .set(&count.checked_add(1).ok_or(ContractCountOverflow)?); + } else if info.owner != caller { + return Err(Unauthorized.into()); } - }; - // Only the owner can publish under this name - if info.owner != caller { - revert(b"Unauthorized"); + info.version_count = info.version_count.checked_add(1).ok_or(VersionOverflow)?; + self.info.insert(&contract_name, &info); + + let version_idx = info.version_count - 1; + self.published_address + .insert(&(contract_name.clone(), version_idx), &contract_address); + self.published_metadata_uri + .insert(&(contract_name, version_idx), &metadata_uri); + Ok(()) } - // Increment version count & save info - info.version_count = match info.version_count.checked_add(1) { - Some(v) => v, - None => revert(b"VersionOverflow"), - }; - Storage::info().insert(&contract_name, &info); + /// Search registered contract names by prefix. + /// + /// The pre-sm/cdm registry used a sorted `OrderedIndex` for O(log n) + /// prefix lookups. The new SDK's storage layer doesn't expose ordered + /// indices, so this is a linear scan over `contract_name_at` (cheap + /// for the current registry sizes; revisit if `OrderedIndex` lands). + /// + /// Returns up to `limit` (capped at `MAX_SEARCH_LIMIT`) names matching + /// `prefix`, starting from name-index `offset` in registration order. + /// `done = true` when the scan reached the end of the registry. + #[pvm_contract_sdk::method] + pub fn search_contract_names( + &self, + prefix: String, + offset: u32, + limit: u32, + ) -> ContractNameSearchPage { + let cap = limit.min(MAX_SEARCH_LIMIT); + if cap == 0 || prefix.len() > MAX_CONTRACT_NAME_LEN || !prefix.is_ascii() { + return ContractNameSearchPage { + names: Vec::new(), + next_offset: offset, + done: true, + }; + } - // Store published contract data at latest version index - let version_idx = info.version_count.saturating_sub(1); - Storage::published_address() - .insert(&(contract_name.clone(), version_idx), &contract_address); - Storage::published_metadata_uri().insert(&(contract_name, version_idx), &metadata_uri); - } + let total = self.contract_name_count.get(); + let mut names = Vec::new(); + let mut idx = offset; + while idx < total && (names.len() as u32) < cap { + let name = self.contract_name_at.get(&idx); + if name.starts_with(prefix.as_str()) { + names.push(name); + } + idx += 1; + } - /// Get the address of the latest published contract for a given `contract_name`. - /// This is the primary function used by CDM runtime lookups. - #[pvm::method] - pub fn get_address(contract_name: String) -> Option
{ - let info = Storage::info().get(&contract_name); - if let Some(info) = info { - let latest_version = info.version_count.saturating_sub(1); - Storage::published_address().get(&(contract_name, latest_version)) - } else { - None + ContractNameSearchPage { + names, + next_offset: idx, + done: idx >= total, + } } - } - /// Get the metadata URI of the latest published contract for a given `contract_name`. - #[pvm::method] - pub fn get_metadata_uri(contract_name: String) -> Option { - let info = Storage::info().get(&contract_name); - if let Some(info) = info { - let latest_version = info.version_count.saturating_sub(1); - Storage::published_metadata_uri().get(&(contract_name, latest_version)) - } else { - None + /// Address of the latest published version of `contract_name`. + /// Returns `Address::ZERO` when the name is unregistered. + #[pvm_contract_sdk::method] + pub fn get_address(&self, contract_name: String) -> Address { + let info = self.info.get(&contract_name); + if info.version_count == 0 { + return Address::ZERO; + } + self.published_address + .get(&(contract_name, info.version_count - 1)) } - } - /// Get the address of a specific version of a contract. - #[pvm::method] - pub fn get_address_at_version(contract_name: String, version: u32) -> Option
{ - Storage::published_address().get(&(contract_name, version)) - } + /// Metadata URI of the latest published version of `contract_name`. + /// Returns the empty string when the name is unregistered. + #[pvm_contract_sdk::method] + pub fn get_metadata_uri(&self, contract_name: String) -> String { + let info = self.info.get(&contract_name); + if info.version_count == 0 { + return String::new(); + } + self.published_metadata_uri + .get(&(contract_name, info.version_count - 1)) + } - /// Get the metadata URI of a specific version of a contract. - #[pvm::method] - pub fn get_metadata_uri_at_version(contract_name: String, version: u32) -> Option { - Storage::published_metadata_uri().get(&(contract_name, version)) - } + /// Address of a specific version of `contract_name`. + /// Returns `Address::ZERO` when the version is unregistered. + #[pvm_contract_sdk::method] + pub fn get_address_at_version(&self, contract_name: String, version: Version) -> Address { + self.published_address.get(&(contract_name, version)) + } - /// Get the contract name at a given index. - #[pvm::method] - pub fn get_contract_name_at(index: u32) -> String { - Storage::contract_name_at().get(&index).unwrap_or_default() - } + /// Metadata URI of a specific version of `contract_name`. + /// Returns the empty string when the version is unregistered. + #[pvm_contract_sdk::method] + pub fn get_metadata_uri_at_version( + &self, + contract_name: String, + version: Version, + ) -> String { + self.published_metadata_uri.get(&(contract_name, version)) + } - /// Search registered contract names by prefix. - #[pvm::method] - pub fn search_contract_names( - prefix: String, - offset: u32, - limit: u32, - ) -> ContractNameSearchPage { - let cap = if limit > MAX_SEARCH_LIMIT { - MAX_SEARCH_LIMIT - } else { - limit - }; - - if cap == 0 || prefix.as_bytes().len() > MAX_CONTRACT_NAME_LEN || !prefix.is_ascii() { - return ContractNameSearchPage { - names: alloc::vec::Vec::new(), - next_offset: offset, - done: true, - }; - } - - let upper = prefix_upper_bound(&prefix); - let to = match upper.as_ref() { - Some(bound) => Bound::Excluded(bound), - None => Bound::Unbounded, - }; - - let hits = Storage::contract_name_index().range( - Bound::Included(&prefix), - to, - offset as u64, - cap as u64, - ); - let returned = hits.len() as u32; - let names = hits.into_iter().map(|(name, _)| name).collect(); - - ContractNameSearchPage { - names, - next_offset: offset.saturating_add(returned), - done: returned < cap, + /// Contract name at a given index in the registration order. + #[pvm_contract_sdk::method] + pub fn get_contract_name_at(&self, index: u32) -> String { + self.contract_name_at.get(&index) } - } - /// Get the owner of a contract name. - #[pvm::method] - pub fn get_owner(contract_name: String) -> Address { - Storage::info() - .get(&contract_name) - .map(|i| i.owner) - .unwrap_or_default() - } + /// Owner of a contract name. Returns `Address::ZERO` when unregistered. + #[pvm_contract_sdk::method] + pub fn get_owner(&self, contract_name: String) -> Address { + self.info.get(&contract_name).owner + } - /// Get the version count for a contract name. - #[pvm::method] - pub fn get_version_count(contract_name: String) -> u32 { - Storage::info() - .get(&contract_name) - .map(|i| i.version_count) - .unwrap_or(0) - } + /// Number of versions published under `contract_name`. Zero means unregistered. + #[pvm_contract_sdk::method] + pub fn get_version_count(&self, contract_name: String) -> Version { + self.info.get(&contract_name).version_count + } - /// Get the number of contract names registered in the registry. - #[pvm::method] - pub fn get_contract_count() -> u32 { - Storage::contract_name_count().get().unwrap_or(0) + /// Number of distinct contract names registered. + #[pvm_contract_sdk::method] + pub fn get_contract_count(&self) -> u32 { + self.contract_name_count.get() + } + + fn caller(&self) -> Address { + let mut buf = [0u8; 20]; + self.host().caller(&mut buf); + Address(buf) + } } } diff --git a/src/lib/cdm/import-test/.cargo/config.toml b/src/lib/cdm/import-test/.cargo/config.toml new file mode 100644 index 0000000..0051bc9 --- /dev/null +++ b/src/lib/cdm/import-test/.cargo/config.toml @@ -0,0 +1,3 @@ +[env] +HOME = { value = "fixtures", relative = true, force = true } +PVM_ENTRY_POINT_CRATE = { value = "cdm-import-test", force = true } diff --git a/src/lib/cdm/import-test/Cargo.toml b/src/lib/cdm/import-test/Cargo.toml new file mode 100644 index 0000000..6931cb4 --- /dev/null +++ b/src/lib/cdm/import-test/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cdm-import-test" +version.workspace = true +edition.workspace = true +publish = false + +[[bin]] +name = "cdm-import-test" +path = "lib.rs" + +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[dependencies] +cdm = { workspace = true } +pvm-contract-sdk = { workspace = true } +pvm-cdm = { workspace = true } +polkavm-derive = { workspace = true } +picoalloc = { workspace = true } diff --git a/src/lib/cdm/import-test/cdm.json b/src/lib/cdm/import-test/cdm.json new file mode 100644 index 0000000..fb86fa6 --- /dev/null +++ b/src/lib/cdm/import-test/cdm.json @@ -0,0 +1,14 @@ +{ + "targets": { + "test": { + "asset-hub": "wss://example.invalid", + "bulletin": "wss://example.invalid", + "registry": null + } + }, + "dependencies": { + "test": { + "@test/sample": 1 + } + } +} diff --git a/src/lib/cdm/import-test/fixtures/.cdm/test/contracts/@test/sample/1/abi.json b/src/lib/cdm/import-test/fixtures/.cdm/test/contracts/@test/sample/1/abi.json new file mode 100644 index 0000000..8b5f1d4 --- /dev/null +++ b/src/lib/cdm/import-test/fixtures/.cdm/test/contracts/@test/sample/1/abi.json @@ -0,0 +1,46 @@ +[ + { + "type": "constructor", + "inputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "ping", + "inputs": [], + "outputs": [], + "stateMutability": "view" + }, + { + "type": "function", + "name": "tryGetValue", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "tuple", + "components": [ + { "name": "ok", "type": "bool" }, + { "name": "value", "type": "uint64" } + ] + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "trySetValue", + "inputs": [ + { + "name": "result", + "type": "tuple", + "components": [ + { "name": "ok", "type": "bool" }, + { "name": "value", "type": "uint64" } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + } +] diff --git a/src/lib/cdm/import-test/lib.rs b/src/lib/cdm/import-test/lib.rs new file mode 100644 index 0000000..5068210 --- /dev/null +++ b/src/lib/cdm/import-test/lib.rs @@ -0,0 +1,27 @@ +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] + +// Workspace-sibling integration test for `cdm::import!`. +// +// The macro is exercised end-to-end: it locates cdm.json at this crate's +// root, resolves @test/sample → target "test" → version 1, then reads +// $HOME/.cdm/test/contracts/@test/sample/1/abi.json. $HOME is redirected to +// ./fixtures via .cargo/config.toml, so the macro reads the in-tree fixture. +// +// The fixture abi.json includes tuple-bearing function signatures to +// exercise the codegen paths that fail under Bug 4 upstream. Once the +// upstream fix lands, `cargo pvm-contract build` against this crate becomes +// the only automated end-to-end check for the import macro. The harness +// contract block exists solely to satisfy the PVM bin scaffold (allocator + +// entry point); the test's signal is whether the build succeeds. + +cdm::import!("@test/sample"); + +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 1024)] +mod harness { + pub struct Harness {} + + impl Harness { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) {} + } +} diff --git a/src/lib/cdm/rust-macros/pvm-cdm-macros/Cargo.toml b/src/lib/cdm/rust-macros/pvm-cdm-macros/Cargo.toml new file mode 100644 index 0000000..5c46ac8 --- /dev/null +++ b/src/lib/cdm/rust-macros/pvm-cdm-macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pvm-cdm-macros" +description = "Proc-macros for the pvm-cdm consumer-side integration" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn.workspace = true +quote = "1.0" +proc-macro2 = "1.0" +keccak-const.workspace = true diff --git a/src/lib/cdm/rust-macros/pvm-cdm-macros/src/lib.rs b/src/lib/cdm/rust-macros/pvm-cdm-macros/src/lib.rs new file mode 100644 index 0000000..a923ed1 --- /dev/null +++ b/src/lib/cdm/rust-macros/pvm-cdm-macros/src/lib.rs @@ -0,0 +1,305 @@ +//! Proc-macro implementation for the `pvm-cdm` crate. +//! +//! The public entry point is [`reference!`], which attaches CDM-aware +//! constructors (`cdm_lookup`, `cdm_from_env`) as inherent methods on an +//! `abi_import!`-generated contract type. See the `pvm-cdm` crate docs for +//! usage. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{LitStr, Path, Token, parse::Parse, parse::ParseStream, parse_macro_input}; + +/// Compile-time selector for `getAddress(string)`, the one registry ABI +/// method this macro targets. Baked at build time of this crate. +const GET_ADDRESS_SELECTOR: [u8; 4] = { + let hash = keccak_const::Keccak256::new() + .update(b"getAddress(string)") + .finalize(); + [hash[0], hash[1], hash[2], hash[3]] +}; + +struct ReferenceInput { + contract_ty: Path, + cdm_name: String, +} + +impl Parse for ReferenceInput { + fn parse(input: ParseStream) -> syn::Result { + let contract_ty: Path = input.parse()?; + input.parse::()?; + let name_lit: LitStr = input.parse()?; + if !input.is_empty() { + input.parse::>()?; + if !input.is_empty() { + return Err(input.error("unexpected tokens after CDM package name")); + } + } + Ok(Self { + contract_ty, + cdm_name: name_lit.value(), + }) + } +} + +/// Attach `cdm_lookup()` and `cdm_from_env()` to an imported contract type. +/// +/// ```ignore +/// pvm_cdm::reference!(foo::Foo, "@example/foo"); +/// ``` +/// +/// Expands to an inherent `impl` block on `foo::Foo` with: +/// +/// - `pub fn cdm_lookup() -> Self` — runtime registry lookup via +/// `ContractRegistry.getAddress(string)`, with the registry address baked +/// from `CONTRACTS_REGISTRY_ADDR`. +/// - `pub fn cdm_from_env() -> Self` — address baked from `CDM_REGISTRY` +/// (`name=hex;name=hex;...`) at compile time. +/// +/// Both env vars are read with `option_env!`: if only one is set, the other +/// resolver's address falls back to the zero sentinel and panics at runtime +/// with a descriptive message when invoked. +#[proc_macro] +pub fn reference(input: TokenStream) -> TokenStream { + let ReferenceInput { + contract_ty, + cdm_name, + } = parse_macro_input!(input as ReferenceInput); + + let [s0, s1, s2, s3] = GET_ADDRESS_SELECTOR; + + let expanded: TokenStream2 = quote! { + impl #contract_ty< + ::pvm_contract_sdk::Pure, + (), + (), + false, + > { + /// Resolve this contract's address via a runtime call to the CDM registry. + /// + /// Panics if `CONTRACTS_REGISTRY_ADDR` was unset at build time, if the + /// registry call fails, or if the package isn't registered. + pub fn cdm_lookup() -> Self { + extern crate alloc; + + const fn __hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + b'A'..=b'F' => c - b'A' + 10, + _ => panic!("CDM address contains an invalid hex character"), + } + } + + const __REGISTRY_ADDR: [u8; 20] = match option_env!("CONTRACTS_REGISTRY_ADDR") { + Some(s) => { + let b = s.as_bytes(); + let off = if b.len() > 1 && b[0] == b'0' && (b[1] == b'x' || b[1] == b'X') { + 2 + } else { + 0 + }; + assert!( + b.len() - off == 40, + "CONTRACTS_REGISTRY_ADDR must be 40 hex chars (optional 0x prefix)" + ); + let mut r = [0u8; 20]; + let mut i = 0; + while i < 20 { + r[i] = __hex_nibble(b[off + i * 2]) << 4 + | __hex_nibble(b[off + i * 2 + 1]); + i += 1; + } + r + } + None => [0u8; 20], + }; + + if __REGISTRY_ADDR == [0u8; 20] { + panic!(concat!( + "cdm_lookup(): CONTRACTS_REGISTRY_ADDR env var must be set ", + "at build time to a 40-hex-char registry address" + )); + } + + let cdm_name: &str = #cdm_name; + let name_len = cdm_name.len(); + let padded_len = name_len.div_ceil(32) * 32; + + let mut calldata = alloc::vec![0u8; 4 + 32 + 32 + padded_len]; + calldata[0] = #s0; + calldata[1] = #s1; + calldata[2] = #s2; + calldata[3] = #s3; + calldata[4 + 24..4 + 32].copy_from_slice(&(32u64).to_be_bytes()); + calldata[4 + 32 + 24..4 + 32 + 32] + .copy_from_slice(&(name_len as u64).to_be_bytes()); + calldata[4 + 64..4 + 64 + name_len].copy_from_slice(cdm_name.as_bytes()); + + let mut output_buf = [0u8; 64]; + let mut output_ref: &mut [u8] = &mut output_buf[..]; + + let result = <::pvm_contract_sdk::PolkaVmHost as ::pvm_contract_sdk::HostApi>::call_evm( + &::pvm_contract_sdk::PolkaVmHost, + ::pvm_contract_sdk::CallFlags::ALLOW_REENTRY, + &__REGISTRY_ADDR, + u64::MAX, + &[0u8; 32], + &calldata, + Some(&mut output_ref), + ); + + match result { + Ok(()) => { + let is_some = output_buf[31] != 0; + if !is_some { + panic!("cdm_lookup: contract not found in registry"); + } + let mut addr = [0u8; 20]; + addr.copy_from_slice(&output_buf[44..64]); + Self::from_address(addr.into()) + } + Err(_) => panic!("cdm_lookup: registry call failed"), + } + } + + /// Resolve this contract's address from the compile-time `CDM_REGISTRY` + /// mapping (`name=hex;name=hex;...`). + /// + /// Panics at runtime if `CDM_REGISTRY` was unset or missing an entry + /// for this package's name. + pub fn cdm_from_env() -> Self { + const fn __hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + b'A'..=b'F' => c - b'A' + 10, + _ => panic!("CDM address contains an invalid hex character"), + } + } + + const __FROM_ENV_ADDR: [u8; 20] = { + const fn find_entry(entries: &[u8], name: &[u8]) -> Option { + let mut i = 0; + while i + name.len() < entries.len() { + let at_start = i == 0 || entries[i - 1] == b';'; + if at_start { + let mut j = 0; + while j < name.len() && entries[i + j] == name[j] { + j += 1; + } + if j == name.len() && entries[i + j] == b'=' { + return Some(i + j); + } + } + i += 1; + } + None + } + match option_env!("CDM_REGISTRY") { + Some(entries) => { + let bytes = entries.as_bytes(); + let name = #cdm_name.as_bytes(); + match find_entry(bytes, name) { + Some(eq) => { + let mut start = eq + 1; + if start + 1 < bytes.len() + && bytes[start] == b'0' + && (bytes[start + 1] == b'x' || bytes[start + 1] == b'X') + { + start += 2; + } + assert!( + start + 40 <= bytes.len(), + "CDM_REGISTRY entry is shorter than 40 hex chars" + ); + let mut r = [0u8; 20]; + let mut i = 0; + while i < 20 { + r[i] = __hex_nibble(bytes[start + i * 2]) << 4 + | __hex_nibble(bytes[start + i * 2 + 1]); + i += 1; + } + r + } + None => [0u8; 20], + } + } + None => [0u8; 20], + } + }; + + if __FROM_ENV_ADDR == [0u8; 20] { + panic!(concat!( + "cdm_from_env(): CDM_REGISTRY env var must be set at build time ", + "and contain an entry for `", #cdm_name, "=`" + )); + } + Self::from_address(__FROM_ENV_ADDR.into()) + } + } + }; + + expanded.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use quote::ToTokens; + + fn parse(src: &str) -> syn::Result { + syn::parse_str(src) + } + + #[test] + fn parses_simple_path_and_name() { + let input = parse(r#"Foo, "@ns/foo""#).unwrap(); + assert_eq!(input.contract_ty.to_token_stream().to_string(), "Foo"); + assert_eq!(input.cdm_name, "@ns/foo"); + } + + #[test] + fn parses_nested_module_path() { + let input = parse(r#"foo::Foo, "@ns/foo""#).unwrap(); + assert_eq!( + input.contract_ty.to_token_stream().to_string(), + "foo :: Foo" + ); + } + + #[test] + fn parses_absolute_path() { + let input = parse(r#"::my_crate::foo::Foo, "@ns/foo""#).unwrap(); + assert_eq!( + input.contract_ty.to_token_stream().to_string(), + ":: my_crate :: foo :: Foo" + ); + } + + #[test] + fn accepts_trailing_comma() { + parse(r#"Foo, "@ns/foo","#).unwrap(); + } + + #[test] + fn rejects_missing_name() { + assert!(parse("Foo").is_err()); + } + + #[test] + fn rejects_missing_type() { + assert!(parse(r#""@ns/foo""#).is_err()); + } + + #[test] + fn rejects_extra_args() { + assert!(parse(r#"Foo, "@ns/foo", extra"#).is_err()); + } + + #[test] + fn get_address_selector_matches_abi_spec() { + // keccak256("getAddress(string)")[..4] + assert_eq!(GET_ADDRESS_SELECTOR, [0xbf, 0x40, 0xfa, 0xc1]); + } +} diff --git a/src/lib/cdm/rust-macros/pvm-cdm/Cargo.toml b/src/lib/cdm/rust-macros/pvm-cdm/Cargo.toml new file mode 100644 index 0000000..f2344e2 --- /dev/null +++ b/src/lib/cdm/rust-macros/pvm-cdm/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pvm-cdm" +description = "Contract Dependency Manager integration for cargo-pvm-contract" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +pvm-cdm-macros = { path = "../pvm-cdm-macros" } + +[dev-dependencies] +pvm-contract-sdk.workspace = true diff --git a/src/lib/cdm/rust-macros/pvm-cdm/src/lib.rs b/src/lib/cdm/rust-macros/pvm-cdm/src/lib.rs new file mode 100644 index 0000000..650cf70 --- /dev/null +++ b/src/lib/cdm/rust-macros/pvm-cdm/src/lib.rs @@ -0,0 +1,54 @@ +//! Contract Dependency Manager (CDM) integration for `cargo-pvm-contract`. +//! +//! CDM publishes contracts under human-readable names (`@ns/name`) and resolves +//! those names to on-chain addresses via a registry contract. This crate layers +//! that resolution on top of the SDK's [`abi_import!`] output without the SDK +//! having to know CDM exists. +//! +//! # Producer side +//! +//! A contract declares its CDM identity in `Cargo.toml`: +//! +//! ```toml +//! [package.metadata.cdm] +//! package = "@example/hello-world" +//! ``` +//! +//! No attribute on the `#[contract]` macro, no ELF symbol, no SDK changes. +//! `cdm deploy` reads the mapping from `Cargo.toml` directly. +//! +//! # Consumer side +//! +//! Attach CDM resolution to an `abi_import!`-ed contract: +//! +//! ```ignore +//! pvm_contract_sdk::abi_import! { +//! #![abi_import(alloc = true)] +//! foo, +//! "../echo/target/release/foo.abi.json" +//! } +//! +//! pvm_cdm::reference!(foo::Foo, "@example/foo"); +//! +//! // At call sites: +//! let handle = foo::Foo::cdm_lookup(); // runtime registry call +//! let handle = foo::Foo::cdm_from_env(); // compile-time baked address +//! ``` +//! +//! The macro emits two inherent methods on the imported contract type: +//! +//! - `cdm_lookup()` — calls `ContractRegistry.getAddress(string)` via +//! [`PolkaVmHost::call_evm`]. Registry address comes from the +//! `CONTRACTS_REGISTRY_ADDR` env var (baked at compile time). +//! - `cdm_from_env()` — bakes the address directly from the `CDM_REGISTRY` env +//! var (a `name=hex;name=hex;...` mapping). Zero runtime overhead. +//! +//! Both env vars are optional. Each resolver only requires its own env var; +//! contracts using only one of the two still compile when the other is unset. +//! +//! [`abi_import!`]: https://docs.rs/pvm-contract-sdk +//! [`PolkaVmHost::call_evm`]: https://docs.rs/pvm-contract-sdk + +#![no_std] + +pub use pvm_cdm_macros::reference; diff --git a/src/lib/cdm/rust-macros/pvm-cdm/tests/reference_shape.rs b/src/lib/cdm/rust-macros/pvm-cdm/tests/reference_shape.rs new file mode 100644 index 0000000..eb9ddae --- /dev/null +++ b/src/lib/cdm/rust-macros/pvm-cdm/tests/reference_shape.rs @@ -0,0 +1,45 @@ +//! Shape test for `pvm_cdm::reference!`. +//! +//! Doesn't *run* the generated code (that would need a live PolkaVM host). +//! It only asserts the macro expansion type-checks against a stub that +//! mirrors what `abi_import!` emits — a 4-generic contract struct with a +//! `from_address` constructor on ``. +//! +//! If the stub drifts from the real `abi_import!` output, this test breaks +//! and signals the macro needs updating. That's the intent. + +use pvm_contract_sdk::{Address, Pure, SolDecode, SolEncode, StateMutability}; + +/// Minimal mirror of `abi_import!`'s output struct shape. +#[allow(dead_code)] +pub struct StubContract< + Mutability: StateMutability, + Inputs: SolEncode, + Outputs: SolDecode, + const INITIALIZED: bool, +> { + _address: Address, + _ph: core::marker::PhantomData<(Mutability, Inputs, Outputs)>, +} + +impl StubContract { + pub fn from_address(address: Address) -> Self { + Self { + _address: address, + _ph: core::marker::PhantomData, + } + } +} + +// This is the actual shape assertion. If the macro fails to expand, or +// expands to something that doesn't type-check against the stub, the test +// file fails to compile — that's the test. +pvm_cdm::reference!(StubContract, "@test/stub"); + +#[test] +fn methods_exist_on_stub() { + // Reference the generated methods as fn pointers so that removing either + // cdm_lookup or cdm_from_env from the expansion fails the test. + let _lookup: fn() -> StubContract = StubContract::cdm_lookup; + let _from_env: fn() -> StubContract = StubContract::cdm_from_env; +} diff --git a/src/lib/cdm/rust-macros/src/lib.rs b/src/lib/cdm/rust-macros/src/lib.rs index b95216d..3e8a147 100644 --- a/src/lib/cdm/rust-macros/src/lib.rs +++ b/src/lib/cdm/rust-macros/src/lib.rs @@ -49,6 +49,26 @@ fn derive_module_name(package_name: &str) -> String { .replace('-', "_") } +// Matches `pvm_contract_macros::utils::to_pascal_case`: the upstream +// `abi_import!` macro applies pascal-case to the user-supplied name when +// minting the generated contract struct type, so we mirror that here to +// produce the path `::` for `pvm_cdm::reference!`. +fn to_pascal_case(snake: &str) -> String { + let mut out = String::with_capacity(snake.len()); + let mut capitalize_next = true; + for ch in snake.chars() { + if ch == '_' { + capitalize_next = true; + } else if capitalize_next { + out.extend(ch.to_uppercase()); + capitalize_next = false; + } else { + out.push(ch); + } + } + out +} + #[proc_macro] pub fn import(input: TokenStream) -> TokenStream { let lit: syn::LitStr = match syn::parse(input) { @@ -214,10 +234,16 @@ pub fn import(input: TokenStream) -> TokenStream { } let module_name = derive_module_name(&package_name); + let module_ident = syn::Ident::new(&module_name, proc_macro2::Span::call_site()); + let contract_ident = syn::Ident::new( + &to_pascal_case(&module_name), + proc_macro2::Span::call_site(), + ); let abi_path_str = abi_path.to_string_lossy().to_string(); quote! { - pvm::abi_import!(#module_name, #abi_path_str, cdm = #package_name); + pvm_contract_sdk::abi_import!(#module_ident, #abi_path_str); + pvm_cdm::reference!(#module_ident::#contract_ident, #package_name); } .into() } diff --git a/src/lib/contracts/src/abi/registry.ts b/src/lib/contracts/src/abi/registry.ts index ec3b76f..f94f6bf 100644 --- a/src/lib/contracts/src/abi/registry.ts +++ b/src/lib/contracts/src/abi/registry.ts @@ -5,7 +5,7 @@ import type { AbiEntry } from "@parity/product-sdk-contracts"; * * Source of truth: `src/contract/src/lib.rs` compiled to PolkaVM; this array * mirrors the Solidity-ABI export produced by `cargo pvm-contract build` - * (`target/contract-registry.release.abi.json`). Keep it bit-for-bit identical + * (`target/release/contract-registry.abi.json`). Keep it bit-for-bit identical * to the on-chain metadata — editing this file by hand will desync it from * the registry deployed on every network. * @@ -34,32 +34,14 @@ export const CONTRACTS_REGISTRY_ABI: AbiEntry[] = [ type: "function", name: "getAddress", inputs: [{ name: "contract_name", type: "string" }], - outputs: [ - { - name: "", - type: "tuple", - components: [ - { name: "isSome", type: "bool" }, - { name: "value", type: "address" }, - ], - }, - ], + outputs: [{ name: "", type: "address" }], stateMutability: "view", }, { type: "function", name: "getMetadataUri", inputs: [{ name: "contract_name", type: "string" }], - outputs: [ - { - name: "", - type: "tuple", - components: [ - { name: "isSome", type: "bool" }, - { name: "value", type: "string" }, - ], - }, - ], + outputs: [{ name: "", type: "string" }], stateMutability: "view", }, { @@ -69,16 +51,7 @@ export const CONTRACTS_REGISTRY_ABI: AbiEntry[] = [ { name: "contract_name", type: "string" }, { name: "version", type: "uint32" }, ], - outputs: [ - { - name: "", - type: "tuple", - components: [ - { name: "isSome", type: "bool" }, - { name: "value", type: "address" }, - ], - }, - ], + outputs: [{ name: "", type: "address" }], stateMutability: "view", }, { @@ -88,16 +61,7 @@ export const CONTRACTS_REGISTRY_ABI: AbiEntry[] = [ { name: "contract_name", type: "string" }, { name: "version", type: "uint32" }, ], - outputs: [ - { - name: "", - type: "tuple", - components: [ - { name: "isSome", type: "bool" }, - { name: "value", type: "string" }, - ], - }, - ], + outputs: [{ name: "", type: "string" }], stateMutability: "view", }, { diff --git a/src/lib/contracts/src/detection.ts b/src/lib/contracts/src/detection.ts index 1577d1e..0d70a29 100644 --- a/src/lib/contracts/src/detection.ts +++ b/src/lib/contracts/src/detection.ts @@ -71,6 +71,7 @@ interface CargoPackage { manifest_path: string; dependencies: CargoDependency[]; targets: CargoTarget[]; + metadata: Record | null; } interface CargoDependency { @@ -100,33 +101,38 @@ function getCargoMetadata(rootDir: string): CargoMetadata { /** * Check if a package is a deployable PVM contract. * A PVM contract must: - * 1. Have pvm_contract as a normal (non-dev, non-build) dependency + * 1. Have pvm-contract-sdk as a normal (non-dev, non-build) dependency * 2. Have a binary target (lib-only crates like shared type libraries are excluded) */ function isPvmContract(pkg: CargoPackage): boolean { const hasPvmDep = pkg.dependencies.some( - (d) => d.name === "pvm_contract" && (d.kind === null || d.kind === "normal"), + (d) => + (d.name === "pvm-contract-sdk" || d.name === "pvm_contract") && + (d.kind === null || d.kind === "normal"), ); const hasBinTarget = pkg.targets.some((t) => t.kind.includes("bin")); return hasPvmDep && hasBinTarget; } /** - * Read CDM package name from post-build .cdm.json file. - * Returns null if contract has no CDM annotation or hasn't been built yet. + * Read CDM package name from a contract's Cargo.toml metadata. * - * The .cdm.json file is generated by cargo-pvm-contract during compilation - * by extracting the __PVM_CDM symbol from the compiled ELF. + * The expected shape is: + * [package.metadata.cdm-package] + * name = "@scope/contract" + * + * The pre-sm/mut SDK extracted this from a `__PVM_CDM` ELF symbol planted by + * `#[pvm::contract(cdm = "...")]`. The new SDK's `#[contract]` macro doesn't + * accept that argument, so the canonical source is now Cargo.toml metadata, + * which `cargo metadata` already surfaces. */ -export function readCdmPackage(rootDir: string, crateName: string): string | null { - const cdmPath = resolve(rootDir, `target/${crateName}.release.cdm.json`); - if (!existsSync(cdmPath)) return null; - try { - const data = JSON.parse(readFileSync(cdmPath, "utf-8")); - return data.cdmPackage ?? null; - } catch { - return null; - } +function extractCdmPackage(pkg: CargoPackage): string | null { + const meta = pkg.metadata; + if (!meta || typeof meta !== "object") return null; + const cdm = (meta as Record)["cdm-package"]; + if (!cdm || typeof cdm !== "object") return null; + const name = (cdm as Record).name; + return typeof name === "string" ? name : null; } /** @@ -136,7 +142,7 @@ export function readCdmPackage(rootDir: string, crateName: string): string | nul * - Workspace members come from cargo metadata (not recursive file scanning) * - PVM contract detection uses resolved dependency graph (not string matching) * - Inter-contract dependencies come from Cargo.toml (not regex on source) - * - CDM package names come from .cdm.json files (not regex on source) + * - CDM package names come from `[package.metadata.cdm-package]` in Cargo.toml */ export function detectContracts(rootDir: string): ContractInfo[] { const meta = getCargoMetadata(rootDir); @@ -164,7 +170,7 @@ export function detectContracts(rootDir: string): ContractInfo[] { name: pkg.name, displayName: pkg.name, toolchain: "rust", - cdmPackage: readCdmPackage(rootDir, pkg.name), + cdmPackage: extractCdmPackage(pkg), description: pkg.description, authors: pkg.authors, homepage: pkg.homepage, diff --git a/src/lib/contracts/src/index.ts b/src/lib/contracts/src/index.ts index 0d41882..36a8ec8 100644 --- a/src/lib/contracts/src/index.ts +++ b/src/lib/contracts/src/index.ts @@ -10,7 +10,6 @@ export { createCrateToPackageMap, detectDeploymentOrder, detectDeploymentOrderLayered, - readCdmPackage, getGitRemoteUrl, readReadmeContent, } from "./detection"; diff --git a/src/lib/contracts/src/pipeline.ts b/src/lib/contracts/src/pipeline.ts index dabc251..382fd94 100644 --- a/src/lib/contracts/src/pipeline.ts +++ b/src/lib/contracts/src/pipeline.ts @@ -18,7 +18,6 @@ import { buildDependencyGraph, detectDeploymentOrderLayered, getGitRemoteUrl, - readCdmPackage, readReadmeContent, toposortLayers, } from "./detection"; @@ -292,21 +291,6 @@ function assertUniqueCdmPackages(contracts: ContractInfo[]): void { } } -function assertUniqueBuiltCdmPackages(build: BuildPhaseResult): void { - const seen = new Map(); - for (const [crate, info] of build.info) { - if (build.failed.has(crate) || !info.cdmPackage) continue; - - const previous = seen.get(info.cdmPackage); - if (previous) { - throw new Error( - `Duplicate CDM package "${info.cdmPackage}" declared by ${previous} and ${crate}`, - ); - } - seen.set(info.cdmPackage, crate); - } -} - /** * Detect every buildable contract target in the workspace and normalize all * dependency edges into crate/target names. Rust contributes Cargo dependency @@ -427,7 +411,7 @@ async function buildRustTargets( for (const result of results) { if (result.success) { - const pvmPath = resolve(rootDir, `target/${result.crateName}.release.polkavm`); + const pvmPath = resolve(rootDir, `target/release/${result.crateName}.polkavm`); let bytecodeSize: number | undefined; try { bytecodeSize = readFileSync(pvmPath).length; @@ -436,20 +420,13 @@ async function buildRustTargets( } const contract = detected.contractMap.get(result.crateName); - const cdmPackage = - readCdmPackage(rootDir, result.crateName) ?? - detected.cdmPackageMap.get(result.crateName) ?? - null; - if (cdmPackage) { - detected.cdmPackageMap.set(result.crateName, cdmPackage); - if (contract) contract.cdmPackage = cdmPackage; - } + const cdmPackage = detected.cdmPackageMap.get(result.crateName) ?? null; build.successful.push(result.crateName); build.info.set(result.crateName, { durationMs: result.durationMs, pvmPath, - abiPath: resolve(rootDir, `target/${result.crateName}.release.abi.json`), + abiPath: resolve(rootDir, `target/release/${result.crateName}.abi.json`), toolchain: "rust", displayName: cdmPackage ?? contract?.displayName ?? result.crateName, sourcePath: contract?.path, @@ -680,7 +657,6 @@ async function runDetectedBuild( await onLayerBuilt?.({ layerIndex, layer, builtCrates, build }); } - assertUniqueBuiltCdmPackages(build); return { build, crates }; } @@ -1002,7 +978,6 @@ export async function deployContracts(opts: DeployContractsOptions): Promise null), getGitRemoteUrl: vi.fn(() => "https://github.com/test/repo"), readReadmeContent: vi.fn(() => ""), }; @@ -1427,9 +1401,7 @@ if (import.meta.vitest) { })); const { pvmContractBuildAsync: mockBuild } = await import("./builder"); - const { readCdmPackage: mockReadCdm, detectDeploymentOrderLayered: mockDetect } = await import( - "./detection" - ); + const { detectDeploymentOrderLayered: mockDetect } = await import("./detection"); const { buildSolidityToolchain: mockBuildSolidity, detectSolidityBuildTargets: mockDetectSolidity, @@ -1478,7 +1450,6 @@ if (import.meta.vitest) { stderr: "", durationMs: 100, })); - (mockReadCdm as any).mockReturnValue(null); }); describe("buildContracts", () => { @@ -1557,14 +1528,6 @@ if (import.meta.vitest) { ); }); - test("rejects duplicate CDM package names discovered after build", async () => { - (mockDetect as any).mockReturnValue(makeOrder([["a", "b"]])); - (mockReadCdm as any).mockReturnValue("@example/counter"); - await expect(buildContracts({ rootDir: "/fake" })).rejects.toThrow( - 'Duplicate CDM package "@example/counter"', - ); - }); - test("features option is forwarded to pvmContractBuildAsync", async () => { (mockDetect as any).mockReturnValue(makeOrder([["a"]])); await buildContracts({ rootDir: "/fake", features: "my-feature" }); @@ -1650,10 +1613,6 @@ if (import.meta.vitest) { (mockDetect as any).mockReturnValue( makeOrder([["a"], ["b"]], { b: ["a"] }, { a: "@example/a", b: "@example/b" }), ); - (mockReadCdm as any).mockImplementation( - (_root: string, crate: string) => - ({ a: "@example/a", b: "@example/b" })[crate] ?? null, - ); const events: string[] = []; await deployContracts({ diff --git a/src/lib/contracts/tests/detection.test.ts b/src/lib/contracts/tests/detection.test.ts index fa5d87f..adad95d 100644 --- a/src/lib/contracts/tests/detection.test.ts +++ b/src/lib/contracts/tests/detection.test.ts @@ -1,27 +1,12 @@ -import { describe, test, expect, beforeAll } from "vitest"; -import { - detectContracts, - buildDependencyGraph, - toposort, - detectDeploymentOrder, -} from "../src/detection"; +import { describe, test, expect } from "vitest"; +import { detectContracts, buildDependencyGraph, detectDeploymentOrder } from "../src/detection"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { rmSync } from "node:fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_DIR = resolve(__dirname, "../../../templates/shared-counter"); describe("detection via cargo metadata", () => { - beforeAll(() => { - const targetDir = resolve(TEMPLATE_DIR, "target"); - for (const name of ["counter", "counter_reader", "counter_writer"]) { - try { - rmSync(resolve(targetDir, `${name}.release.cdm.json`)); - } catch {} - } - }); - test("detects all 3 contracts in shared-counter template", () => { const contracts = detectContracts(TEMPLATE_DIR); const names = contracts.map((c) => c.name).sort(); @@ -43,10 +28,14 @@ describe("detection via cargo metadata", () => { expect(order.crateNames.length).toBe(3); }); - test("CDM packages are null before build (no .cdm.json files)", () => { + test("CDM package names come from [package.metadata.cdm-package] in Cargo.toml", () => { + // After the new SDK migration, cdmPackage is resolved at detection + // time from `[package.metadata.cdm-package]` (surfaced by `cargo + // metadata`), not from a post-build `.cdm.json` artifact. const contracts = detectContracts(TEMPLATE_DIR); - for (const c of contracts) { - expect(c.cdmPackage).toBeNull(); - } + const byName = new Map(contracts.map((c) => [c.name, c.cdmPackage])); + expect(byName.get("counter")).toBe("@example/counter"); + expect(byName.get("counter_writer")).toBe("@example/counter-writer"); + expect(byName.get("counter_reader")).toBe("@example/counter-reader"); }); }); diff --git a/src/lib/contracts/tests/e2e/harness.ts b/src/lib/contracts/tests/e2e/harness.ts new file mode 100644 index 0000000..47e254b --- /dev/null +++ b/src/lib/contracts/tests/e2e/harness.ts @@ -0,0 +1,160 @@ +// E2E test harness: spawn revive-dev-node, deploy the registry, tear down. +// +// Modelled on cargo-pvm-contract's `pvm-contract-e2e-tests::SubstrateDevNode` +// (per-test port allocation + Drop kills the child), but native to the CDM TS +// stack so vitest can drive it directly. Each call to `spawnReviveNode()` +// returns a fresh `NodeHandle` whose `.kill()` is meant to run in an +// `afterAll` hook. +// +// Requires: +// - `revive-dev-node` on $PATH (install: +// cargo install --git https://github.com/paritytech/polkadot-sdk --bin revive-dev-node) +// - `@dotdm/contracts` + `@dotdm/env` + `@dotdm/utils` dist/ built (pnpm -r build) +// - the registry .polkavm binary (built lazily on first `deployRegistry()` call +// via `make build-registry`) + +import { spawn, execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import type { HexString } from "polkadot-api"; + +// Deploy via `bun run src/lib/scripts/deploy-registry.ts` rather than +// invoking `ContractDeployer` programmatically: the deploy dry-run behaves +// differently under Node than under Bun on the dev-node — Node reports +// `Module(Revive(StackUnderflow))` for the exact same code path that +// succeeds under Bun. Until the divergence is rooted out (likely papi's +// encoding of the upload payload), the script is the canonical deploy path. + +const execFileAsync = promisify(execFile); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// src/lib/contracts/tests/e2e -> repo root is 5 levels up +const ROOT_DIR = resolve(__dirname, "../../../../.."); +const REGISTRY_PVM = resolve(ROOT_DIR, "target/release/contract-registry.polkavm"); + +// Per-process port counter. Different vitest workers would collide; we don't +// fan out e2e suites across workers today (vitest.e2e.config.ts pins +// `--no-file-parallelism`). +let nextPort = 29545; +function allocatePort(): number { + return nextPort++; +} + +export interface NodeHandle { + wsUrl: string; + port: number; + /** SIGTERM the child; SIGKILL after 3s if it hasn't exited. */ + kill(): Promise; +} + +async function pollRpcReady(port: number, timeoutMs = 60_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`http://127.0.0.1:${port}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "system_chain", + params: [], + }), + signal: AbortSignal.timeout(2000), + }); + if (res.ok) { + const json = (await res.json()) as { result?: string }; + if (typeof json.result === "string") return; + } + } catch { + // not ready + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`revive-dev-node did not become ready on port ${port} within ${timeoutMs}ms`); +} + +export async function spawnReviveNode(): Promise { + const port = allocatePort(); + const child = spawn( + "revive-dev-node", + ["--dev", "--rpc-port", String(port), "--no-prometheus", "--log", "error"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + if (child.pid === undefined) { + throw new Error( + "Failed to spawn `revive-dev-node`. Install:\n" + + " cargo install --git https://github.com/paritytech/polkadot-sdk --bin revive-dev-node", + ); + } + + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", (err) => { + throw new Error( + `revive-dev-node spawn error: ${err.message}\n(node stderr so far: ${stderr})`, + ); + }); + + try { + await pollRpcReady(port); + } catch (e) { + child.kill(); + throw new Error(`${(e as Error).message}\n(node stderr: ${stderr})`); + } + + return { + wsUrl: `ws://127.0.0.1:${port}`, + port, + async kill() { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + for (let i = 0; i < 30; i++) { + if (child.exitCode !== null) return; + await new Promise((r) => setTimeout(r, 100)); + } + child.kill("SIGKILL"); + }, + }; +} + +async function ensureRegistryBuilt(): Promise { + if (existsSync(REGISTRY_PVM)) return; + await execFileAsync("make", ["build-registry"], { + cwd: ROOT_DIR, + maxBuffer: 16 * 1024 * 1024, + }); + if (!existsSync(REGISTRY_PVM)) { + throw new Error( + `Registry .polkavm not produced at ${REGISTRY_PVM} after make build-registry`, + ); + } +} + +/** + * Build the registry (if needed) and deploy it via CREATE2 against `wsUrl`. + * Returns the deployed contract address. + * + * Spawns `bun run src/lib/scripts/deploy-registry.ts`. See the import-site + * note above for why we don't invoke `ContractDeployer` directly under Node. + */ +export async function deployRegistry(wsUrl: string): Promise { + await ensureRegistryBuilt(); + const { stdout } = await execFileAsync( + "bun", + ["run", "src/lib/scripts/deploy-registry.ts", "--assethub-url", wsUrl], + { cwd: ROOT_DIR, maxBuffer: 16 * 1024 * 1024 }, + ); + const match = stdout.match(/^CONTRACTS_REGISTRY_ADDR=(0x[a-fA-F0-9]+)/m); + if (!match) { + throw new Error( + `Could not parse registry address from deploy-registry.ts output:\n${stdout}`, + ); + } + return match[1] as HexString; +} diff --git a/src/lib/contracts/tests/e2e/registry.e2e.test.ts b/src/lib/contracts/tests/e2e/registry.e2e.test.ts new file mode 100644 index 0000000..828d4df --- /dev/null +++ b/src/lib/contracts/tests/e2e/registry.e2e.test.ts @@ -0,0 +1,162 @@ +// End-to-end registry validation against a live `revive-dev-node`. +// +// The suite spawns a fresh node + deploys the registry once (`beforeAll`), +// then drives every public method via the same code path CDM's TS pipeline +// uses (`createContractFromClient` + the embedded `CONTRACTS_REGISTRY_ABI`). +// Each assertion is its own `test()` for granular failure attribution. +// +// Gated to the `e2e/` directory by `vitest.e2e.config.ts` so `pnpm test` +// (unit only) stays fast; run this suite via `pnpm test:e2e`. + +import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import type { HexString } from "polkadot-api"; +import { createCdmAssetHubClient, prepareSigner, type CdmChainClient } from "@dotdm/env"; +import { ALICE_SS58 } from "@dotdm/utils"; +// Subpath `@dotdm/contracts/abi` rather than the package root: vitest's +// Vite resolver tree-shakes the package's `dist/index.js` (re-exports + +// Node-only deps in adjacent modules) and CONTRACTS_REGISTRY_ABI comes back +// undefined. The dedicated subpath maps straight to `dist/abi.js`. +import { CONTRACTS_REGISTRY_ABI } from "@dotdm/contracts/abi"; +import { createContractFromClient } from "@parity/product-sdk-contracts"; +import { spawnReviveNode, deployRegistry, type NodeHandle } from "./harness"; + +const NAME = "@test/roundtrip"; +const ADDR = "0x1111111111111111111111111111111111111111" as HexString; +// 59-byte URI exercises the spilled-chunks (long-form) Solidity string layout. +const URI = "ipfs://bafy2bzaceblahblahQmExampleLongCidExercisingSpilledChunks"; +const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; + +let node: NodeHandle; +let chainClient: CdmChainClient; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let registry: any; + +function lc(v: unknown): string { + return String(v).toLowerCase(); +} + +beforeAll(async () => { + node = await spawnReviveNode(); + const registryAddress = await deployRegistry(node.wsUrl); + + const signer = prepareSigner("Alice"); + chainClient = await createCdmAssetHubClient(node.wsUrl, "local"); + await chainClient.raw.assetHub.getChainSpecData(); + registry = await createContractFromClient( + chainClient.raw.assetHub, + chainClient.descriptors.assetHub, + registryAddress, + CONTRACTS_REGISTRY_ABI, + { defaultSigner: signer, defaultOrigin: ALICE_SS58 }, + ); +}, 180_000); + +afterAll(async () => { + chainClient?.destroy(); + await node?.kill(); +}); + +describe("registry pre-publish (empty state)", () => { + test("getVersionCount of an unregistered name returns 0", async () => { + const r = await registry.getVersionCount.query(NAME); + expect(r.success).toBe(true); + expect(r.value).toBe(0); + }); + + test("getAddress of an unregistered name returns Address::ZERO", async () => { + const r = await registry.getAddress.query(NAME); + expect(r.success).toBe(true); + expect(lc(r.value)).toBe(ZERO_ADDR); + }); + + test("getMetadataUri of an unregistered name returns empty string", async () => { + const r = await registry.getMetadataUri.query(NAME); + expect(r.success).toBe(true); + expect(r.value).toBe(""); + }); + + test("getContractCount is 0", async () => { + const r = await registry.getContractCount.query(); + expect(r.value).toBe(0); + }); +}); + +describe("registry publishLatest + post-publish queries", () => { + test("publishLatest tx finalizes", async () => { + const r = await registry.publishLatest.tx(NAME, ADDR, URI); + expect(r.ok).toBe(true); + }); + + test("getVersionCount is now 1", async () => { + const r = await registry.getVersionCount.query(NAME); + expect(r.value).toBe(1); + }); + + test("getAddress returns the published address", async () => { + const r = await registry.getAddress.query(NAME); + expect(lc(r.value)).toBe(lc(ADDR)); + }); + + test("getMetadataUri returns the long-form URI (exercises spilled chunks)", async () => { + const r = await registry.getMetadataUri.query(NAME); + expect(r.value).toBe(URI); + }); + + test("getAddressAtVersion(0) matches the latest address", async () => { + const r = await registry.getAddressAtVersion.query(NAME, 0); + expect(lc(r.value)).toBe(lc(ADDR)); + }); + + test("getMetadataUriAtVersion(0) matches the latest URI", async () => { + const r = await registry.getMetadataUriAtVersion.query(NAME, 0); + expect(r.value).toBe(URI); + }); + + test("getContractNameAt(0) returns the registered name", async () => { + const r = await registry.getContractNameAt.query(0); + expect(r.value).toBe(NAME); + }); + + test("getOwner returns a non-zero address (the signer's mapped EVM addr)", async () => { + const r = await registry.getOwner.query(NAME); + expect(typeof r.value).toBe("string"); + expect(lc(r.value)).not.toBe(ZERO_ADDR); + }); + + test("getContractCount is now 1", async () => { + const r = await registry.getContractCount.query(); + expect(r.value).toBe(1); + }); +}); + +describe("registry searchContractNames (linear-scan prefix match)", () => { + function unpackPage(value: unknown): { names: string[]; nextOffset: number; done: boolean } { + if (Array.isArray(value)) { + return { + names: (value[0] as string[]) ?? [], + nextOffset: Number(value[1] ?? 0), + done: Boolean(value[2]), + }; + } + const v = value as { names?: string[]; next_offset?: number; done?: boolean }; + return { + names: v.names ?? [], + nextOffset: Number(v.next_offset ?? 0), + done: Boolean(v.done), + }; + } + + test("matching prefix returns the registered name with done=true", async () => { + const r = await registry.searchContractNames.query("@test/", 0, 10); + expect(r.success).toBe(true); + const page = unpackPage(r.value); + expect(page.names).toContain(NAME); + expect(page.done).toBe(true); + }); + + test("non-matching prefix returns an empty page", async () => { + const r = await registry.searchContractNames.query("@nonexistent/", 0, 10); + const page = unpackPage(r.value); + expect(page.names).toEqual([]); + }); +}); diff --git a/src/lib/contracts/tests/install-roundtrip.test.ts b/src/lib/contracts/tests/install-roundtrip.test.ts new file mode 100644 index 0000000..e1686e5 --- /dev/null +++ b/src/lib/contracts/tests/install-roundtrip.test.ts @@ -0,0 +1,145 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { saveContract, getContractDir } from "../src/store"; + +// Inlined to avoid pulling deployer.ts (which transitively imports @dotdm/env +// and @dotdm/utils — workspace packages whose dist/ isn't built in a fresh +// checkout). These mirror the AbiEntry/Metadata shapes in src/deployer.ts. +interface AbiParam { + name: string; + type: string; + components?: AbiParam[]; +} +interface AbiEntry { + type: string; + name?: string; + inputs: AbiParam[]; + outputs?: AbiParam[]; + stateMutability?: string; + anonymous?: boolean; +} +interface Metadata { + publish_block: number; + published_at: string; + description: string; + readme: string; + authors: string[]; + homepage: string; + repository: string; + abi: AbiEntry[]; +} + +// Representative ABI exercising the new SDK's (bool, T) tuple result shape: +// tuple types in inputs/outputs, nested components, and an event entry. +const sampleAbi: AbiEntry[] = [ + { + type: "constructor", + name: "new", + inputs: [{ name: "owner", type: "address" }], + outputs: [], + stateMutability: "payable", + }, + { + type: "function", + name: "get", + inputs: [], + outputs: [{ name: "", type: "(bool,u32)" }], + stateMutability: "view", + }, + { + type: "function", + name: "set_with_meta", + inputs: [ + { name: "value", type: "(bool,u128)" }, + { + name: "meta", + type: "tuple", + components: [ + { name: "ok", type: "bool" }, + { name: "payload", type: "(bool,string)" }, + ], + }, + ], + outputs: [{ name: "", type: "(bool,Result)" }], + stateMutability: "nonpayable", + }, + { + type: "event", + name: "Set", + inputs: [{ name: "by", type: "address" }], + anonymous: false, + }, +]; + +const sampleMetadata: Metadata = { + publish_block: 42, + published_at: "2026-05-14T00:00:00Z", + description: "Round-trip fixture", + readme: "# Hi", + authors: ["alice"], + homepage: "https://example.com", + repository: "https://example.com/repo", + abi: sampleAbi, +}; + +describe("install round-trip preserves new-shape ABI", () => { + let cdmRoot: string; + let prevCdmRoot: string | undefined; + + beforeEach(() => { + cdmRoot = mkdtempSync(resolve(tmpdir(), "cdm-install-roundtrip-")); + prevCdmRoot = process.env.CDM_ROOT; + process.env.CDM_ROOT = cdmRoot; + }); + + afterEach(() => { + if (prevCdmRoot === undefined) delete process.env.CDM_ROOT; + else process.env.CDM_ROOT = prevCdmRoot; + rmSync(cdmRoot, { recursive: true, force: true }); + }); + + test("publish encode is structurally identical after decode", () => { + // Mirrors publisher.ts: JSON.stringify(metadata) + UTF-8 encode → bytes go + // through Bulletin → identical bytes come back via the IPFS gateway. + const encoded = new TextEncoder().encode(JSON.stringify(sampleMetadata)); + const decoded = new TextDecoder().decode(encoded); + const parsed = JSON.parse(decoded); + expect(parsed).toEqual(sampleMetadata); + }); + + test("install fetch → save → re-read preserves ABI structurally", () => { + // Mirrors install-pipeline.ts: (await ipfs.fetch(cid)).json() then saveContract. + const encoded = new TextEncoder().encode(JSON.stringify(sampleMetadata)); + const fetched = JSON.parse(new TextDecoder().decode(encoded)) as Record; + const fetchedAbi = fetched.abi as AbiEntry[]; + + const savedPath = saveContract({ + targetHash: "deadbeef", + library: "round_trip_fixture", + version: 0, + abi: fetchedAbi, + metadata: fetched, + address: "0x0000000000000000000000000000000000000001", + metadataCid: "bafy-test-cid", + }); + + expect(savedPath).toBe(getContractDir("deadbeef", "round_trip_fixture", 0)); + + const rereadAbi = JSON.parse(readFileSync(resolve(savedPath, "abi.json"), "utf-8")); + expect(rereadAbi).toEqual(sampleAbi); + + const rereadMeta = JSON.parse(readFileSync(resolve(savedPath, "metadata.json"), "utf-8")); + expect(rereadMeta).toEqual(sampleMetadata); + + const info = JSON.parse(readFileSync(resolve(savedPath, "info.json"), "utf-8")); + expect(info).toEqual({ + name: "round_trip_fixture", + targetHash: "deadbeef", + version: 0, + address: "0x0000000000000000000000000000000000000001", + metadataCid: "bafy-test-cid", + }); + }); +}); diff --git a/src/lib/scripts/deploy-registry.ts b/src/lib/scripts/deploy-registry.ts index 08861c3..43d92d5 100644 --- a/src/lib/scripts/deploy-registry.ts +++ b/src/lib/scripts/deploy-registry.ts @@ -46,7 +46,7 @@ if (!assethubUrl) { } const rootDir = resolve(import.meta.dir, "../../.."); -const pvmPath = resolve(rootDir, `target/${CONTRACTS_REGISTRY_CRATE}.release.polkavm`); +const pvmPath = resolve(rootDir, `target/release/${CONTRACTS_REGISTRY_CRATE}.polkavm`); if (!existsSync(pvmPath)) { console.error(`Registry not built: ${pvmPath}`); diff --git a/src/lib/scripts/package.json b/src/lib/scripts/package.json index ab6a846..0048ab0 100644 --- a/src/lib/scripts/package.json +++ b/src/lib/scripts/package.json @@ -10,7 +10,8 @@ "dependencies": { "@dotdm/contracts": "workspace:*", "@dotdm/env": "workspace:*", - "@dotdm/utils": "workspace:*" + "@dotdm/utils": "workspace:*", + "@parity/product-sdk-contracts": "catalog:" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/src/templates/guide/Cargo.toml b/src/templates/guide/Cargo.toml index 5b5e4cf..f567d34 100644 --- a/src/templates/guide/Cargo.toml +++ b/src/templates/guide/Cargo.toml @@ -9,8 +9,8 @@ homepage = "https://polkadot.com/" edition = "2024" [workspace.dependencies] -cdm = { git = "https://github.com/paritytech/contract-dependency-manager" } -pvm_contract = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "charles/cdm-integration" } +cdm = { path = "../../lib/cdm/rust" } +pvm-contract-sdk = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "sm/cdm", features = ["alloc"] } +pvm-cdm = { path = "../../lib/cdm/rust-macros/pvm-cdm" } polkavm-derive = "0.31" -parity-scale-codec = { version = "3.7", default-features = false, features = ["derive"] } picoalloc = "5.2" diff --git a/src/templates/guide/contracts/app-api/Cargo.toml b/src/templates/guide/contracts/app-api/Cargo.toml index b98e322..e039920 100644 --- a/src/templates/guide/contracts/app-api/Cargo.toml +++ b/src/templates/guide/contracts/app-api/Cargo.toml @@ -13,10 +13,13 @@ path = "lib.rs" name = "app-api" path = "lib.rs" +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[package.metadata.cdm-package] +name = "@example/app-api" + [dependencies] -cdm = { workspace = true } -pvm_contract = { workspace = true } +pvm-contract-sdk = { workspace = true } polkavm-derive = { workspace = true } -parity-scale-codec = { workspace = true } picoalloc = { workspace = true } -support_contract = { path = "../support_contract" } diff --git a/src/templates/guide/contracts/app-api/lib.rs b/src/templates/guide/contracts/app-api/lib.rs index dae28ad..dc72d6e 100644 --- a/src/templates/guide/contracts/app-api/lib.rs +++ b/src/templates/guide/contracts/app-api/lib.rs @@ -1,32 +1,29 @@ -#![no_main] -#![no_std] +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] -use pvm::storage::Lazy; -use pvm_contract as pvm; +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 1024)] +mod app_api { + use pvm_contract_sdk::Lazy; -#[pvm::storage] -struct Storage { - count: u32, -} - -#[pvm::contract(cdm = "@example/counter")] -mod counter { - use super::*; - - #[pvm::constructor] - pub fn new() -> Result<(), Error> { - Storage::count().set(&0); - Ok(()) + pub struct AppApi { + #[slot(0)] + count: Lazy, } - #[pvm::method] - pub fn increment() { - let current = Storage::count().get().unwrap_or(0); - Storage::count().set(&(current + 1)); - } + impl AppApi { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) { + self.count.set(&0); + } + + #[pvm_contract_sdk::method] + pub fn increment(&mut self) { + let current = self.count.get(); + self.count.set(&(current + 1)); + } - #[pvm::method] - pub fn get_count() -> u32 { - Storage::count().get().unwrap_or(0) + #[pvm_contract_sdk::method] + pub fn get_count(&self) -> u32 { + self.count.get() + } } } diff --git a/src/templates/guide/contracts/support-contract/Cargo.toml b/src/templates/guide/contracts/support-contract/Cargo.toml index c0e0d7e..6660c70 100644 --- a/src/templates/guide/contracts/support-contract/Cargo.toml +++ b/src/templates/guide/contracts/support-contract/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "suuport_contract" +name = "support_contract" version.workspace = true edition.workspace = true -description = "Reads the current count from the shared counter contract via cross-contract CDM reference" +description = "Reads the current count from the app-api contract via cross-contract CDM reference" authors.workspace = true homepage.workspace = true @@ -13,9 +13,17 @@ path = "lib.rs" name = "support_contract" path = "lib.rs" +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[package.metadata.cdm-package] +name = "@example/support-contract" + [dependencies] cdm = { workspace = true } -pvm_contract = { workspace = true } +pvm-contract-sdk = { workspace = true } +pvm-cdm = { workspace = true } polkavm-derive = { workspace = true } -parity-scale-codec = { workspace = true } -picoalloc = { workspace = true } \ No newline at end of file +picoalloc = { workspace = true } +# Build/deploy ordering only — source consumes `app-api` via `cdm::import!`. +app-api = { path = "../app-api" } \ No newline at end of file diff --git a/src/templates/guide/contracts/support-contract/lib.rs b/src/templates/guide/contracts/support-contract/lib.rs index 9844b28..c945a1a 100644 --- a/src/templates/guide/contracts/support-contract/lib.rs +++ b/src/templates/guide/contracts/support-contract/lib.rs @@ -1,21 +1,22 @@ -#![no_main] -#![no_std] +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] -use pvm_contract as pvm; +cdm::import!("@example/app-api"); -#[pvm::contract(cdm = "@example/counter-reader")] -mod counter_reader { +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 1024)] +mod support_contract { use super::*; - #[pvm::constructor] - pub fn new() -> Result<(), Error> { - Ok(()) - } + pub struct SupportContract; + + impl SupportContract { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) {} - /// Read the current count from the shared counter contract via CDM - #[pvm::method] - pub fn read_count() -> u32 { - let counter = counter::cdm_reference(); - counter.get_count().expect("get_count failed") + /// Read the current count from the app-api contract via CDM. + #[pvm_contract_sdk::method] + pub fn read_count(&self) -> u32 { + let api = app_api::AppApi::cdm_lookup(); + api.get_count().call(self).expect("get_count failed") + } } } diff --git a/src/templates/instagram/Cargo.toml b/src/templates/instagram/Cargo.toml index 5b5e4cf..f567d34 100644 --- a/src/templates/instagram/Cargo.toml +++ b/src/templates/instagram/Cargo.toml @@ -9,8 +9,8 @@ homepage = "https://polkadot.com/" edition = "2024" [workspace.dependencies] -cdm = { git = "https://github.com/paritytech/contract-dependency-manager" } -pvm_contract = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "charles/cdm-integration" } +cdm = { path = "../../lib/cdm/rust" } +pvm-contract-sdk = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "sm/cdm", features = ["alloc"] } +pvm-cdm = { path = "../../lib/cdm/rust-macros/pvm-cdm" } polkavm-derive = "0.31" -parity-scale-codec = { version = "3.7", default-features = false, features = ["derive"] } picoalloc = "5.2" diff --git a/src/templates/instagram/contracts/instagram/Cargo.toml b/src/templates/instagram/contracts/instagram/Cargo.toml index 52b3fe6..d8de0a8 100644 --- a/src/templates/instagram/contracts/instagram/Cargo.toml +++ b/src/templates/instagram/contracts/instagram/Cargo.toml @@ -13,9 +13,13 @@ path = "lib.rs" name = "instagram" path = "lib.rs" +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[package.metadata.cdm-package] +name = "@example/instagram" + [dependencies] -cdm = { workspace = true } -pvm_contract = { workspace = true } +pvm-contract-sdk = { workspace = true } polkavm-derive = { workspace = true } -parity-scale-codec = { workspace = true } picoalloc = { workspace = true } diff --git a/src/templates/instagram/contracts/instagram/lib.rs b/src/templates/instagram/contracts/instagram/lib.rs index b55dd7e..3fd945b 100644 --- a/src/templates/instagram/contracts/instagram/lib.rs +++ b/src/templates/instagram/contracts/instagram/lib.rs @@ -1,101 +1,107 @@ -#![no_main] -#![no_std] - -use alloc::string::String; -use parity_scale_codec::{Decode, Encode}; -use pvm::storage::Mapping; -use pvm_contract as pvm; - -#[allow(unreachable_code)] -fn revert(msg: &[u8]) -> ! { - pvm::api::return_value(pvm_contract::ReturnFlags::REVERT, msg); - loop {} -} +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] -#[derive(Default, Clone, Encode, Decode)] -pub struct PostData { - pub description: String, - pub photo_cid: String, - pub timestamp: u64, -} +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 65536)] +mod instagram { + use alloc::string::String; + use pvm_contract_sdk::{Address, HostApi, Lazy, Mapping, SolType}; -#[derive(pvm::SolAbi)] -pub struct Post { - pub description: String, - pub photo_cid: String, - pub timestamp: u64, -} + pvm_contract_sdk::sol_revert_enum! { + pub enum Error { + PostNotFound(PostNotFound), + UserNotFound(UserNotFound), + } + } -#[pvm::storage] -struct Storage { - post_counts: Mapping<[u8; 20], u64>, - posts: Mapping<([u8; 20], u64), PostData>, - user_count: u64, - users: Mapping, - user_registered: Mapping<[u8; 20], bool>, -} + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct PostNotFound; -#[pvm::contract(cdm = "@example/instagram")] -mod instagram { - use super::*; + #[derive(Debug, pvm_contract_sdk::SolError)] + pub struct UserNotFound; - #[pvm::constructor] - pub fn new() -> Result<(), Error> { - Storage::user_count().set(&0); - Ok(()) + #[derive(Clone, Default, SolType)] + pub struct PostData { + pub description: String, + pub photo_cid: String, + pub timestamp: u64, } - #[pvm::method] - pub fn create_post(description: String, photo_cid: String) -> u64 { - let caller = *pvm::caller().as_fixed_bytes(); + pub struct Instagram { + #[slot(0)] + post_counts: Mapping<[u8; 20], u64>, + #[slot(1)] + posts: Mapping<([u8; 20], u64), PostData>, + #[slot(2)] + user_count: Lazy, + #[slot(3)] + users: Mapping, + #[slot(4)] + user_registered: Mapping<[u8; 20], bool>, + } - if !Storage::user_registered().contains(&caller) { - let count = Storage::user_count().get().unwrap_or(0); - Storage::users().insert(&count, &caller); - Storage::user_count().set(&(count + 1)); - Storage::user_registered().insert(&caller, &true); + impl Instagram { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) { + self.user_count.set(&0); } - let index = Storage::post_counts().get(&caller).unwrap_or(0); + #[pvm_contract_sdk::method] + pub fn create_post(&mut self, description: String, photo_cid: String) -> u64 { + let caller = self.caller(); - let mut buf = [0u8; 32]; - pvm::api::now(&mut buf); - let timestamp = u64::from_le_bytes(buf[0..8].try_into().unwrap()); + if !self.user_registered.get(&caller.0) { + let count = self.user_count.get(); + self.users.insert(&count, &caller.0); + self.user_count.set(&(count + 1)); + self.user_registered.insert(&caller.0, &true); + } - let post = PostData { description, photo_cid, timestamp }; - Storage::posts().insert(&(caller, index), &post); - Storage::post_counts().insert(&caller, &(index + 1)); + let index = self.post_counts.get(&caller.0); - index - } + let mut buf = [0u8; 32]; + self.host().now(&mut buf); + let timestamp = u64::from_le_bytes(buf[0..8].try_into().unwrap()); - #[pvm::method] - pub fn get_post_count(user: [u8; 20]) -> u64 { - Storage::post_counts().get(&user).unwrap_or(0) - } + let post = PostData { + description, + photo_cid, + timestamp, + }; + self.posts.insert(&(caller.0, index), &post); + self.post_counts.insert(&caller.0, &(index + 1)); - #[pvm::method] - pub fn get_post(user: [u8; 20], index: u64) -> Post { - match Storage::posts().get(&(user, index)) { - Some(d) => Post { - description: d.description, - photo_cid: d.photo_cid, - timestamp: d.timestamp, - }, - None => revert(b"PostNotFound"), + index } - } - #[pvm::method] - pub fn get_user_count() -> u64 { - Storage::user_count().get().unwrap_or(0) - } + #[pvm_contract_sdk::method] + pub fn get_post_count(&self, user: Address) -> u64 { + self.post_counts.get(&user.0) + } + + #[pvm_contract_sdk::method] + pub fn get_post(&self, user: Address, index: u64) -> Result { + if index >= self.post_counts.get(&user.0) { + return Err(PostNotFound.into()); + } + Ok(self.posts.get(&(user.0, index))) + } + + #[pvm_contract_sdk::method] + pub fn get_user_count(&self) -> u64 { + self.user_count.get() + } + + #[pvm_contract_sdk::method] + pub fn get_user_at(&self, index: u64) -> Result { + if index >= self.user_count.get() { + return Err(UserNotFound.into()); + } + Ok(Address(self.users.get(&index))) + } - #[pvm::method] - pub fn get_user_at(index: u64) -> [u8; 20] { - match Storage::users().get(&index) { - Some(addr) => addr, - None => revert(b"UserNotFound"), + fn caller(&self) -> Address { + let mut buf = [0u8; 20]; + self.host().caller(&mut buf); + Address(buf) } } } diff --git a/src/templates/shared-counter/Cargo.toml b/src/templates/shared-counter/Cargo.toml index 5b5e4cf..f567d34 100644 --- a/src/templates/shared-counter/Cargo.toml +++ b/src/templates/shared-counter/Cargo.toml @@ -9,8 +9,8 @@ homepage = "https://polkadot.com/" edition = "2024" [workspace.dependencies] -cdm = { git = "https://github.com/paritytech/contract-dependency-manager" } -pvm_contract = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "charles/cdm-integration" } +cdm = { path = "../../lib/cdm/rust" } +pvm-contract-sdk = { git = "https://github.com/paritytech/cargo-pvm-contract", branch = "sm/cdm", features = ["alloc"] } +pvm-cdm = { path = "../../lib/cdm/rust-macros/pvm-cdm" } polkavm-derive = "0.31" -parity-scale-codec = { version = "3.7", default-features = false, features = ["derive"] } picoalloc = "5.2" diff --git a/src/templates/shared-counter/contracts/counter-reader/Cargo.toml b/src/templates/shared-counter/contracts/counter-reader/Cargo.toml index a02308b..b23c587 100644 --- a/src/templates/shared-counter/contracts/counter-reader/Cargo.toml +++ b/src/templates/shared-counter/contracts/counter-reader/Cargo.toml @@ -13,10 +13,17 @@ path = "lib.rs" name = "counter_reader" path = "lib.rs" +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[package.metadata.cdm-package] +name = "@example/counter-reader" + [dependencies] cdm = { workspace = true } -pvm_contract = { workspace = true } +pvm-contract-sdk = { workspace = true } +pvm-cdm = { workspace = true } polkavm-derive = { workspace = true } -parity-scale-codec = { workspace = true } picoalloc = { workspace = true } +# Build/deploy ordering only — source consumes `counter` via `cdm::import!`. counter = { path = "../counter" } diff --git a/src/templates/shared-counter/contracts/counter-reader/lib.rs b/src/templates/shared-counter/contracts/counter-reader/lib.rs index 9844b28..489f965 100644 --- a/src/templates/shared-counter/contracts/counter-reader/lib.rs +++ b/src/templates/shared-counter/contracts/counter-reader/lib.rs @@ -1,21 +1,22 @@ -#![no_main] -#![no_std] +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] -use pvm_contract as pvm; +cdm::import!("@example/counter"); -#[pvm::contract(cdm = "@example/counter-reader")] +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 1024)] mod counter_reader { use super::*; - #[pvm::constructor] - pub fn new() -> Result<(), Error> { - Ok(()) - } + pub struct CounterReader; + + impl CounterReader { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) {} - /// Read the current count from the shared counter contract via CDM - #[pvm::method] - pub fn read_count() -> u32 { - let counter = counter::cdm_reference(); - counter.get_count().expect("get_count failed") + /// Read the current count from the shared counter contract via CDM. + #[pvm_contract_sdk::method] + pub fn read_count(&self) -> u32 { + let counter = counter::Counter::cdm_lookup(); + counter.get_count().call(self).expect("get_count failed") + } } } diff --git a/src/templates/shared-counter/contracts/counter-writer/Cargo.toml b/src/templates/shared-counter/contracts/counter-writer/Cargo.toml index 2645cff..074414b 100644 --- a/src/templates/shared-counter/contracts/counter-writer/Cargo.toml +++ b/src/templates/shared-counter/contracts/counter-writer/Cargo.toml @@ -13,10 +13,17 @@ path = "lib.rs" name = "counter_writer" path = "lib.rs" +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[package.metadata.cdm-package] +name = "@example/counter-writer" + [dependencies] cdm = { workspace = true } -pvm_contract = { workspace = true } +pvm-contract-sdk = { workspace = true } +pvm-cdm = { workspace = true } polkavm-derive = { workspace = true } -parity-scale-codec = { workspace = true } picoalloc = { workspace = true } +# Build/deploy ordering only — source consumes `counter` via `cdm::import!`. counter = { path = "../counter" } diff --git a/src/templates/shared-counter/contracts/counter-writer/lib.rs b/src/templates/shared-counter/contracts/counter-writer/lib.rs index 377c0d0..d455dd2 100644 --- a/src/templates/shared-counter/contracts/counter-writer/lib.rs +++ b/src/templates/shared-counter/contracts/counter-writer/lib.rs @@ -1,30 +1,31 @@ -#![no_main] -#![no_std] +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] -use pvm_contract as pvm; +cdm::import!("@example/counter"); -#[pvm::contract(cdm = "@example/counter-writer")] +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 1024)] mod counter_writer { use super::*; - #[pvm::constructor] - pub fn new() -> Result<(), Error> { - Ok(()) - } + pub struct CounterWriter; - /// Increment the shared counter by calling the counter contract via CDM - #[pvm::method] - pub fn write_increment() { - let counter = counter::cdm_reference(); - counter.increment().expect("increment failed"); - } + impl CounterWriter { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) {} + + /// Increment the shared counter by calling the counter contract via CDM. + #[pvm_contract_sdk::method] + pub fn write_increment(&mut self) { + let counter = counter::Counter::cdm_lookup(); + counter.increment().call(self).expect("increment failed"); + } - /// Increment the shared counter N times - #[pvm::method] - pub fn write_increment_n(n: u32) { - let counter = counter::cdm_reference(); - for _ in 0..n { - counter.increment().expect("increment failed"); + /// Increment the shared counter N times. + #[pvm_contract_sdk::method] + pub fn write_increment_n(&mut self, n: u32) { + let counter = counter::Counter::cdm_lookup(); + for _ in 0..n { + counter.increment().call(self).expect("increment failed"); + } } } } diff --git a/src/templates/shared-counter/contracts/counter/Cargo.toml b/src/templates/shared-counter/contracts/counter/Cargo.toml index d2508df..e96f4b9 100644 --- a/src/templates/shared-counter/contracts/counter/Cargo.toml +++ b/src/templates/shared-counter/contracts/counter/Cargo.toml @@ -13,9 +13,13 @@ path = "lib.rs" name = "counter" path = "lib.rs" +[features] +abi-gen = ["pvm-contract-sdk/abi-gen"] + +[package.metadata.cdm-package] +name = "@example/counter" + [dependencies] -cdm = { workspace = true } -pvm_contract = { workspace = true } +pvm-contract-sdk = { workspace = true } polkavm-derive = { workspace = true } -parity-scale-codec = { workspace = true } picoalloc = { workspace = true } diff --git a/src/templates/shared-counter/contracts/counter/lib.rs b/src/templates/shared-counter/contracts/counter/lib.rs index dae28ad..909f5aa 100644 --- a/src/templates/shared-counter/contracts/counter/lib.rs +++ b/src/templates/shared-counter/contracts/counter/lib.rs @@ -1,32 +1,29 @@ -#![no_main] -#![no_std] +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] -use pvm::storage::Lazy; -use pvm_contract as pvm; - -#[pvm::storage] -struct Storage { - count: u32, -} - -#[pvm::contract(cdm = "@example/counter")] +#[pvm_contract_sdk::contract(allocator = "pico", allocator_size = 1024)] mod counter { - use super::*; + use pvm_contract_sdk::Lazy; - #[pvm::constructor] - pub fn new() -> Result<(), Error> { - Storage::count().set(&0); - Ok(()) + pub struct Counter { + #[slot(0)] + count: Lazy, } - #[pvm::method] - pub fn increment() { - let current = Storage::count().get().unwrap_or(0); - Storage::count().set(&(current + 1)); - } + impl Counter { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) { + self.count.set(&0); + } + + #[pvm_contract_sdk::method] + pub fn increment(&mut self) { + let current = self.count.get(); + self.count.set(&(current + 1)); + } - #[pvm::method] - pub fn get_count() -> u32 { - Storage::count().get().unwrap_or(0) + #[pvm_contract_sdk::method] + pub fn get_count(&self) -> u32 { + self.count.get() + } } } diff --git a/vitest.config.ts b/vitest.config.ts index 84ac741..c9ab299 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "**/dist/**", "src/apps/frontend/**", "src/templates/**", + "src/**/tests/e2e/**", ], reporters: "verbose", environment: "node", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..a7a6dff --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +// E2E test suite — opt-in via `pnpm test:e2e`. Tests spawn a real +// `revive-dev-node`, deploy the registry, and exercise it over WS. The unit +// `pnpm test` flow excludes these so it stays fast and binary-independent. +// +// Single-file run, no parallelism: the harness allocates ports from a static +// counter and shares one dev-node per suite. +export default defineConfig({ + test: { + globals: true, + include: ["src/**/tests/e2e/**/*.test.ts"], + reporters: "verbose", + environment: "node", + testTimeout: 60_000, + hookTimeout: 180_000, + fileParallelism: false, + }, +});