Critical Gap Analysis: cargo-pvm-contract vs openzeppelin-stylus
Below is a critical, ordered list of what's missing.
- Storage primitives — only single-slot Lazy and Mapping<K,V> exist
Blocking impact: ~80% of the OZ Stylus contracts cannot be ported as-is.
What pvm-storage supports today (crates/pvm-storage/src/lib.rs):
Lazy and Mapping<K, V> — and both are hard-gated to T: StaticEncodedLen with ENCODED_SIZE == 32. A const assert in Lazy::new rejects any other size.
A #[slot(N)] field attribute on the contract struct. Slots are hand-numbered by the author.
What stylus-sdk provides and OZ uses pervasively:
StorageVec — used in Trace/Checkpoint, EnumerableSet, ERC721Enumerable, ERC721Consecutive.
StorageString, StorageBytes — required for Erc20Metadata (name/symbol), Erc721URIStorage, Erc1155URIStorage, Erc1155MetadataURI.
StorageMap<K, StorageMap<K2, ...>> with non-32-byte values (e.g. nested map values that are themselves arrays/strings).
Composable typed storage where any type that implements StorageType can be a field — see Trace<S: Size> which parameterizes the storage element.
StorageGuard / StorageGuardMut — borrow-checker handles for nested setters (positions.setter(value).set(position)).
StorageSlot::get_slot::(IMPLEMENTATION_SLOT) — slot-by-keccak access used by all ERC-1967/UUPS/Beacon proxies.
What's blocking it in PVM: the Solidity-compatible derivation for dynamic types (bytes/string use keccak256(slot) as the data root with length packed into the slot; arrays use keccak256(slot) + i). The AsStorageKey macro is explicitly limited to 32-byte scalars and a comment forbids extending it. No abstraction for length-prefixed slots, no StorageType-style trait that types like StorageVec could implement.
- Storage composition / contract inheritance — no equivalent of #[storage] field embedding
Blocking impact: Core to the entire OZ design pattern. Every extension is implemented as a struct with a pub erc20: Erc20 field.
Example from token/erc20/extensions/burnable.rs and the README:
#[storage]
struct MyContract { pub erc20: Erc20 }
The stylus #[storage] macro recursively assigns slot ranges based on each field's StorageType::SLOT_BYTES. You can nest Erc20 inside another #[storage] and the slots compose automatically.
In PVM, #[slot(N)] is per-leaf-field on the single contract struct. There is no way to say "this field is a sub-component starting at slot N and consuming the next M slots." If you wanted Erc20Capped, you'd have to flatten all of Erc20's fields plus the cap field onto one struct and re-implement every method by hand. There is no #[contract] attribute that lets one contract delegate dispatch to another struct's methods.
The macro at pvm-contract-macros/src/codegen/contract.rs is built around a single pub struct Foo with all impl Foo { #[method] fn ... } methods. No #[implements(IErc20)], no method import from another module/struct.
- Events — no encoding abstraction, contracts hand-roll selectors and topics
Blocking impact: Every event-emitting contract is harder; large surface for bugs.
pvm-contract-types::HostApi::deposit_event(&topics, &data) is the only API. The Events example shows what a developer must do today: hardcode VALUE_CHANGED_SIG as a [u8; 32] literal, manually pad the indexed address into a 32-byte topic, manually encode the non-indexed body. No compile-time selector computation, no derive macro.
Stylus uses alloy_sol_macro::sol! { event Transfer(address indexed from, ...); }. The macro produces SolEvent impls with SIGNATURE_HASH, topics, and encoded body; emit is one call. Required for every ERC-20/721/1155/AccessControl/Ownable/Pausable contract.
What's needed in PVM: a #[derive(SolEvent)] or sol_event! macro that parses the event signature, computes the topic-0 keccak at compile time, generates indexed-vs-data field splitting, and an event.emit(host) helper.
- Trait-based interfaces & #[interface_id] — no ERC-165 introspection
Blocking impact: ERC-165 is required by ERC-721, ERC-1155, AccessControl, Ownable, ERC-4626, and the supportsInterface check is part of safe-transfer flows (onERC721Received).
OZ provides openzeppelin-stylus-proc::#[interface_id] which sums Keccak selectors over a trait's methods to produce ::interface_id() -> B32. It's applied to every interface trait (IErc20, IErc721, IErc1155, IAccessControl, IErc20Permit, IErc4626, IErc721Receiver, IErc1155Receiver, IErc1822Proxiable, etc).
PVM has no proc macro for this and no trait-based dispatch pattern. The Solidity interface lives in a .sol file, not in Rust traits. There's no concept of "implement multiple interfaces and merge dispatch."
- EVM precompile reachability — ECDSA / EIP-712 / P256 fundamentally blocked
Blocking impact: Hard blocker for ECDSA, EIP-712, ERC20Permit, ERC2612, P256Verify precompile, and anything using ecrecover (signatures, meta-transactions, ERC-1271-style flows).
OZ Stylus uses:
ECRECOVER_ADDR = 0x0...01 via call::static_call.
P256_VERIFY_ADDRESS = 0x0...0100 (RIP-7212).
For PVM/pallet-revive, this depends on what precompiles the runtime actually exposes; pallet-revive-uapi advertises a precompiles-sol-interfaces feature but the PVM SDK provides no Rust wrapper, no fixed-address helpers, and no equivalent of OZ's Precompiles trait. Until (a) pallet-revive confirms which Ethereum precompiles are present at which addresses, and (b) the SDK ships typed wrappers, every signature-checking OZ contract is non-portable.
EIP-712 also needs chain_id and the contract's own address baked into the domain separator — HostApi::chain_id exists, so the EIP-712 primitive itself is implementable, but ECRecover-based signature verification on top of it is not.
- Static calls / typed cross-contract calls with &self view callees
Partial gap, ergonomic blocker.
OZ pattern: interface.supports_interface(Call::new_in(self), interface_id) from a &self method. Call::new_in builds a StaticCallContext.
PVM has CallBuilder with View/Pure/NonPayable/Payable typestates and abi_import! for typed wrappers, which is conceptually similar. What's missing from the OZ porting perspective:
A generated Erc20Interface::new(address) style that's analogous to the alloy-sol!-generated IErc20Solidity types used everywhere in OZ.
The MethodError ergonomics: stylus errors flatten into Vec via MethodError::encode; PVM SolRevert writes into a caller-provided buffer. Not a hard blocker but every error type defined by OZ would need re-derivation.
- alloy-sol-types / sol! macro — only partial parity
Partial gap. PVM has #[derive(SolType)] and #[derive(SolError)] and supports primitives + arrays + tuples + custom structs. But:
No sol! { … } block macro that parses Solidity-flavored declarations of struct, event, error, function together. Every OZ file uses sol! { error ERC20InsufficientBalance(...); } for revert types.
No &[u8] SolEncode impl (acknowledged in "Known Gaps").
No Bytes / FixedBytes distinction beyond [u8; N].
- ABI export from Rust source — only .sol-driven
OZ Stylus has the export-abi feature: compile your Rust contract with --features export-abi and it prints the Solidity ABI/interface from the trait + method signatures. PVM's flow is the opposite — it consumes .sol and validates Rust matches. This means OZ-style "Rust is the source of truth, ABI is derived" workflows aren't possible without writing a .sol first. Not strictly blocking but a friction point for adoption.
- Testing framework — no multi-contract motsu equivalent
OZ Stylus uses motsu heavily for unit tests with multi-account fakes, contract-to-contract orchestration, pranking msg::sender, simulating multiple deployments. PVM's MockHost handles single-contract host-side mocking only. Cross-contract tests have to go through the e2e test harness at crates/pvm-contract-e2e-tests, which is much heavier than a unit test.
- Reentrancy guard — explicitly absent
CLAUDE.md states: "reentrancy-sensitive method needs an explicit guard (not provided by the SDK yet)." OZ Stylus has a reentrant cargo feature flag (stylus-sdk/reentrant) and patterns assuming it's available. For PVM, pallet-revive rejects reentrancy by default, so the OZ guard is less necessary — but anything that opts into allow_reentry (flash loans, ERC-777-style hooks, callback patterns) loses the explicit nonReentrant modifier semantics OZ provides.
- Convenience accessors — msg::sender(), block::timestamp(), Address::has_code()
Ergonomic only, not blocking.
OZ contracts call msg::sender(), msg::value(), block::timestamp(), block::number(), block::chainid() as free functions and addr.has_code() as an Address method. PVM forces:
let mut caller = [0u8; 20]; self.host().caller(&mut caller);
Every call requires a stack buffer and a typed re-wrap. AddressUtils helpers don't exist. This isn't a blocker but multiplies the line count of every ported contract by ~2×.
- Bytes / MethodError::encode interop type
Many OZ functions return Result<Bytes, Error> where Bytes is stylus_sdk::abi::Bytes — used for proxy delegate-call returns, ERC-1271 sig checks, EIP-712 hooks. PVM uses Vec or a bytes no-alloc inline decode path; no equivalent newtype, and the dynamic encoding path requires the alloc feature.
Critical Gap Analysis: cargo-pvm-contract vs openzeppelin-stylus
Below is a critical, ordered list of what's missing.
Blocking impact: ~80% of the OZ Stylus contracts cannot be ported as-is.
What pvm-storage supports today (crates/pvm-storage/src/lib.rs):
Lazy and Mapping<K, V> — and both are hard-gated to T: StaticEncodedLen with ENCODED_SIZE == 32. A const assert in Lazy::new rejects any other size.
A #[slot(N)] field attribute on the contract struct. Slots are hand-numbered by the author.
What stylus-sdk provides and OZ uses pervasively:
StorageVec — used in Trace/Checkpoint, EnumerableSet, ERC721Enumerable, ERC721Consecutive.
StorageString, StorageBytes — required for Erc20Metadata (name/symbol), Erc721URIStorage, Erc1155URIStorage, Erc1155MetadataURI.
StorageMap<K, StorageMap<K2, ...>> with non-32-byte values (e.g. nested map values that are themselves arrays/strings).
Composable typed storage where any type that implements StorageType can be a field — see Trace<S: Size> which parameterizes the storage element.
StorageGuard / StorageGuardMut — borrow-checker handles for nested setters (positions.setter(value).set(position)).
StorageSlot::get_slot::(IMPLEMENTATION_SLOT) — slot-by-keccak access used by all ERC-1967/UUPS/Beacon proxies.
What's blocking it in PVM: the Solidity-compatible derivation for dynamic types (bytes/string use keccak256(slot) as the data root with length packed into the slot; arrays use keccak256(slot) + i). The AsStorageKey macro is explicitly limited to 32-byte scalars and a comment forbids extending it. No abstraction for length-prefixed slots, no StorageType-style trait that types like StorageVec could implement.
Blocking impact: Core to the entire OZ design pattern. Every extension is implemented as a struct with a pub erc20: Erc20 field.
Example from token/erc20/extensions/burnable.rs and the README:
#[storage]
struct MyContract { pub erc20: Erc20 }
The stylus #[storage] macro recursively assigns slot ranges based on each field's StorageType::SLOT_BYTES. You can nest Erc20 inside another #[storage] and the slots compose automatically.
In PVM, #[slot(N)] is per-leaf-field on the single contract struct. There is no way to say "this field is a sub-component starting at slot N and consuming the next M slots." If you wanted Erc20Capped, you'd have to flatten all of Erc20's fields plus the cap field onto one struct and re-implement every method by hand. There is no #[contract] attribute that lets one contract delegate dispatch to another struct's methods.
The macro at pvm-contract-macros/src/codegen/contract.rs is built around a single pub struct Foo with all impl Foo { #[method] fn ... } methods. No #[implements(IErc20)], no method import from another module/struct.
Blocking impact: Every event-emitting contract is harder; large surface for bugs.
pvm-contract-types::HostApi::deposit_event(&topics, &data) is the only API. The Events example shows what a developer must do today: hardcode VALUE_CHANGED_SIG as a [u8; 32] literal, manually pad the indexed address into a 32-byte topic, manually encode the non-indexed body. No compile-time selector computation, no derive macro.
Stylus uses alloy_sol_macro::sol! { event Transfer(address indexed from, ...); }. The macro produces SolEvent impls with SIGNATURE_HASH, topics, and encoded body; emit is one call. Required for every ERC-20/721/1155/AccessControl/Ownable/Pausable contract.
What's needed in PVM: a #[derive(SolEvent)] or sol_event! macro that parses the event signature, computes the topic-0 keccak at compile time, generates indexed-vs-data field splitting, and an event.emit(host) helper.
Blocking impact: ERC-165 is required by ERC-721, ERC-1155, AccessControl, Ownable, ERC-4626, and the supportsInterface check is part of safe-transfer flows (onERC721Received).
OZ provides openzeppelin-stylus-proc::#[interface_id] which sums Keccak selectors over a trait's methods to produce ::interface_id() -> B32. It's applied to every interface trait (IErc20, IErc721, IErc1155, IAccessControl, IErc20Permit, IErc4626, IErc721Receiver, IErc1155Receiver, IErc1822Proxiable, etc).
PVM has no proc macro for this and no trait-based dispatch pattern. The Solidity interface lives in a .sol file, not in Rust traits. There's no concept of "implement multiple interfaces and merge dispatch."
Blocking impact: Hard blocker for ECDSA, EIP-712, ERC20Permit, ERC2612, P256Verify precompile, and anything using ecrecover (signatures, meta-transactions, ERC-1271-style flows).
OZ Stylus uses:
ECRECOVER_ADDR = 0x0...01 via call::static_call.
P256_VERIFY_ADDRESS = 0x0...0100 (RIP-7212).
For PVM/pallet-revive, this depends on what precompiles the runtime actually exposes; pallet-revive-uapi advertises a precompiles-sol-interfaces feature but the PVM SDK provides no Rust wrapper, no fixed-address helpers, and no equivalent of OZ's Precompiles trait. Until (a) pallet-revive confirms which Ethereum precompiles are present at which addresses, and (b) the SDK ships typed wrappers, every signature-checking OZ contract is non-portable.
EIP-712 also needs chain_id and the contract's own address baked into the domain separator — HostApi::chain_id exists, so the EIP-712 primitive itself is implementable, but ECRecover-based signature verification on top of it is not.
Partial gap, ergonomic blocker.
OZ pattern: interface.supports_interface(Call::new_in(self), interface_id) from a &self method. Call::new_in builds a StaticCallContext.
PVM has CallBuilder with View/Pure/NonPayable/Payable typestates and abi_import! for typed wrappers, which is conceptually similar. What's missing from the OZ porting perspective:
A generated Erc20Interface::new(address) style that's analogous to the alloy-sol!-generated IErc20Solidity types used everywhere in OZ.
The MethodError ergonomics: stylus errors flatten into Vec via MethodError::encode; PVM SolRevert writes into a caller-provided buffer. Not a hard blocker but every error type defined by OZ would need re-derivation.
Partial gap. PVM has #[derive(SolType)] and #[derive(SolError)] and supports primitives + arrays + tuples + custom structs. But:
No sol! { … } block macro that parses Solidity-flavored declarations of struct, event, error, function together. Every OZ file uses sol! { error ERC20InsufficientBalance(...); } for revert types.
No &[u8] SolEncode impl (acknowledged in "Known Gaps").
No Bytes / FixedBytes distinction beyond [u8; N].
OZ Stylus has the export-abi feature: compile your Rust contract with --features export-abi and it prints the Solidity ABI/interface from the trait + method signatures. PVM's flow is the opposite — it consumes .sol and validates Rust matches. This means OZ-style "Rust is the source of truth, ABI is derived" workflows aren't possible without writing a .sol first. Not strictly blocking but a friction point for adoption.
OZ Stylus uses motsu heavily for unit tests with multi-account fakes, contract-to-contract orchestration, pranking msg::sender, simulating multiple deployments. PVM's MockHost handles single-contract host-side mocking only. Cross-contract tests have to go through the e2e test harness at crates/pvm-contract-e2e-tests, which is much heavier than a unit test.
CLAUDE.md states: "reentrancy-sensitive method needs an explicit guard (not provided by the SDK yet)." OZ Stylus has a reentrant cargo feature flag (stylus-sdk/reentrant) and patterns assuming it's available. For PVM, pallet-revive rejects reentrancy by default, so the OZ guard is less necessary — but anything that opts into allow_reentry (flash loans, ERC-777-style hooks, callback patterns) loses the explicit nonReentrant modifier semantics OZ provides.
Ergonomic only, not blocking.
OZ contracts call msg::sender(), msg::value(), block::timestamp(), block::number(), block::chainid() as free functions and addr.has_code() as an Address method. PVM forces:
let mut caller = [0u8; 20]; self.host().caller(&mut caller);
Every call requires a stack buffer and a typed re-wrap. AddressUtils helpers don't exist. This isn't a blocker but multiplies the line count of every ported contract by ~2×.
Many OZ functions return Result<Bytes, Error> where Bytes is stylus_sdk::abi::Bytes — used for proxy delegate-call returns, ERC-1271 sig checks, EIP-712 hooks. PVM uses Vec or a bytes no-alloc inline decode path; no equivalent newtype, and the dynamic encoding path requires the alloc feature.