diff --git a/CLAUDE.md b/CLAUDE.md index 71840d74..53f8277c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,12 +73,15 @@ The macro injects a `pub host: Host` field on the storage struct and a `fn host( **DSL API** (explicit, manual dispatch): ```rust +let host = Host::new(); ContractBuilder::new() .method(BALANCE_OF_SELECTOR, balance_of_handler) .method(TRANSFER_SELECTOR, transfer_handler) - .dispatch::() + .dispatch_impl::<256>(&host) ``` +DSL handlers take a concrete `&Host` (same type the macro path injects on the storage struct). For typed cross-contract calls, handlers wrap a cloned host in `Context::new(host.clone())` — `Context` impls `ContractContext` so it can be passed to `.call(&mut cx)` / `.delegate_call(&mut cx)`. `Host::clone()` is `Copy` on riscv64 (ZST) and a single `Rc::clone` on host targets. Because the wrapper carries only the host handle (no storage state), the borrow checker cannot enforce view-vs-mutating in DSL; use the `#[contract]` macro path if you need that static guarantee. The same `Context` type is used in unit tests, where it owns a `Host` backed by a `MockHost`. + ### Macro-Generated Code The `#[contract]` macro generates two PolkaVM entry points: @@ -104,6 +107,48 @@ Selectors are Keccak-256 of the canonical Solidity signature (first 4 bytes), co - `#[method]` — marks a public function as a contract method - `#[method(rename = "name")]` — overrides the Solidity function name (default: snake_case to camelCase) +- `#[payable]` — marks the method as `payable` (must be combined with `&mut self`) + +### Mutability Inference + +Solidity `stateMutability` is inferred from the Rust receiver. No explicit `#[view]` or `#[pure]` attribute — receiver shape is the source of truth. + +| Receiver | `#[payable]` | ABI emits | +|---|---|---| +| none (`fn foo(args)`) | — | `pure` | +| `&self` | — | `view` | +| `&mut self` | — | `nonpayable` | +| `&mut self` | yes | `payable` | +| `&self` | yes | **compile error** | +| no receiver | yes | **compile error** | + +**Constructor:** must take `&mut self`; `pure`/`view` constructors are rejected (they cannot initialize storage). `#[payable]` is allowed. + +**Fallback:** follows the same inference table as regular methods. + +**`.sol` consistency check:** when a `.sol` interface is provided, the macro errors if the Rust-inferred mutability disagrees with the `.sol` declaration (e.g., `.sol` says `view` but Rust uses `&mut self`). + +### Mutability Enforcement + +Three layers, in increasing strength: + +1. **Compile-time (typed-API)** — `#[contract]` auto-implements `ContractContext` on the storage struct (and forbids `#[derive(Clone)]` on it). Cross-contract call builders take `&impl ContractContext` for `view`/`pure` callees and `&mut impl ContractContext` for `nonpayable`/`payable` callees, so a `&self` (view) method *cannot* initiate a state-mutating call through the typed `abi_import!`-generated SDK. `delegate_call` and `instantiate` always require `&mut`. Storage helpers (`Lazy`, `Mapping`) similarly gate `set`/`insert` on `&mut self`. + +2. **Runtime (contract-side)** — non-payable methods (`pure`/`view`/`nonpayable`) get an injected `__pvm_assert_non_payable` / `__pvm_assert_value_zero` guard at the dispatch entry; the contract reverts if `msg.value > 0`. + +3. **Runtime (host-side)** — `pallet-revive` enforces the STATICCALL boundary: state-mutating host calls revert when invoked inside a static frame. This is what backstops `view`/`pure` for cross-contract callers. + +**Honest caveat:** the typed-API gate covers cross-contract calls made through `abi_import!`-generated wrappers and storage operations through `pvm-storage`. Raw `pallet_revive_uapi` calls (e.g., `api::set_storage`) bypass the type-level check — only the host's STATICCALL enforcement and the runtime payable guard apply there. Use the typed APIs as the primary surface; reach for raw uAPI only when the typed surface lacks coverage. + +**Pure semantics (matches Solidity, by design):** a pure method has no receiver and therefore no `host` accessor. By construction it cannot: +- make cross-contract calls (no `&impl ContractContext` to pass to `CallBuilder::call`), +- read block/chain/tx context (`block.number`, `chain.id`, etc.), +- call host-routed helpers (`keccak256`, event emission, storage), +- emit events. + +This matches Solidity's `pure` rules — solc rejects the same operations in a `pure` function. If a method needs `keccak256`, block context, or any host call, mark it `view` (`&self`) rather than pure. The restriction isn't a SDK limitation; it's the same semantic boundary Solidity callers expect when they see `pure` in the ABI. + +**Reentrancy non-protection:** `&mut self` enforces single-threaded mutation within a frame, but persistent storage is shared across reentrant frames (each callee gets a fresh contract struct, so the borrow checker offers no cross-frame guarantee). A reentrancy-sensitive method needs an explicit guard (not provided by the SDK yet). ## Type System diff --git a/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_dsl.rs b/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_dsl.rs index f0cb8303..60f96c88 100644 --- a/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_dsl.rs +++ b/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_dsl.rs @@ -1,8 +1,10 @@ #![no_main] #![no_std] -use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; -use pvm_contract_builder_dsl::pvm_contract_types::{HostApi, PolkaVmHost, SolDecode, SolEncode, StaticEncodedLen}; +use pvm_contract_builder_dsl::{ + ContractBuilder, HandlerResult, assert_non_payable_deploy, solidity_selector, +}; +use pvm_contract_builder_dsl::pvm_contract_types::{Host, SolDecode, SolEncode, StaticEncodedLen}; const FIBONACCI_SELECTOR: [u8; 4] = solidity_selector("fibonacci(uint32)"); @@ -16,18 +18,20 @@ fn panic(_info: &core::panic::PanicInfo) -> ! { #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] -pub extern "C" fn deploy() {} +pub extern "C" fn deploy() { + assert_non_payable_deploy(&Host::new()); +} #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; - ContractBuilder::::new() - .method(FIBONACCI_SELECTOR, fibonacci_handler::) + let host = Host::new(); + ContractBuilder::new() + .method(FIBONACCI_SELECTOR, fibonacci_handler) .dispatch_impl::<256>(&host); } -fn fibonacci_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn fibonacci_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let n = u32::decode_at(input, 0); let result = fibonacci(n); let len = ::ENCODED_SIZE; diff --git a/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_no_alloc.rs b/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_no_alloc.rs index 93439a78..ba3f040d 100644 --- a/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_no_alloc.rs +++ b/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_no_alloc.rs @@ -19,7 +19,7 @@ mod fibonacci { } #[pvm_contract_sdk::method] - pub fn fibonacci(&self, n: u32) -> u32 { + pub fn fibonacci(n: u32) -> u32 { if n <= 1 { n } else { diff --git a/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_with_alloc.rs b/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_with_alloc.rs index 37d0dccd..7a3ac4cc 100644 --- a/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_with_alloc.rs +++ b/crates/cargo-pvm-contract/templates/examples/fibonacci/fibonacci_with_alloc.rs @@ -19,7 +19,7 @@ mod fibonacci { } #[pvm_contract_sdk::method] - pub fn fibonacci(&self, n: u32) -> u32 { + pub fn fibonacci(n: u32) -> u32 { if n <= 1 { n } else { diff --git a/crates/cargo-pvm-contract/templates/examples/multi/multi_dsl.rs b/crates/cargo-pvm-contract/templates/examples/multi/multi_dsl.rs index af7779ef..d06e8096 100644 --- a/crates/cargo-pvm-contract/templates/examples/multi/multi_dsl.rs +++ b/crates/cargo-pvm-contract/templates/examples/multi/multi_dsl.rs @@ -1,8 +1,10 @@ #![no_main] #![no_std] -use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; -use pvm_contract_builder_dsl::pvm_contract_types::{HostApi, PolkaVmHost, SolDecode, SolEncode, StaticEncodedLen}; +use pvm_contract_builder_dsl::{ + ContractBuilder, HandlerResult, assert_non_payable_deploy, solidity_selector, +}; +use pvm_contract_builder_dsl::pvm_contract_types::{Host, SolDecode, SolEncode, StaticEncodedLen}; use pvm_contract_builder_dsl::ruint::aliases::U256; const ADD_SELECTOR: [u8; 4] = solidity_selector("add(uint32,uint32)"); @@ -26,27 +28,29 @@ fn panic(_info: &core::panic::PanicInfo) -> ! { #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] -pub extern "C" fn deploy() {} +pub extern "C" fn deploy() { + assert_non_payable_deploy(&Host::new()); +} #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; - ContractBuilder::::new() - .method(ADD_SELECTOR, add_handler::) - .method(MULTIPLY_SELECTOR, multiply_handler::) - .method(IS_EVEN_SELECTOR, is_even_handler::) - .method(NEGATE_SELECTOR, negate_handler::) - .method(MAX_SELECTOR, max_handler::) - .method(HASH_SELECTOR, hash_handler::) - .method(SUM3_SELECTOR, sum3_handler::) - .method(BIT_AND_SELECTOR, bit_and_handler::) - .method(IS_ZERO_SELECTOR, is_zero_handler::) - .method(INCREMENT_SELECTOR, increment_handler::) + let host = Host::new(); + ContractBuilder::new() + .method(ADD_SELECTOR, add_handler) + .method(MULTIPLY_SELECTOR, multiply_handler) + .method(IS_EVEN_SELECTOR, is_even_handler) + .method(NEGATE_SELECTOR, negate_handler) + .method(MAX_SELECTOR, max_handler) + .method(HASH_SELECTOR, hash_handler) + .method(SUM3_SELECTOR, sum3_handler) + .method(BIT_AND_SELECTOR, bit_and_handler) + .method(IS_ZERO_SELECTOR, is_zero_handler) + .method(INCREMENT_SELECTOR, increment_handler) .dispatch_impl::<256>(&host); } -fn add_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn add_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = u32::decode_at(input, 0); let b = u32::decode_at(input, ::ENCODED_SIZE); let result = a.wrapping_add(b); @@ -55,7 +59,7 @@ fn add_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handle HandlerResult::Ok(len) } -fn multiply_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn multiply_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = u64::decode_at(input, 0); let b = u64::decode_at(input, ::ENCODED_SIZE); let result = a.wrapping_mul(b); @@ -64,7 +68,7 @@ fn multiply_handler(_host: &H, input: &[u8], output: &mut [u8]) -> H HandlerResult::Ok(len) } -fn is_even_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn is_even_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let n = u32::decode_at(input, 0); let result = (n & 1) == 0; let len = ::ENCODED_SIZE; @@ -72,7 +76,7 @@ fn is_even_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(len) } -fn negate_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn negate_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let value = U256::decode_at(input, 0); let result = !value + U256::from(1u8); let len = ::ENCODED_SIZE; @@ -80,7 +84,7 @@ fn negate_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Han HandlerResult::Ok(len) } -fn max_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn max_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = U256::decode_at(input, 0); let b = U256::decode_at(input, ::ENCODED_SIZE); let result = if a > b { a } else { b }; @@ -89,7 +93,7 @@ fn max_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handle HandlerResult::Ok(len) } -fn hash_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn hash_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let account = <[u8; 20]>::decode_at(input, 0); let mut bytes = [0u8; 32]; bytes[12..].copy_from_slice(&account); @@ -99,7 +103,7 @@ fn hash_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handl HandlerResult::Ok(len) } -fn sum3_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn sum3_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = u32::decode_at(input, 0); let b = u32::decode_at(input, ::ENCODED_SIZE); let c = u32::decode_at(input, ::ENCODED_SIZE * 2); @@ -109,7 +113,7 @@ fn sum3_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handl HandlerResult::Ok(len) } -fn bit_and_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn bit_and_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = U256::decode_at(input, 0); let b = U256::decode_at(input, ::ENCODED_SIZE); let result = a & b; @@ -118,7 +122,7 @@ fn bit_and_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(len) } -fn is_zero_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn is_zero_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let value = U256::decode_at(input, 0); let result = value == U256::ZERO; let len = ::ENCODED_SIZE; @@ -126,7 +130,7 @@ fn is_zero_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(len) } -fn increment_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn increment_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let n = u32::decode_at(input, 0); let result = n.wrapping_add(1); let len = ::ENCODED_SIZE; diff --git a/crates/cargo-pvm-contract/templates/examples/multi/multi_no_alloc.rs b/crates/cargo-pvm-contract/templates/examples/multi/multi_no_alloc.rs index 0e75739e..f018fd20 100644 --- a/crates/cargo-pvm-contract/templates/examples/multi/multi_no_alloc.rs +++ b/crates/cargo-pvm-contract/templates/examples/multi/multi_no_alloc.rs @@ -23,27 +23,27 @@ mod multi { } #[pvm_contract_sdk::method] - pub fn add(&self, a: u32, b: u32) -> u32 { + pub fn add(a: u32, b: u32) -> u32 { a.wrapping_add(b) } #[pvm_contract_sdk::method] - pub fn multiply(&self, a: u64, b: u64) -> u64 { + pub fn multiply(a: u64, b: u64) -> u64 { a.wrapping_mul(b) } #[pvm_contract_sdk::method] - pub fn is_even(&self, n: u32) -> bool { + pub fn is_even(n: u32) -> bool { (n & 1) == 0 } #[pvm_contract_sdk::method] - pub fn negate(&self, value: U256) -> U256 { + pub fn negate(value: U256) -> U256 { !value + U256::from(1u8) } #[pvm_contract_sdk::method] - pub fn max(&self, a: U256, b: U256) -> U256 { + pub fn max(a: U256, b: U256) -> U256 { if a > b { a } else { b } } @@ -55,22 +55,22 @@ mod multi { } #[pvm_contract_sdk::method] - pub fn sum3(&self, a: u32, b: u32, c: u32) -> u32 { + pub fn sum3(a: u32, b: u32, c: u32) -> u32 { a.wrapping_add(b).wrapping_add(c) } #[pvm_contract_sdk::method] - pub fn bit_and(&self, a: U256, b: U256) -> U256 { + pub fn bit_and(a: U256, b: U256) -> U256 { a & b } #[pvm_contract_sdk::method] - pub fn is_zero(&self, value: U256) -> bool { + pub fn is_zero(value: U256) -> bool { value == U256::ZERO } #[pvm_contract_sdk::method] - pub fn increment(&self, n: u32) -> u32 { + pub fn increment(n: u32) -> u32 { n.wrapping_add(1) } } diff --git a/crates/cargo-pvm-contract/templates/examples/multi/multi_with_alloc.rs b/crates/cargo-pvm-contract/templates/examples/multi/multi_with_alloc.rs index a6db8b98..c9727786 100644 --- a/crates/cargo-pvm-contract/templates/examples/multi/multi_with_alloc.rs +++ b/crates/cargo-pvm-contract/templates/examples/multi/multi_with_alloc.rs @@ -23,27 +23,27 @@ mod multi { } #[pvm_contract_sdk::method] - pub fn add(&self, a: u32, b: u32) -> u32 { + pub fn add(a: u32, b: u32) -> u32 { a.wrapping_add(b) } #[pvm_contract_sdk::method] - pub fn multiply(&self, a: u64, b: u64) -> u64 { + pub fn multiply(a: u64, b: u64) -> u64 { a.wrapping_mul(b) } #[pvm_contract_sdk::method] - pub fn is_even(&self, n: u32) -> bool { + pub fn is_even(n: u32) -> bool { (n & 1) == 0 } #[pvm_contract_sdk::method] - pub fn negate(&self, value: U256) -> U256 { + pub fn negate(value: U256) -> U256 { !value + U256::from(1u8) } #[pvm_contract_sdk::method] - pub fn max(&self, a: U256, b: U256) -> U256 { + pub fn max(a: U256, b: U256) -> U256 { if a > b { a } else { b } } @@ -55,22 +55,22 @@ mod multi { } #[pvm_contract_sdk::method] - pub fn sum3(&self, a: u32, b: u32, c: u32) -> u32 { + pub fn sum3(a: u32, b: u32, c: u32) -> u32 { a.wrapping_add(b).wrapping_add(c) } #[pvm_contract_sdk::method] - pub fn bit_and(&self, a: U256, b: U256) -> U256 { + pub fn bit_and(a: U256, b: U256) -> U256 { a & b } #[pvm_contract_sdk::method] - pub fn is_zero(&self, value: U256) -> bool { + pub fn is_zero(value: U256) -> bool { value == U256::ZERO } #[pvm_contract_sdk::method] - pub fn increment(&self, n: u32) -> u32 { + pub fn increment(n: u32) -> u32 { n.wrapping_add(1) } } diff --git a/crates/cargo-pvm-contract/templates/examples/mytoken/mytoken_dsl.rs b/crates/cargo-pvm-contract/templates/examples/mytoken/mytoken_dsl.rs index d944ba6c..50ea6c14 100644 --- a/crates/cargo-pvm-contract/templates/examples/mytoken/mytoken_dsl.rs +++ b/crates/cargo-pvm-contract/templates/examples/mytoken/mytoken_dsl.rs @@ -1,9 +1,11 @@ #![no_main] #![no_std] -use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; +use pvm_contract_builder_dsl::{ + ContractBuilder, HandlerResult, assert_non_payable_deploy, solidity_selector, +}; use pvm_contract_builder_dsl::pvm_contract_types::{ - Address, HostApi, PolkaVmHost, SolDecode, SolEncode, StaticEncodedLen, StorageFlags, + Address, Host, HostApi, SolDecode, SolEncode, StaticEncodedLen, StorageFlags, }; use pvm_contract_builder_dsl::ruint::aliases::U256; @@ -27,21 +29,23 @@ fn panic(_info: &core::panic::PanicInfo) -> ! { #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] -pub extern "C" fn deploy() {} +pub extern "C" fn deploy() { + assert_non_payable_deploy(&Host::new()); +} #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; - ContractBuilder::::new() - .method(TOTAL_SUPPLY_SELECTOR, total_supply_handler::) - .method(BALANCE_OF_SELECTOR, balance_of_handler::) - .method(TRANSFER_SELECTOR, transfer_handler::) - .method(MINT_SELECTOR, mint_handler::) + let host = Host::new(); + ContractBuilder::new() + .method(TOTAL_SUPPLY_SELECTOR, total_supply_handler) + .method(BALANCE_OF_SELECTOR, balance_of_handler) + .method(TRANSFER_SELECTOR, transfer_handler) + .method(MINT_SELECTOR, mint_handler) .dispatch_impl::<256>(&host); } -fn total_supply_handler(host: &H, _input: &[u8], output: &mut [u8]) -> HandlerResult { +fn total_supply_handler(host: &Host, _input: &[u8], output: &mut [u8]) -> HandlerResult { let key = total_supply_key(); let mut supply_bytes = [0u8; 32]; let mut supply_slice = &mut supply_bytes[..]; @@ -55,7 +59,7 @@ fn total_supply_handler(host: &H, _input: &[u8], output: &mut [u8]) HandlerResult::Ok(len) } -fn balance_of_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn balance_of_handler(host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let account =
::decode_at(input, 0); let account: [u8; 20] = account.into(); let key = balance_key(host, &account); @@ -71,7 +75,7 @@ fn balance_of_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult::Ok(len) } -fn transfer_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn transfer_handler(host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let to =
::decode_at(input, 0); let to: [u8; 20] = to.into(); let amount = U256::decode_at(input,
::ENCODED_SIZE); @@ -112,7 +116,7 @@ fn transfer_handler(host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(0) } -fn mint_handler(host: &H, input: &[u8], _output: &mut [u8]) -> HandlerResult { +fn mint_handler(host: &Host, input: &[u8], _output: &mut [u8]) -> HandlerResult { let to =
::decode_at(input, 0); let to: [u8; 20] = to.into(); let amount = U256::decode_at(input,
::ENCODED_SIZE); @@ -149,7 +153,7 @@ fn total_supply_key() -> [u8; 32] { [0u8; 32] } -fn balance_key(host: &H, addr: &[u8; 20]) -> [u8; 32] { +fn balance_key(host: &Host, addr: &[u8; 20]) -> [u8; 32] { let mut input = [0u8; 64]; input[12..32].copy_from_slice(addr); input[63] = 1; @@ -159,23 +163,23 @@ fn balance_key(host: &H, addr: &[u8; 20]) -> [u8; 32] { key } -fn set_total_supply(host: &H, amount: U256) { +fn set_total_supply(host: &Host, amount: U256) { let key = total_supply_key(); host.set_storage(StorageFlags::empty(), &key, &amount.to_be_bytes::<32>()); } -fn set_balance(host: &H, addr: &[u8; 20], amount: U256) { +fn set_balance(host: &Host, addr: &[u8; 20], amount: U256) { let key = balance_key(host, addr); host.set_storage(StorageFlags::empty(), &key, &amount.to_be_bytes::<32>()); } -fn get_caller(host: &H) -> [u8; 20] { +fn get_caller(host: &Host) -> [u8; 20] { let mut caller = [0u8; 20]; host.caller(&mut caller); caller } -fn emit_transfer(host: &H, from: &[u8; 20], to: &[u8; 20], value: U256) { +fn emit_transfer(host: &Host, from: &[u8; 20], to: &[u8; 20], value: U256) { let mut from_topic = [0u8; 32]; from_topic[12..32].copy_from_slice(from); diff --git a/crates/cargo-pvm-contract/templates/scaffold/contract_dsl.rs.txt b/crates/cargo-pvm-contract/templates/scaffold/contract_dsl.rs.txt index 92c8480a..94e1e627 100644 --- a/crates/cargo-pvm-contract/templates/scaffold/contract_dsl.rs.txt +++ b/crates/cargo-pvm-contract/templates/scaffold/contract_dsl.rs.txt @@ -49,8 +49,10 @@ unsafe impl core::alloc::GlobalAlloc for BumpAllocator { static ALLOC: BumpAllocator = BumpAllocator; {% endif %} -use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; -use pvm_contract_builder_dsl::pvm_contract_types::{HostApi, PolkaVmHost, SolDecode, SolEncode, StaticEncodedLen}; +use pvm_contract_builder_dsl::{ + ContractBuilder, HandlerResult, assert_non_payable_deploy, solidity_selector, +}; +use pvm_contract_builder_dsl::pvm_contract_types::{Host, SolDecode, SolEncode, StaticEncodedLen}; use pvm_contract_builder_dsl::ruint::aliases::U256; #[cfg(not(test))] @@ -71,24 +73,26 @@ const {{ func.selector_const }}: [u8; 4] = solidity_selector("{{ func.solidity_s #[cfg(not(test))] #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] -pub extern "C" fn deploy() {} +pub extern "C" fn deploy() { + assert_non_payable_deploy(&Host::new()); +} #[cfg(not(test))] #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; + let host = Host::new(); {% if functions.is_empty() %} // Register your method handlers: // const MY_METHOD: [u8; 4] = solidity_selector("myMethod(uint256)"); - // ContractBuilder::::new() - // .method(MY_METHOD, my_method_handler::) + // ContractBuilder::new() + // .method(MY_METHOD, my_method_handler) // .dispatch_impl::<256>(&host); - ContractBuilder::::new().dispatch_impl::<256>(&host); + ContractBuilder::new().dispatch_impl::<256>(&host); {% else %} - ContractBuilder::::new() + ContractBuilder::new() {% for func in functions %} - .method({{ func.selector_const }}, {{ func.name_snake }}_handler::) + .method({{ func.selector_const }}, {{ func.name_snake }}_handler) {% endfor %} .dispatch_impl::<256>(&host); {% endif %} @@ -96,8 +100,8 @@ pub extern "C" fn call() { {% if !functions.is_empty() %} {% for func in functions %} -fn {{ func.name_snake }}_handler( - _host: &H, +fn {{ func.name_snake }}_handler( + _host: &Host, {% if func.params.is_empty() %}_input{% else %}input{% endif %}: &[u8], output: &mut [u8], ) -> HandlerResult { diff --git a/crates/pvm-contract-builder-dsl/contracts/fibonacci_builder.rs b/crates/pvm-contract-builder-dsl/contracts/fibonacci_builder.rs index 5fc50d14..eb8b2608 100644 --- a/crates/pvm-contract-builder-dsl/contracts/fibonacci_builder.rs +++ b/crates/pvm-contract-builder-dsl/contracts/fibonacci_builder.rs @@ -3,7 +3,7 @@ #![no_std] use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; -use pvm_contract_builder_dsl::pvm_contract_types::{HostApi, PolkaVmHost, SolDecode, SolEncode, StaticEncodedLen}; +use pvm_contract_builder_dsl::pvm_contract_types::{Host, SolDecode, SolEncode, StaticEncodedLen}; const FIBONACCI_SELECTOR: [u8; 4] = solidity_selector("fibonacci(uint32)"); @@ -22,13 +22,13 @@ pub extern "C" fn deploy() {} #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; - ContractBuilder::::new() - .method(FIBONACCI_SELECTOR, fibonacci_handler::) + let host = Host::new(); + ContractBuilder::new() + .method(FIBONACCI_SELECTOR, fibonacci_handler) .dispatch_impl::<256>(&host); } -fn fibonacci_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn fibonacci_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let n = u32::decode_at(input, 0); let result = fibonacci(n); let len = ::ENCODED_SIZE; diff --git a/crates/pvm-contract-builder-dsl/contracts/multi_builder.rs b/crates/pvm-contract-builder-dsl/contracts/multi_builder.rs index d4b58ad4..4066a9ba 100644 --- a/crates/pvm-contract-builder-dsl/contracts/multi_builder.rs +++ b/crates/pvm-contract-builder-dsl/contracts/multi_builder.rs @@ -3,7 +3,7 @@ #![no_std] use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; -use pvm_contract_builder_dsl::pvm_contract_types::{HostApi, PolkaVmHost, SolDecode, SolEncode, StaticEncodedLen}; +use pvm_contract_builder_dsl::pvm_contract_types::{Host, SolDecode, SolEncode, StaticEncodedLen}; use pvm_contract_builder_dsl::ruint::aliases::U256; const ADD_SELECTOR: [u8; 4] = solidity_selector("add(uint32,uint32)"); @@ -32,22 +32,22 @@ pub extern "C" fn deploy() {} #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; - ContractBuilder::::new() - .method(ADD_SELECTOR, add_handler::) - .method(MULTIPLY_SELECTOR, multiply_handler::) - .method(IS_EVEN_SELECTOR, is_even_handler::) - .method(NEGATE_SELECTOR, negate_handler::) - .method(MAX_SELECTOR, max_handler::) - .method(HASH_SELECTOR, hash_handler::) - .method(SUM3_SELECTOR, sum3_handler::) - .method(BIT_AND_SELECTOR, bit_and_handler::) - .method(IS_ZERO_SELECTOR, is_zero_handler::) - .method(INCREMENT_SELECTOR, increment_handler::) + let host = Host::new(); + ContractBuilder::new() + .method(ADD_SELECTOR, add_handler) + .method(MULTIPLY_SELECTOR, multiply_handler) + .method(IS_EVEN_SELECTOR, is_even_handler) + .method(NEGATE_SELECTOR, negate_handler) + .method(MAX_SELECTOR, max_handler) + .method(HASH_SELECTOR, hash_handler) + .method(SUM3_SELECTOR, sum3_handler) + .method(BIT_AND_SELECTOR, bit_and_handler) + .method(IS_ZERO_SELECTOR, is_zero_handler) + .method(INCREMENT_SELECTOR, increment_handler) .dispatch_impl::<256>(&host); } -fn add_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn add_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = u32::decode_at(input, 0); let b = u32::decode_at(input, ::ENCODED_SIZE); let result = a.wrapping_add(b); @@ -56,7 +56,7 @@ fn add_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handle HandlerResult::Ok(len) } -fn multiply_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn multiply_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = u64::decode_at(input, 0); let b = u64::decode_at(input, ::ENCODED_SIZE); let result = a.wrapping_mul(b); @@ -65,7 +65,7 @@ fn multiply_handler(_host: &H, input: &[u8], output: &mut [u8]) -> H HandlerResult::Ok(len) } -fn is_even_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn is_even_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let n = u32::decode_at(input, 0); let result = (n & 1) == 0; let len = ::ENCODED_SIZE; @@ -73,7 +73,7 @@ fn is_even_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(len) } -fn negate_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn negate_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let value = U256::decode_at(input, 0); let result = !value + U256::from(1u8); let len = ::ENCODED_SIZE; @@ -81,7 +81,7 @@ fn negate_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Han HandlerResult::Ok(len) } -fn max_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn max_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = U256::decode_at(input, 0); let b = U256::decode_at(input, ::ENCODED_SIZE); let result = if a > b { a } else { b }; @@ -90,7 +90,7 @@ fn max_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handle HandlerResult::Ok(len) } -fn hash_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn hash_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let account = <[u8; 20]>::decode_at(input, 0); let mut bytes = [0u8; 32]; bytes[12..].copy_from_slice(&account); @@ -100,7 +100,7 @@ fn hash_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handl HandlerResult::Ok(len) } -fn sum3_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn sum3_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = u32::decode_at(input, 0); let b = u32::decode_at(input, ::ENCODED_SIZE); let c = u32::decode_at(input, ::ENCODED_SIZE * 2); @@ -110,7 +110,7 @@ fn sum3_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Handl HandlerResult::Ok(len) } -fn bit_and_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn bit_and_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let a = U256::decode_at(input, 0); let b = U256::decode_at(input, ::ENCODED_SIZE); let result = a & b; @@ -119,7 +119,7 @@ fn bit_and_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(len) } -fn is_zero_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn is_zero_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let value = U256::decode_at(input, 0); let result = value == U256::ZERO; let len = ::ENCODED_SIZE; @@ -127,7 +127,7 @@ fn is_zero_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(len) } -fn increment_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn increment_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let n = u32::decode_at(input, 0); let result = n.wrapping_add(1); let len = ::ENCODED_SIZE; diff --git a/crates/pvm-contract-builder-dsl/contracts/mytoken_builder.rs b/crates/pvm-contract-builder-dsl/contracts/mytoken_builder.rs index 3a696bdb..5caf26fa 100644 --- a/crates/pvm-contract-builder-dsl/contracts/mytoken_builder.rs +++ b/crates/pvm-contract-builder-dsl/contracts/mytoken_builder.rs @@ -4,7 +4,7 @@ use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; use pvm_contract_builder_dsl::pvm_contract_types::{ - HostApi, PolkaVmHost, SolDecode, SolEncode, StaticEncodedLen, StorageFlags, + Host, HostApi, SolDecode, SolEncode, StaticEncodedLen, StorageFlags, }; use pvm_contract_builder_dsl::ruint::aliases::U256; @@ -33,16 +33,16 @@ pub extern "C" fn deploy() {} #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; - ContractBuilder::::new() - .method(TOTAL_SUPPLY_SELECTOR, total_supply_handler::) - .method(BALANCE_OF_SELECTOR, balance_of_handler::) - .method(TRANSFER_SELECTOR, transfer_handler::) - .method(MINT_SELECTOR, mint_handler::) + let host = Host::new(); + ContractBuilder::new() + .method(TOTAL_SUPPLY_SELECTOR, total_supply_handler) + .method(BALANCE_OF_SELECTOR, balance_of_handler) + .method(TRANSFER_SELECTOR, transfer_handler) + .method(MINT_SELECTOR, mint_handler) .dispatch_impl::<256>(&host); } -fn total_supply_handler(host: &H, _input: &[u8], output: &mut [u8]) -> HandlerResult { +fn total_supply_handler(host: &Host, _input: &[u8], output: &mut [u8]) -> HandlerResult { let key = total_supply_key(); let mut supply_bytes = [0u8; 32]; let mut supply_slice = &mut supply_bytes[..]; @@ -56,7 +56,7 @@ fn total_supply_handler(host: &H, _input: &[u8], output: &mut [u8]) HandlerResult::Ok(len) } -fn balance_of_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn balance_of_handler(host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let account = <[u8; 20]>::decode_at(input, 0); let key = balance_key(host, &account); let mut balance_bytes = [0u8; 32]; @@ -71,7 +71,7 @@ fn balance_of_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult::Ok(len) } -fn transfer_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn transfer_handler(host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let to = <[u8; 20]>::decode_at(input, 0); let amount = U256::decode_at(input, <[u8; 20] as StaticEncodedLen>::ENCODED_SIZE); @@ -111,7 +111,7 @@ fn transfer_handler(host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(0) } -fn mint_handler(host: &H, input: &[u8], _output: &mut [u8]) -> HandlerResult { +fn mint_handler(host: &Host, input: &[u8], _output: &mut [u8]) -> HandlerResult { let to = <[u8; 20]>::decode_at(input, 0); let amount = U256::decode_at(input, <[u8; 20] as StaticEncodedLen>::ENCODED_SIZE); @@ -148,7 +148,7 @@ fn total_supply_key() -> [u8; 32] { [0u8; 32] } -fn balance_key(host: &H, addr: &[u8; 20]) -> [u8; 32] { +fn balance_key(host: &Host, addr: &[u8; 20]) -> [u8; 32] { let mut input = [0u8; 64]; input[12..32].copy_from_slice(addr); input[63] = 1; @@ -158,23 +158,23 @@ fn balance_key(host: &H, addr: &[u8; 20]) -> [u8; 32] { key } -fn set_total_supply(host: &H, amount: U256) { +fn set_total_supply(host: &Host, amount: U256) { let key = total_supply_key(); host.set_storage(StorageFlags::empty(), &key, &amount.to_be_bytes::<32>()); } -fn set_balance(host: &H, addr: &[u8; 20], amount: U256) { +fn set_balance(host: &Host, addr: &[u8; 20], amount: U256) { let key = balance_key(host, addr); host.set_storage(StorageFlags::empty(), &key, &amount.to_be_bytes::<32>()); } -fn get_caller(host: &H) -> [u8; 20] { +fn get_caller(host: &Host) -> [u8; 20] { let mut caller = [0u8; 20]; host.caller(&mut caller); caller } -fn emit_transfer(host: &H, from: &[u8; 20], to: &[u8; 20], value: U256) { +fn emit_transfer(host: &Host, from: &[u8; 20], to: &[u8; 20], value: U256) { let mut from_topic = [0u8; 32]; from_topic[12..32].copy_from_slice(from); diff --git a/crates/pvm-contract-builder-dsl/src/lib.rs b/crates/pvm-contract-builder-dsl/src/lib.rs index 6fd0d901..5788c810 100644 --- a/crates/pvm-contract-builder-dsl/src/lib.rs +++ b/crates/pvm-contract-builder-dsl/src/lib.rs @@ -1,8 +1,6 @@ #![doc = include_str!("../../../specs/builder-dsl.md")] #![no_std] -use core::marker::PhantomData; - pub use pallet_revive_uapi; pub use pallet_revive_uapi::solidity_selector; pub use polkavm_derive; @@ -10,11 +8,27 @@ pub use polkavm_derive::polkavm_export; pub use pvm_contract_types; pub use ruint; -use pvm_contract_types::ReturnFlags; +use pvm_contract_types::{Host, HostApi, ReturnFlags}; /// 4-byte Solidity function selector. pub type Selector = [u8; 4]; +/// Revert the `deploy` entry point if any value was attached. +/// +/// Solidity's default constructor is non-payable, and the `#[contract]` macro +/// path auto-injects an equivalent guard. The DSL has no codegen step, so +/// scaffolded `deploy()` functions must call this explicitly. Omit the call +/// only when the constructor is intentionally payable. +#[inline(always)] +pub fn assert_non_payable_deploy(host: &Host) { + if pvm_contract_types::value_transferred_is_nonzero(host) { + host.return_value( + ReturnFlags::REVERT, + &pvm_contract_types::framework_errors::NON_PAYABLE_VALUE_RECEIVED, + ); + } +} + /// The result a [`MethodHandler`] returns to the dispatcher. /// /// `Ok(n)` — success; `n` bytes were written to the caller-supplied output buffer. @@ -44,25 +58,22 @@ pub enum HandlerResult { /// - The returned `HandlerResult::Ok(n)` / `Revert(n)` must satisfy /// `n <= output.len()`; the dispatcher clamps but will not re-read the /// buffer past `n` bytes. -pub type MethodHandler = fn(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult; +pub type MethodHandler = fn(host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult; /// Maximum number of methods a single contract can register. const MAX_METHODS: usize = 16; -fn noop_handler( - _host: &H, - _input: &[u8], - _output: &mut [u8], -) -> HandlerResult { +#[inline(always)] +fn noop_handler(_host: &Host, _input: &[u8], _output: &mut [u8]) -> HandlerResult { HandlerResult::Ok(0) } /// Pure Rust builder for PVM smart contract dispatch. /// -/// Generic over the host type so only one monomorphization lands in any given -/// binary. In production that's `PolkaVmHost` (a zero-sized type — the builder plus -/// dispatch loop is byte-equivalent to today's static-call version). In unit -/// tests it's `MockHost`, compiled into the host-target test binary only. +/// Handlers take a concrete `&Host`; on riscv64 `Host` is a zero-sized wrapper +/// around `PolkaVmHost`, so production builds pay no indirection. In native +/// unit tests `Host` wraps `Rc` (via [`Host::from_dyn`]) so a +/// `MockHost` can back the same handlers. /// /// Methods registered via [`method`](Self::method) are non-payable: the /// dispatcher reverts with @@ -74,53 +85,56 @@ fn noop_handler( /// /// ```ignore /// use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; -/// use pvm_contract_types::{HostApi, PolkaVmHost}; +/// use pvm_contract_types::Host; /// /// const FIB: [u8; 4] = solidity_selector("fibonacci(uint32)"); /// -/// fn fibonacci(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +/// fn fibonacci(_host: &Host, _input: &[u8], _output: &mut [u8]) -> HandlerResult { /// // decode n, compute fib(n), encode into output[..32] /// HandlerResult::Ok(32) /// } /// /// #[cfg(target_arch = "riscv64")] /// pub extern "C" fn call() { -/// let host = PolkaVmHost; -/// ContractBuilder::::new() -/// .method(FIB, fibonacci::) +/// let host = Host::new(); +/// ContractBuilder::new() +/// .method(FIB, fibonacci) /// .dispatch_impl::<256>(&host); /// } /// ``` -pub struct ContractBuilder { - methods: [(Selector, MethodHandler); MAX_METHODS], +pub struct ContractBuilder { + methods: [(Selector, MethodHandler); MAX_METHODS], len: usize, payable_bits: u64, - _marker: PhantomData, } -impl Default for ContractBuilder { +impl Default for ContractBuilder { fn default() -> Self { Self::new() } } -impl ContractBuilder { +impl ContractBuilder { /// Create a new empty contract builder. + #[inline(always)] pub fn new() -> Self { Self { - methods: [([0; 4], noop_handler:: as MethodHandler); MAX_METHODS], + methods: [([0; 4], noop_handler as MethodHandler); MAX_METHODS], len: 0, payable_bits: 0, - _marker: PhantomData, } } /// Register a non-payable method handler for the given selector. /// + /// Rejects calls carrying a non-zero value transfer at the dispatch + /// boundary; the handler itself is not called in that case. + /// /// # Panics /// /// Panics if more than MAX_METHODS methods are registered. - pub fn method(mut self, selector: Selector, handler: MethodHandler) -> Self { + #[inline(always)] + pub fn method(mut self, selector: Selector, handler: MethodHandler) -> Self { assert!( self.len < MAX_METHODS, "ContractBuilder: exceeded MAX_METHODS ({})", @@ -139,7 +153,8 @@ impl ContractBuilder { /// # Panics /// /// Panics if more than MAX_METHODS methods are registered. - pub fn payable_method(mut self, selector: Selector, handler: MethodHandler) -> Self { + #[inline(always)] + pub fn payable_method(mut self, selector: Selector, handler: MethodHandler) -> Self { assert!( self.len < MAX_METHODS, "ContractBuilder: exceeded MAX_METHODS ({})", @@ -161,7 +176,7 @@ impl ContractBuilder { #[inline(always)] pub fn try_route( &self, - host: &H, + host: &Host, selector: Selector, input: &[u8], output: &mut [u8], @@ -195,10 +210,16 @@ impl ContractBuilder { /// Mirrors the `#[contract]` macro's `route()` shape: same dispatch /// architecture across DSL and macro paths, same test ergonomics. /// - /// `#[inline]` keeps the dispatcher tight when called from a single - /// `extern "C" fn call()` entry point. - #[inline] - pub fn dispatch_impl(&self, host: &H) { + /// `#[inline(always)]` keeps the dispatcher tight when called from a + /// single `extern "C" fn call()` entry point. Force-inline (not just hint) + /// is required to preserve the cross-crate constant-folding that the + /// previous `` generic gave us "for free" via monomorphization + /// — generics are always inlined-visible at the call site, but a plain + /// `#[inline]` non-generic function is only a hint the inliner may + /// decline, which produces an indirect-call dispatch and several hundred + /// extra bytes of bytecode. + #[inline(always)] + pub fn dispatch_impl(&self, host: &Host) { let call_data_len = host.call_data_size() as usize; if call_data_len > BUF_SIZE { @@ -245,63 +266,56 @@ impl ContractBuilder { #[cfg(test)] mod tests { use super::*; - use pvm_contract_types::MockHost; + use pvm_contract_types::Host; const DEPOSIT: Selector = [0xde, 0x00, 0x00, 0x01]; const TRANSFER: Selector = [0x7f, 0x00, 0x00, 0x02]; - fn dummy_handler( - _host: &H, - _input: &[u8], - _output: &mut [u8], - ) -> HandlerResult { + fn dummy_handler(_host: &Host, _input: &[u8], _output: &mut [u8]) -> HandlerResult { HandlerResult::Ok(0) } #[test] #[should_panic(expected = "MAX_METHODS")] fn method_panics_on_overflow() { - let mut builder = ContractBuilder::::new(); + let mut builder = ContractBuilder::new(); for i in 0..=MAX_METHODS { - builder = builder.method([i as u8, 0, 0, 0], dummy_handler::); + builder = builder.method([i as u8, 0, 0, 0], dummy_handler); } } #[test] #[should_panic(expected = "MAX_METHODS")] fn payable_method_panics_on_overflow() { - let mut builder = ContractBuilder::::new(); + let mut builder = ContractBuilder::new(); for i in 0..=MAX_METHODS { - builder = builder.payable_method([i as u8, 0, 0, 0], dummy_handler::); + builder = builder.payable_method([i as u8, 0, 0, 0], dummy_handler); } } #[test] fn payable_bit_set_correctly() { - let builder = ContractBuilder::::new() - .method(TRANSFER, dummy_handler::) - .payable_method(DEPOSIT, dummy_handler::); + let builder = ContractBuilder::new() + .method(TRANSFER, dummy_handler) + .payable_method(DEPOSIT, dummy_handler); assert_eq!(builder.payable_bits, 0b10); } #[test] fn payable_bit_survives_for_high_index() { - let mut builder = ContractBuilder::::new(); + let mut builder = ContractBuilder::new(); for i in 0..(MAX_METHODS - 1) { - builder = builder.method([i as u8, 0, 0, 0xaa], dummy_handler::); + builder = builder.method([i as u8, 0, 0, 0xaa], dummy_handler); } - builder = builder.payable_method( - [(MAX_METHODS - 1) as u8, 0, 0, 0xaa], - dummy_handler::, - ); + builder = builder.payable_method([(MAX_METHODS - 1) as u8, 0, 0, 0xaa], dummy_handler); assert_eq!(builder.payable_bits, 1u64 << (MAX_METHODS - 1)); } #[test] fn non_payable_contract_has_zero_payable_bits() { - let builder = ContractBuilder::::new() - .method(TRANSFER, dummy_handler::) - .method(DEPOSIT, dummy_handler::); + let builder = ContractBuilder::new() + .method(TRANSFER, dummy_handler) + .method(DEPOSIT, dummy_handler); assert_eq!(builder.payable_bits, 0); } } diff --git a/crates/pvm-contract-builder-dsl/tests/mock_dispatch.rs b/crates/pvm-contract-builder-dsl/tests/mock_dispatch.rs index 63fbe3e4..59dacd07 100644 --- a/crates/pvm-contract-builder-dsl/tests/mock_dispatch.rs +++ b/crates/pvm-contract-builder-dsl/tests/mock_dispatch.rs @@ -5,16 +5,20 @@ //! `MockHost::take_return_value()`. Mirrors the `#[contract]` macro's test //! pattern — same shape across both dispatch paths. -use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; +use std::rc::Rc; + +use pvm_contract_builder_dsl::{ + ContractBuilder, HandlerResult, assert_non_payable_deploy, solidity_selector, +}; use pvm_contract_types::{ - HostApi, MockHost, MockHostBuilder, ReturnFlags, ReturnValue, SolDecode, SolEncode, + Host, MockHost, MockHostBuilder, ReturnFlags, ReturnValue, SolDecode, SolEncode, StaticEncodedLen, }; const DOUBLE_SELECTOR: [u8; 4] = solidity_selector("double(uint32)"); const PING_SELECTOR: [u8; 4] = solidity_selector("ping()"); -fn double_handler(_host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn double_handler(_host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let n = u32::decode_at(input, 0); let result = n.wrapping_mul(2); let len = ::ENCODED_SIZE; @@ -22,14 +26,14 @@ fn double_handler(_host: &H, input: &[u8], output: &mut [u8]) -> Han HandlerResult::Ok(len) } -fn ping_handler(_host: &H, _input: &[u8], _output: &mut [u8]) -> HandlerResult { +fn ping_handler(_host: &Host, _input: &[u8], _output: &mut [u8]) -> HandlerResult { HandlerResult::Ok(0) } -fn builder() -> ContractBuilder { - ContractBuilder::::new() - .method(DOUBLE_SELECTOR, double_handler::) - .method(PING_SELECTOR, ping_handler::) +fn builder() -> ContractBuilder { + ContractBuilder::new() + .method(DOUBLE_SELECTOR, double_handler) + .method(PING_SELECTOR, ping_handler) } fn encode_call_double(n: u32) -> Vec { @@ -40,38 +44,48 @@ fn encode_call_double(n: u32) -> Vec { calldata } -fn drive(host: &MockHost) -> ReturnValue { - builder().dispatch_impl::<256>(host); - host.take_return_value() +fn wrap(mock: &Rc) -> Host { + Host::from_dyn(mock.clone()) +} + +fn drive(mock: &Rc) -> ReturnValue { + builder().dispatch_impl::<256>(&wrap(mock)); + mock.take_return_value() .expect("dispatch should call return_value") } #[test] fn double_returns_doubled_value() { - let host = MockHostBuilder::new() - .calldata(encode_call_double(21)) - .build(); - let rv = drive(&host); + let mock = Rc::new( + MockHostBuilder::new() + .calldata(encode_call_double(21)) + .build(), + ); + let rv = drive(&mock); assert_eq!(rv.flags, ReturnFlags::empty()); assert_eq!(u32::decode_at(&rv.data, 0), 42); } #[test] fn ping_returns_empty_success() { - let host = MockHostBuilder::new() - .calldata(PING_SELECTOR.to_vec()) - .build(); - let rv = drive(&host); + let mock = Rc::new( + MockHostBuilder::new() + .calldata(PING_SELECTOR.to_vec()) + .build(), + ); + let rv = drive(&mock); assert_eq!(rv.flags, ReturnFlags::empty()); assert_eq!(rv.data.len(), 0); } #[test] fn unknown_selector_reverts() { - let host = MockHostBuilder::new() - .calldata(vec![0xde, 0xad, 0xbe, 0xef]) - .build(); - let rv = drive(&host); + let mock = Rc::new( + MockHostBuilder::new() + .calldata(vec![0xde, 0xad, 0xbe, 0xef]) + .build(), + ); + let rv = drive(&mock); assert_eq!(rv.flags, ReturnFlags::REVERT); assert_eq!( rv.data, @@ -81,8 +95,8 @@ fn unknown_selector_reverts() { #[test] fn short_calldata_reverts() { - let host = MockHostBuilder::new().calldata(vec![0x00]).build(); - let rv = drive(&host); + let mock = Rc::new(MockHostBuilder::new().calldata(vec![0x00]).build()); + let rv = drive(&mock); assert_eq!(rv.flags, ReturnFlags::REVERT); assert_eq!( rv.data, @@ -96,21 +110,23 @@ fn handler_revert_is_reflected_in_outcome() { // `flags == REVERT` with the handler-written payload intact. const FAIL_SELECTOR: [u8; 4] = solidity_selector("fail()"); - fn always_fails(_host: &H, _input: &[u8], output: &mut [u8]) -> HandlerResult { + fn always_fails(_host: &Host, _input: &[u8], output: &mut [u8]) -> HandlerResult { let msg = b"not allowed"; output[..msg.len()].copy_from_slice(msg); HandlerResult::Revert(msg.len()) } - let host = MockHostBuilder::new() - .calldata(FAIL_SELECTOR.to_vec()) - .build(); + let mock = Rc::new( + MockHostBuilder::new() + .calldata(FAIL_SELECTOR.to_vec()) + .build(), + ); - ContractBuilder::::new() - .method(FAIL_SELECTOR, always_fails::) - .dispatch_impl::<256>(&host); + ContractBuilder::new() + .method(FAIL_SELECTOR, always_fails) + .dispatch_impl::<256>(&wrap(&mock)); - let rv = host + let rv = mock .take_return_value() .expect("dispatch called return_value"); assert_eq!(rv.flags, ReturnFlags::REVERT); @@ -123,21 +139,23 @@ fn handler_returning_oversize_len_is_clamped() { // be clamped by the dispatcher rather than panicking on slice access. const BAD_SELECTOR: [u8; 4] = solidity_selector("bad()"); - fn bogus_len(_host: &H, _input: &[u8], output: &mut [u8]) -> HandlerResult { + fn bogus_len(_host: &Host, _input: &[u8], output: &mut [u8]) -> HandlerResult { // Only wrote 4 bytes but claims 9999. output[..4].copy_from_slice(&[1, 2, 3, 4]); HandlerResult::Ok(9999) } - let host = MockHostBuilder::new() - .calldata(BAD_SELECTOR.to_vec()) - .build(); + let mock = Rc::new( + MockHostBuilder::new() + .calldata(BAD_SELECTOR.to_vec()) + .build(), + ); - ContractBuilder::::new() - .method(BAD_SELECTOR, bogus_len::) - .dispatch_impl::<256>(&host); + ContractBuilder::new() + .method(BAD_SELECTOR, bogus_len) + .dispatch_impl::<256>(&wrap(&mock)); - let rv = host + let rv = mock .take_return_value() .expect("dispatch called return_value"); assert_eq!(rv.flags, ReturnFlags::empty()); @@ -146,11 +164,41 @@ fn handler_returning_oversize_len_is_clamped() { assert_eq!(&rv.data[..4], &[1, 2, 3, 4]); } +#[test] +fn deploy_guard_reverts_when_value_attached() { + let mut value = [0u8; 32]; + value[31] = 1; + let mock = Rc::new(MockHostBuilder::new().value_transferred(value).build()); + + assert_non_payable_deploy(&wrap(&mock)); + + let rv = mock + .take_return_value() + .expect("deploy guard must call return_value on non-zero value"); + assert_eq!(rv.flags, ReturnFlags::REVERT); + assert_eq!( + rv.data, + pvm_contract_types::framework_errors::NON_PAYABLE_VALUE_RECEIVED.as_slice() + ); +} + +#[test] +fn deploy_guard_passes_with_zero_value() { + let mock = Rc::new(MockHostBuilder::new().build()); + + assert_non_payable_deploy(&wrap(&mock)); + + assert!( + mock.take_return_value().is_none(), + "deploy guard must not call return_value when value is zero" + ); +} + #[test] fn storage_is_observable_from_handler() { // Register a handler that reads from storage and returns the value. - fn read_slot(host: &H, _input: &[u8], output: &mut [u8]) -> HandlerResult { - use pvm_contract_types::StorageFlags; + fn read_slot(host: &Host, _input: &[u8], output: &mut [u8]) -> HandlerResult { + use pvm_contract_types::{HostApi, StorageFlags}; let mut buf = [0u8; 32]; let mut out = &mut buf[..]; let _ = host.get_storage(StorageFlags::empty(), &[0u8; 32], &mut out); @@ -161,16 +209,18 @@ fn storage_is_observable_from_handler() { let mut preset = [0u8; 32]; preset[31] = 0x42; - let host = MockHostBuilder::new() - .calldata(READ_SELECTOR.to_vec()) - .storage(vec![(vec![0u8; 32], preset.to_vec())]) - .build(); + let mock = Rc::new( + MockHostBuilder::new() + .calldata(READ_SELECTOR.to_vec()) + .storage(vec![(vec![0u8; 32], preset.to_vec())]) + .build(), + ); - ContractBuilder::::new() - .method(READ_SELECTOR, read_slot::) - .dispatch_impl::<256>(&host); + ContractBuilder::new() + .method(READ_SELECTOR, read_slot) + .dispatch_impl::<256>(&wrap(&mock)); - let rv = host + let rv = mock .take_return_value() .expect("dispatch called return_value"); assert_eq!(rv.flags, ReturnFlags::empty()); diff --git a/crates/pvm-contract-core/src/call.rs b/crates/pvm-contract-core/src/call.rs index f0d60cf7..e2677f49 100644 --- a/crates/pvm-contract-core/src/call.rs +++ b/crates/pvm-contract-core/src/call.rs @@ -1,8 +1,8 @@ use core::{fmt::Debug, marker::PhantomData}; use pvm_contract_types::{ - Address, CallFlags, Host, HostApi, ReturnErrorCode, SolDecode, SolEncode, SolError, - const_selector, + Address, CallFlags, ContractContext, Host, HostApi, ReturnErrorCode, SolDecode, SolEncode, + SolError, const_selector, }; use ruint::aliases::U256; @@ -191,7 +191,7 @@ impl CallBuilder { } impl CallBuilder { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_limits = limits; self @@ -211,8 +211,28 @@ impl CallBuilder Result { + if self.output_size(host) > output_buf.len() { + return Err(CallError::OutputBufTooSmall); + } + host.return_data_copy(&mut output_buf, 0); + Ok(R::decode(output_buf)) + } + + pub fn output_size(&self, host: &Host) -> usize { + // safe as we always run on 64bit arches + host.return_data_size() as usize + } + + /// Internal — actually invoke the cross-contract call. Mutability gating + /// happens at the public surface (`call_raw` / `call` per typestate). + fn call_raw_inner( &self, host: &Host, address: Address, @@ -221,23 +241,34 @@ impl CallBuilder { - host.delegate_call_evm(call_flags, &address.0, limit, input_buf, None) - } + CallLimits::GasLimit(limit) => host.call_evm( + call_flags, + &address.0, + limit, + &U256::from(value).to_be_bytes(), + input_buf, + None, + ), CallLimits::RefTimeAndProofSize(RefTimeAndProofSizeLimits { ref_time_limit, proof_size_limit, deposit_limit, - }) => host.delegate_call( + }) => host.call( call_flags, &address.0, ref_time_limit, proof_size_limit, &deposit_limit, + &U256::from(value).to_be_bytes(), input_buf, None, ), @@ -245,34 +276,47 @@ impl CallBuilder Result { - if self.output_size(host) > output_buf.len() { - return Err(CallError::OutputBufTooSmall); - } - host.return_data_copy(&mut output_buf, 0); - Ok(R::decode(output_buf)) - } - - pub fn output_size(&self, host: &Host) -> usize { - // safe as we always run on 64bit arches - host.return_data_size() as usize - } - - /// Execute code in the context (storage, caller, value) of the current contract. - pub fn delegate_call( + /// Internal — actually invoke the delegate call. Always mutating from the + /// caller's perspective: callee runs in caller's storage context, so any + /// callee write hits caller's storage. Gated `&mut impl ContractContext` at + /// the public surface regardless of the callee's declared mutability. + fn delegate_call_raw_inner( &self, host: &Host, address: Address, input_buf: &mut [u8], - output_buf: &mut [u8], - ) -> Result { - self.delegate_call_raw(host, address, input_buf) - .and_then(|_| self.extract_output(host, output_buf)) + ) -> Result<(), CallError> { + if input_buf.len() < 4 + self.payload.encode_len() { + return Err(CallError::InputBufTooSmall); + } + let call_flags = CallFlags::empty(); + input_buf[..4].copy_from_slice(&self.selector[..]); + self.payload.encode_to(&mut input_buf[4..]); + match self.call_limits { + CallLimits::GasLimit(limit) => { + host.delegate_call_evm(call_flags, &address.0, limit, input_buf, None) + } + CallLimits::RefTimeAndProofSize(RefTimeAndProofSizeLimits { + ref_time_limit, + proof_size_limit, + deposit_limit, + }) => host.delegate_call( + call_flags, + &address.0, + ref_time_limit, + proof_size_limit, + &deposit_limit, + input_buf, + None, + ), + } + .map_err(convert_error) } - /// Call a given contract + /// Internal — actually invoke instantiate. Always mutating: transfers + /// value, emits a deploy event, bumps the caller's nonce. #[allow(clippy::too_many_arguments)] - pub fn instantiate_raw( + fn instantiate_raw_inner( &self, host: &Host, limits: RefTimeAndProofSizeLimits, @@ -300,80 +344,160 @@ impl CallBuilder( + &self, + root: &mut R0, + address: Address, + input_buf: &mut [u8], + ) -> Result<(), CallError> { + self.delegate_call_raw_inner(root.host(), address, input_buf) + } + + /// Delegate-call another contract and decode the output. + pub fn delegate_call( + &self, + root: &mut R0, + address: Address, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + // Clone the host handle before the mutable borrow of `root`: on + // `riscv64` `Host` is a ZST (free `Copy`), on host-target builds it's + // a refcount bump on `Rc`. Avoids re-borrowing `root` + // after the mutable call to read return data. + let host = root.host().clone(); + self.delegate_call_raw(root, address, input_buf)?; + self.extract_output(&host, output_buf) + } + + /// Instantiate a new contract. + /// + /// Always requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. #[allow(clippy::too_many_arguments)] - /// Execute code in the context (storage, caller, value) of the current contract. - pub fn instantiate( + pub fn instantiate_raw( &self, - host: &Host, + root: &mut R0, limits: RefTimeAndProofSizeLimits, value: u128, code_hash: &[u8; 32], salt: Option<&[u8; 32]>, address_buf: &mut [u8; 20], input_buf: &mut [u8], - output_buf: &mut [u8], - ) -> Result { - self.instantiate_raw(host, limits, value, code_hash, salt, address_buf, input_buf) - .and_then(|_| self.extract_output(host, output_buf)) - } - /// Call a given contract - pub fn call_raw( - &self, - host: &Host, - address: Address, - input_buf: &mut [u8], ) -> Result<(), CallError> { - if input_buf.len() < 4 + self.payload.encode_len() { - return Err(CallError::InputBufTooSmall); - } - let call_flags = if self.allow_reentry { - self.witness.call_flags() | CallFlags::ALLOW_REENTRY - } else { - self.witness.call_flags() - }; - let value = self.witness.value(); - input_buf[..4].copy_from_slice(&self.selector[..]); - self.payload.encode_to(&mut input_buf[4..]); - match self.call_limits { - CallLimits::GasLimit(limit) => host.call_evm( - call_flags, - &address.0, - limit, - &U256::from(value).to_be_bytes(), - input_buf, - None, - ), - CallLimits::RefTimeAndProofSize(RefTimeAndProofSizeLimits { - ref_time_limit, - proof_size_limit, - deposit_limit, - }) => host.call( - call_flags, - &address.0, - ref_time_limit, - proof_size_limit, - &deposit_limit, - &U256::from(value).to_be_bytes(), - input_buf, - None, - ), - } - .map_err(convert_error) + self.instantiate_raw_inner( + root.host(), + limits, + value, + code_hash, + salt, + address_buf, + input_buf, + ) } - /// Execute code in the context (storage, caller, value) of the current contract. - pub fn call( + /// Instantiate a new contract and decode the constructor's output. + #[allow(clippy::too_many_arguments)] + pub fn instantiate( &self, - host: &Host, - address: Address, + root: &mut R0, + limits: RefTimeAndProofSizeLimits, + value: u128, + code_hash: &[u8; 32], + salt: Option<&[u8; 32]>, + address_buf: &mut [u8; 20], input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_raw(host, address, input_buf) - .and_then(|_| self.extract_output(host, output_buf)) + // Clone first — see `delegate_call` for rationale. + let host = root.host().clone(); + self.instantiate_raw(root, limits, value, code_hash, salt, address_buf, input_buf)?; + self.extract_output(&host, output_buf) } } +// --------------------------------------------------------------------------- +// View / Pure callees: read-only, callable from `&self` methods. +// --------------------------------------------------------------------------- + +macro_rules! impl_readonly_call { + ($mutability:ident) => { + impl CallBuilder<$mutability, I, R> { + /// Call a `view`/`pure` callee. Borrows the contract root + /// immutably, so this is callable from `&self` methods. + pub fn call_raw( + &self, + root: &R0, + address: Address, + input_buf: &mut [u8], + ) -> Result<(), CallError> { + self.call_raw_inner(root.host(), address, input_buf) + } + + /// Call a `view`/`pure` callee and decode its output. + pub fn call( + &self, + root: &R0, + address: Address, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + let host = root.host(); + self.call_raw_inner(host, address, input_buf)?; + self.extract_output(host, output_buf) + } + } + }; +} +impl_readonly_call!(View); +impl_readonly_call!(Pure); + +// --------------------------------------------------------------------------- +// NonPayable / Payable callees: state-mutating, require `&mut self`. +// --------------------------------------------------------------------------- + +macro_rules! impl_mutating_call { + ($mutability:ident) => { + impl CallBuilder<$mutability, I, R> { + /// Call a `nonpayable`/`payable` callee. Borrows the contract + /// root mutably, so this is only callable from `&mut self` + /// methods. A `&self` (view) method cannot construct the + /// `&mut impl ContractContext` required here, so the borrow checker + /// rejects view methods that try to initiate a state-mutating + /// cross-contract call. + pub fn call_raw( + &self, + root: &mut R0, + address: Address, + input_buf: &mut [u8], + ) -> Result<(), CallError> { + self.call_raw_inner(root.host(), address, input_buf) + } + + /// Call a `nonpayable`/`payable` callee and decode its output. + pub fn call( + &self, + root: &mut R0, + address: Address, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + // Clone first — see `delegate_call` for rationale. + let host = root.host().clone(); + self.call_raw(root, address, input_buf)?; + self.extract_output(&host, output_buf) + } + } + }; +} +impl_mutating_call!(NonPayable); +impl_mutating_call!(Payable); + #[cfg(test)] mod test { use core::marker::PhantomData; diff --git a/crates/pvm-contract-macros/src/abi_import/mod.rs b/crates/pvm-contract-macros/src/abi_import/mod.rs index 04854cd5..8ab86e1e 100644 --- a/crates/pvm-contract-macros/src/abi_import/mod.rs +++ b/crates/pvm-contract-macros/src/abi_import/mod.rs @@ -380,22 +380,55 @@ pub fn expand_to_module(file: &File, alloc: bool) -> TokenStream { #(#constructor)* } }; - let alloc_calls = if alloc { + // Per-mutability `alloc_calls`: View/Pure callees borrow the + // contract root immutably (`&R0`), so they can be invoked from + // `&self` (view) caller methods. NonPayable/Payable callees + // require `&mut R0`, so the borrow checker rejects invocations + // from `&self` methods. + // + // `delegate_call` always takes `&mut R0` regardless of callee + // mutability — the callee runs in caller's storage context, so + // even a "view" callee can mutate caller state. + let alloc_calls_readonly = if alloc { quote! { - /// Perform a call to another contract - pub fn call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call(&self, root: &R0) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![0; 4 + self.call_builder.payload.encode_len()]; - self.call_builder.call_raw(host, self.address, input_buf.as_mut_slice())?; - let mut output_buf: alloc::vec::Vec = alloc::vec![0; self.call_builder.output_size(host).max(512)]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![0; self.call_builder.output_size(root.host()).max(512)]; + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) } - - /// Perform a delegated call to another contract - pub fn delegate_call(&self, host: &Host) -> Result { + } + } else { + quote! {} + }; + let alloc_calls_mutating = if alloc { + quote! { + /// Perform a call to another contract. + pub fn call(&self, root: &mut R0) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![0; 4 + self.call_builder.payload.encode_len()]; + // Clone the host handle before the mutable borrow: + // `Host` is a ZST on riscv64 (free) and `Rc` on + // host-target builds (refcount bump). Removes the + // need to re-borrow `root` for `extract_output`. + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![0; self.call_builder.output_size(&host).max(512)]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + } + } else { + quote! {} + }; + let alloc_delegate = if alloc { + quote! { + /// Perform a delegated call to another contract. + pub fn delegate_call(&self, root: &mut R0) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![0; 4 + self.call_builder.payload.encode_len()]; - self.call_builder.delegate_call_raw(host, self.address, input_buf.as_mut_slice())?; - let mut output_buf: alloc::vec::Vec = alloc::vec![0; self.call_builder.output_size(host).max(512)]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + let host = root.host().clone(); + self.call_builder.delegate_call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![0; self.call_builder.output_size(&host).max(512)]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } } } else { @@ -405,11 +438,12 @@ pub fn expand_to_module(file: &File, alloc: bool) -> TokenStream { let alloc_instantiate = if alloc { quote! { /// Instantiate another contract by it's code_hash - pub fn instantiate(&self, host: &Host, code_hash: &[u8;32], value: u128, limits: RefTimeAndProofSizeLimits, salt: Option<&[u8;32]>) -> Result<(Address, Outputs), CallError> { + pub fn instantiate(&self, root: &mut R0, code_hash: &[u8;32], value: u128, limits: RefTimeAndProofSizeLimits, salt: Option<&[u8;32]>) -> Result<(Address, Outputs), CallError> { let mut input_buf: alloc::vec::Vec = alloc::vec![0; 32 + self.call_builder.payload.encode_len()]; let mut address_buf = [0u8; 20]; + let host = root.host().clone(); self.call_builder.instantiate_raw( - host, + root, limits, value, code_hash, @@ -417,8 +451,8 @@ pub fn expand_to_module(file: &File, alloc: bool) -> TokenStream { &mut address_buf, input_buf.as_mut_slice(), )?; - let mut output_buf: alloc::vec::Vec = alloc::vec![0; self.call_builder.output_size(host).max(512)]; - let output = self.call_builder.extract_output(host, output_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![0; self.call_builder.output_size(&host).max(512)]; + let output = self.call_builder.extract_output(&host, output_buf.as_mut_slice())?; Ok((address_buf.into(), output)) } } @@ -462,29 +496,69 @@ pub fn expand_to_module(file: &File, alloc: bool) -> TokenStream { #constructor impl #contract_name { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_builder = self.call_builder.set_call_limits(limits); self } - /// Perform a call to another contract - pub fn call_raw(&self, host: &Host, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result { - self.call_builder.call(host, self.address, input_buf, output_buf) + + /// Perform a delegated call to another contract. + /// + /// Always requires `&mut impl ContractContext` regardless of the + /// callee's declared mutability: the callee runs in caller's + /// storage context, so even a "view" callee can mutate state. + pub fn delegate_call_raw(&self, root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result { + self.call_builder.delegate_call(root, self.address, input_buf, output_buf) } - /// Perform a delegated call to another contract - pub fn delegate_call_raw(&self, host: &Host, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result { - self.call_builder.delegate_call(host, self.address, input_buf, output_buf) + + #alloc_delegate + } + + // View / Pure callees: callable from `&self` (read-only) caller methods. + impl #contract_name { + /// Perform a call to a `view` callee. + pub fn call_raw(&self, root: &R0, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + + #alloc_calls_readonly + } + impl #contract_name { + /// Perform a call to a `pure` callee. + pub fn call_raw(&self, root: &R0, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) } - #alloc_calls + #alloc_calls_readonly } + // NonPayable / Payable callees: require `&mut self` caller. + impl #contract_name { + /// Perform a call to a `nonpayable` callee. Caller must take + /// `&mut self` — `&self` (view) caller methods cannot construct + /// the `&mut impl ContractContext` argument. + pub fn call_raw(&self, root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + + #alloc_calls_mutating + } impl #contract_name { - /// Instantiate another contract by it's code_hash - pub fn instantiate_raw(&self, host: &Host, code_hash: &[u8;32], value: u128, limits: RefTimeAndProofSizeLimits, salt: Option<&[u8;32]>, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result<(Address, Outputs), CallError> { + /// Perform a call to a `payable` callee. Caller must take + /// `&mut self`. + pub fn call_raw(&self, root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + + #alloc_calls_mutating + + /// Instantiate another contract by it's code_hash. Always + /// requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. + pub fn instantiate_raw(&self, root: &mut R0, code_hash: &[u8;32], value: u128, limits: RefTimeAndProofSizeLimits, salt: Option<&[u8;32]>, input_buf: &mut [u8], output_buf: &mut [u8]) -> Result<(Address, Outputs), CallError> { let mut address_buf = [0u8; 20]; let result = self.call_builder.instantiate( - host, + root, limits, value, code_hash, @@ -498,7 +572,7 @@ pub fn expand_to_module(file: &File, alloc: bool) -> TokenStream { #alloc_instantiate - /// Set the transfer `.value` of the call + /// Set the transfer `.value` of the call. pub fn set_value(mut self, value: u128) -> Self { self.call_builder = self.call_builder.set_value(value); self @@ -588,7 +662,9 @@ mod test { error NonPayableValueReceived(); error UnknownSelector(); constructor(); - function getCount() external returns (uint64); + function add(uint64 a, uint64 b) external pure returns (uint64); + function deposit() external payable; + function getCount() external view returns (uint64); function setFlag(bool flag) external; function transfer(address to, uint256 amount, uint32 nonce) external returns (bool); } @@ -608,13 +684,43 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > MultiMethod { - pub fn get_count(mut self) -> MultiMethod { - MultiMethod:: { + pub fn add( + mut self, + a: u64, + b: u64, + ) -> MultiMethod { + MultiMethod:: { address: self.address, - call_builder: CallBuilder:: { + call_builder: CallBuilder:: { + payload: (a, b), + selector: [110u8, 44u8, 115u8, 45u8], + witness: Pure::default(), + call_limits: Default::default(), + allow_reentry: false, + _ret: core::marker::PhantomData, + }, + } + } + pub fn deposit(mut self) -> MultiMethod { + MultiMethod:: { + address: self.address, + call_builder: CallBuilder:: { + payload: (), + selector: [208u8, 227u8, 13u8, 176u8], + witness: Payable::default(), + call_limits: Default::default(), + allow_reentry: false, + _ret: core::marker::PhantomData, + }, + } + } + pub fn get_count(mut self) -> MultiMethod { + MultiMethod:: { + address: self.address, + call_builder: CallBuilder:: { payload: (), selector: [168u8, 125u8, 148u8, 44u8], - witness: NonPayable::default(), + witness: View::default(), call_limits: Default::default(), allow_reentry: false, _ret: core::marker::PhantomData, @@ -683,61 +789,163 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > MultiMethod { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_builder = self.call_builder.set_call_limits(limits); self } - /// Perform a call to another contract - pub fn call_raw( + /// Perform a delegated call to another contract. + /// + /// Always requires `&mut impl ContractContext` regardless of the + /// callee's declared mutability: the callee runs in caller's + /// storage context, so even a "view" callee can mutate state. + pub fn delegate_call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.delegate_call(root, self.address, input_buf, output_buf) + } + /// Perform a delegated call to another contract. + pub fn delegate_call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder + .delegate_call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > MultiMethod { + /// Perform a call to a `view` callee. + pub fn call_raw( &self, - host: &Host, + root: &R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.call(host, self.address, input_buf, output_buf) + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a delegated call to another contract - pub fn delegate_call_raw( + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(root.host()).max(512) + ]; + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > MultiMethod { + /// Perform a call to a `pure` callee. + pub fn call_raw( &self, - host: &Host, + root: &R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.delegate_call(host, self.address, input_buf, output_buf) + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a call to another contract - pub fn call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder.call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(root.host()).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call(&self, host: &Host) -> Result { + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > MultiMethod { + /// Perform a call to a `nonpayable` callee. Caller must take + /// `&mut self` — `&self` (view) caller methods cannot construct + /// the `&mut impl ContractContext` argument. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder - .delegate_call_raw(host, self.address, input_buf.as_mut_slice())?; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } } impl< Inputs: SolEncode, Outputs: SolDecode, > MultiMethod { - /// Instantiate another contract by it's code_hash - pub fn instantiate_raw( + /// Perform a call to a `payable` callee. Caller must take + /// `&mut self`. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( &self, - host: &Host, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + /// Instantiate another contract by it's code_hash. Always + /// requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. + pub fn instantiate_raw( + &self, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -749,7 +957,7 @@ mod test { let result = self .call_builder .instantiate( - host, + root, limits, value, code_hash, @@ -761,9 +969,9 @@ mod test { Ok((address_buf.into(), result)) } /// Instantiate another contract by it's code_hash - pub fn instantiate( + pub fn instantiate( &self, - host: &Host, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -773,9 +981,10 @@ mod test { 0; 32 + self.call_builder.payload.encode_len() ]; let mut address_buf = [0u8; 20]; + let host = root.host().clone(); self.call_builder .instantiate_raw( - host, + root, limits, value, code_hash, @@ -784,14 +993,14 @@ mod test { input_buf.as_mut_slice(), )?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; let output = self .call_builder - .extract_output(host, output_buf.as_mut_slice())?; + .extract_output(&host, output_buf.as_mut_slice())?; Ok((address_buf.into(), output)) } - /// Set the transfer `.value` of the call + /// Set the transfer `.value` of the call. pub fn set_value(mut self, value: u128) -> Self { self.call_builder = self.call_builder.set_value(value); self @@ -829,8 +1038,8 @@ mod test { error NonPayableValueReceived(); error UnknownSelector(); constructor(); - function origin() external returns ((uint64,uint64) memory); - function reflect(((uint64,uint64),(uint64,uint64)) memory line) external returns (((uint64,uint64),(uint64,uint64)) memory); + function origin() external view returns ((uint64,uint64) memory); + function reflect(((uint64,uint64),(uint64,uint64)) memory line) external view returns (((uint64,uint64),(uint64,uint64)) memory); } ```*/ /// @@ -848,13 +1057,13 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > NestedCustomType { - pub fn origin(mut self) -> NestedCustomType { - NestedCustomType:: { + pub fn origin(mut self) -> NestedCustomType { + NestedCustomType:: { address: self.address, - call_builder: CallBuilder:: { + call_builder: CallBuilder:: { payload: (), selector: [147u8, 139u8, 95u8, 50u8], - witness: NonPayable::default(), + witness: View::default(), call_limits: Default::default(), allow_reentry: false, _ret: core::marker::PhantomData, @@ -865,26 +1074,26 @@ mod test { mut self, line: ((u64, u64), (u64, u64)), ) -> NestedCustomType< - NonPayable, + View, (((u64, u64), (u64, u64))), (((u64, u64), (u64, u64))), true, > { NestedCustomType::< - NonPayable, + View, (((u64, u64), (u64, u64))), (((u64, u64), (u64, u64))), true, > { address: self.address, call_builder: CallBuilder::< - NonPayable, + View, (((u64, u64), (u64, u64))), (((u64, u64), (u64, u64))), > { payload: (line), selector: [5u8, 150u8, 191u8, 142u8], - witness: NonPayable::default(), + witness: View::default(), call_limits: Default::default(), allow_reentry: false, _ret: core::marker::PhantomData, @@ -919,61 +1128,163 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > NestedCustomType { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_builder = self.call_builder.set_call_limits(limits); self } - /// Perform a call to another contract - pub fn call_raw( + /// Perform a delegated call to another contract. + /// + /// Always requires `&mut impl ContractContext` regardless of the + /// callee's declared mutability: the callee runs in caller's + /// storage context, so even a "view" callee can mutate state. + pub fn delegate_call_raw( &self, - host: &Host, + root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.call(host, self.address, input_buf, output_buf) + self.call_builder.delegate_call(root, self.address, input_buf, output_buf) + } + /// Perform a delegated call to another contract. + pub fn delegate_call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder + .delegate_call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call_raw( + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > NestedCustomType { + /// Perform a call to a `view` callee. + pub fn call_raw( &self, - host: &Host, + root: &R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.delegate_call(host, self.address, input_buf, output_buf) + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a call to another contract - pub fn call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder.call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(root.host()).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call(&self, host: &Host) -> Result { + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > NestedCustomType { + /// Perform a call to a `pure` callee. + pub fn call_raw( + &self, + root: &R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder - .delegate_call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(root.host()).max(512) + ]; + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > NestedCustomType { + /// Perform a call to a `nonpayable` callee. Caller must take + /// `&mut self` — `&self` (view) caller methods cannot construct + /// the `&mut impl ContractContext` argument. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } } impl< Inputs: SolEncode, Outputs: SolDecode, > NestedCustomType { - /// Instantiate another contract by it's code_hash - pub fn instantiate_raw( + /// Perform a call to a `payable` callee. Caller must take + /// `&mut self`. + pub fn call_raw( &self, - host: &Host, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + /// Instantiate another contract by it's code_hash. Always + /// requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. + pub fn instantiate_raw( + &self, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -985,7 +1296,7 @@ mod test { let result = self .call_builder .instantiate( - host, + root, limits, value, code_hash, @@ -997,9 +1308,9 @@ mod test { Ok((address_buf.into(), result)) } /// Instantiate another contract by it's code_hash - pub fn instantiate( + pub fn instantiate( &self, - host: &Host, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1009,9 +1320,10 @@ mod test { 0; 32 + self.call_builder.payload.encode_len() ]; let mut address_buf = [0u8; 20]; + let host = root.host().clone(); self.call_builder .instantiate_raw( - host, + root, limits, value, code_hash, @@ -1020,14 +1332,14 @@ mod test { input_buf.as_mut_slice(), )?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; let output = self .call_builder - .extract_output(host, output_buf.as_mut_slice())?; + .extract_output(&host, output_buf.as_mut_slice())?; Ok((address_buf.into(), output)) } - /// Set the transfer `.value` of the call + /// Set the transfer `.value` of the call. pub fn set_value(mut self, value: u128) -> Self { self.call_builder = self.call_builder.set_value(value); self @@ -1065,7 +1377,7 @@ mod test { error NonPayableValueReceived(); error UnknownSelector(); constructor(); - function touch((uint256,uint256) memory value) external returns ((uint256,uint256) memory); + function touch((uint256,uint256) memory value) external view returns ((uint256,uint256) memory); } ```*/ /// @@ -1086,13 +1398,13 @@ mod test { pub fn touch( mut self, value: (U256, U256), - ) -> CustomTypeMethod { - CustomTypeMethod:: { + ) -> CustomTypeMethod { + CustomTypeMethod:: { address: self.address, - call_builder: CallBuilder:: { + call_builder: CallBuilder:: { payload: (value), selector: [184u8, 219u8, 195u8, 2u8], - witness: NonPayable::default(), + witness: View::default(), call_limits: Default::default(), allow_reentry: false, _ret: core::marker::PhantomData, @@ -1127,61 +1439,163 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > CustomTypeMethod { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_builder = self.call_builder.set_call_limits(limits); self } - /// Perform a call to another contract - pub fn call_raw( + /// Perform a delegated call to another contract. + /// + /// Always requires `&mut impl ContractContext` regardless of the + /// callee's declared mutability: the callee runs in caller's + /// storage context, so even a "view" callee can mutate state. + pub fn delegate_call_raw( &self, - host: &Host, + root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.call(host, self.address, input_buf, output_buf) + self.call_builder.delegate_call(root, self.address, input_buf, output_buf) + } + /// Perform a delegated call to another contract. + pub fn delegate_call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder + .delegate_call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call_raw( + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > CustomTypeMethod { + /// Perform a call to a `view` callee. + pub fn call_raw( &self, - host: &Host, + root: &R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.delegate_call(host, self.address, input_buf, output_buf) + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a call to another contract - pub fn call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder.call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(root.host()).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call(&self, host: &Host) -> Result { + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > CustomTypeMethod { + /// Perform a call to a `pure` callee. + pub fn call_raw( + &self, + root: &R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder - .delegate_call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(root.host()).max(512) + ]; + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > CustomTypeMethod { + /// Perform a call to a `nonpayable` callee. Caller must take + /// `&mut self` — `&self` (view) caller methods cannot construct + /// the `&mut impl ContractContext` argument. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } } impl< Inputs: SolEncode, Outputs: SolDecode, > CustomTypeMethod { - /// Instantiate another contract by it's code_hash - pub fn instantiate_raw( + /// Perform a call to a `payable` callee. Caller must take + /// `&mut self`. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( &self, - host: &Host, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + /// Instantiate another contract by it's code_hash. Always + /// requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. + pub fn instantiate_raw( + &self, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1193,7 +1607,7 @@ mod test { let result = self .call_builder .instantiate( - host, + root, limits, value, code_hash, @@ -1205,9 +1619,9 @@ mod test { Ok((address_buf.into(), result)) } /// Instantiate another contract by it's code_hash - pub fn instantiate( + pub fn instantiate( &self, - host: &Host, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1217,9 +1631,10 @@ mod test { 0; 32 + self.call_builder.payload.encode_len() ]; let mut address_buf = [0u8; 20]; + let host = root.host().clone(); self.call_builder .instantiate_raw( - host, + root, limits, value, code_hash, @@ -1228,14 +1643,14 @@ mod test { input_buf.as_mut_slice(), )?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; let output = self .call_builder - .extract_output(host, output_buf.as_mut_slice())?; + .extract_output(&host, output_buf.as_mut_slice())?; Ok((address_buf.into(), output)) } - /// Set the transfer `.value` of the call + /// Set the transfer `.value` of the call. pub fn set_value(mut self, value: u128) -> Self { self.call_builder = self.call_builder.set_value(value); self @@ -1273,8 +1688,8 @@ mod test { error NonPayableValueReceived(); error UnknownSelector(); constructor(); - function getNamed() external returns ((uint64,string) memory); - function process((uint64,string) memory data, bool flag) external returns (uint64); + function getNamed() external view returns ((uint64,string) memory); + function process((uint64,string) memory data, bool flag) external view returns (uint64); } ```*/ /// @@ -1294,17 +1709,13 @@ mod test { > DynamicCustomReturn { pub fn get_named( mut self, - ) -> DynamicCustomReturn { - DynamicCustomReturn:: { + ) -> DynamicCustomReturn { + DynamicCustomReturn:: { address: self.address, - call_builder: CallBuilder::< - NonPayable, - (), - ((u64, alloc::string::String)), - > { + call_builder: CallBuilder:: { payload: (), selector: [233u8, 148u8, 217u8, 223u8], - witness: NonPayable::default(), + witness: View::default(), call_limits: Default::default(), allow_reentry: false, _ret: core::marker::PhantomData, @@ -1316,26 +1727,26 @@ mod test { data: (u64, alloc::string::String), flag: bool, ) -> DynamicCustomReturn< - NonPayable, + View, ((u64, alloc::string::String), bool), (u64), true, > { DynamicCustomReturn::< - NonPayable, + View, ((u64, alloc::string::String), bool), (u64), true, > { address: self.address, call_builder: CallBuilder::< - NonPayable, + View, ((u64, alloc::string::String), bool), (u64), > { payload: (data, flag), selector: [57u8, 253u8, 73u8, 204u8], - witness: NonPayable::default(), + witness: View::default(), call_limits: Default::default(), allow_reentry: false, _ret: core::marker::PhantomData, @@ -1372,61 +1783,163 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > DynamicCustomReturn { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_builder = self.call_builder.set_call_limits(limits); self } - /// Perform a call to another contract - pub fn call_raw( + /// Perform a delegated call to another contract. + /// + /// Always requires `&mut impl ContractContext` regardless of the + /// callee's declared mutability: the callee runs in caller's + /// storage context, so even a "view" callee can mutate state. + pub fn delegate_call_raw( &self, - host: &Host, + root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.call(host, self.address, input_buf, output_buf) + self.call_builder.delegate_call(root, self.address, input_buf, output_buf) + } + /// Perform a delegated call to another contract. + pub fn delegate_call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder + .delegate_call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > DynamicCustomReturn { + /// Perform a call to a `view` callee. + pub fn call_raw( + &self, + root: &R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(root.host()).max(512) + ]; + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call_raw( + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > DynamicCustomReturn { + /// Perform a call to a `pure` callee. + pub fn call_raw( &self, - host: &Host, + root: &R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.delegate_call(host, self.address, input_buf, output_buf) + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a call to another contract - pub fn call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder.call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(root.host()).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call(&self, host: &Host) -> Result { + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > DynamicCustomReturn { + /// Perform a call to a `nonpayable` callee. Caller must take + /// `&mut self` — `&self` (view) caller methods cannot construct + /// the `&mut impl ContractContext` argument. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder - .delegate_call_raw(host, self.address, input_buf.as_mut_slice())?; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } } impl< Inputs: SolEncode, Outputs: SolDecode, > DynamicCustomReturn { - /// Instantiate another contract by it's code_hash - pub fn instantiate_raw( + /// Perform a call to a `payable` callee. Caller must take + /// `&mut self`. + pub fn call_raw( &self, - host: &Host, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + /// Instantiate another contract by it's code_hash. Always + /// requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. + pub fn instantiate_raw( + &self, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1438,7 +1951,7 @@ mod test { let result = self .call_builder .instantiate( - host, + root, limits, value, code_hash, @@ -1450,9 +1963,9 @@ mod test { Ok((address_buf.into(), result)) } /// Instantiate another contract by it's code_hash - pub fn instantiate( + pub fn instantiate( &self, - host: &Host, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1462,9 +1975,10 @@ mod test { 0; 32 + self.call_builder.payload.encode_len() ]; let mut address_buf = [0u8; 20]; + let host = root.host().clone(); self.call_builder .instantiate_raw( - host, + root, limits, value, code_hash, @@ -1473,14 +1987,14 @@ mod test { input_buf.as_mut_slice(), )?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; let output = self .call_builder - .extract_output(host, output_buf.as_mut_slice())?; + .extract_output(&host, output_buf.as_mut_slice())?; Ok((address_buf.into(), output)) } - /// Set the transfer `.value` of the call + /// Set the transfer `.value` of the call. pub fn set_value(mut self, value: u128) -> Self { self.call_builder = self.call_builder.set_value(value); self @@ -1518,7 +2032,7 @@ mod test { error NonPayableValueReceived(); error UnknownSelector(); constructor(address owner, uint256 supply); - function balanceOf(address account) external returns (uint256); + function balanceOf(address account) external view returns (uint256); } ```*/ /// @@ -1539,13 +2053,13 @@ mod test { pub fn balance_of( mut self, account: Address, - ) -> ConstructorWithParams { - ConstructorWithParams:: { + ) -> ConstructorWithParams { + ConstructorWithParams:: { address: self.address, - call_builder: CallBuilder:: { + call_builder: CallBuilder:: { payload: (account), selector: [112u8, 160u8, 130u8, 49u8], - witness: NonPayable::default(), + witness: View::default(), call_limits: Default::default(), allow_reentry: false, _ret: core::marker::PhantomData, @@ -1585,61 +2099,163 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > ConstructorWithParams { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_builder = self.call_builder.set_call_limits(limits); self } - /// Perform a call to another contract - pub fn call_raw( + /// Perform a delegated call to another contract. + /// + /// Always requires `&mut impl ContractContext` regardless of the + /// callee's declared mutability: the callee runs in caller's + /// storage context, so even a "view" callee can mutate state. + pub fn delegate_call_raw( &self, - host: &Host, + root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.call(host, self.address, input_buf, output_buf) + self.call_builder.delegate_call(root, self.address, input_buf, output_buf) } - /// Perform a delegated call to another contract - pub fn delegate_call_raw( + /// Perform a delegated call to another contract. + pub fn delegate_call( &self, - host: &Host, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder + .delegate_call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > ConstructorWithParams { + /// Perform a call to a `view` callee. + pub fn call_raw( + &self, + root: &R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.delegate_call(host, self.address, input_buf, output_buf) + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a call to another contract - pub fn call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder.call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(root.host()).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > ConstructorWithParams { + /// Perform a call to a `pure` callee. + pub fn call_raw( + &self, + root: &R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a delegated call to another contract - pub fn delegate_call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder - .delegate_call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(root.host()).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > ConstructorWithParams { + /// Perform a call to a `nonpayable` callee. Caller must take + /// `&mut self` — `&self` (view) caller methods cannot construct + /// the `&mut impl ContractContext` argument. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } } impl< Inputs: SolEncode, Outputs: SolDecode, > ConstructorWithParams { - /// Instantiate another contract by it's code_hash - pub fn instantiate_raw( + /// Perform a call to a `payable` callee. Caller must take + /// `&mut self`. + pub fn call_raw( &self, - host: &Host, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + /// Instantiate another contract by it's code_hash. Always + /// requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. + pub fn instantiate_raw( + &self, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1651,7 +2267,7 @@ mod test { let result = self .call_builder .instantiate( - host, + root, limits, value, code_hash, @@ -1663,9 +2279,9 @@ mod test { Ok((address_buf.into(), result)) } /// Instantiate another contract by it's code_hash - pub fn instantiate( + pub fn instantiate( &self, - host: &Host, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1675,9 +2291,10 @@ mod test { 0; 32 + self.call_builder.payload.encode_len() ]; let mut address_buf = [0u8; 20]; + let host = root.host().clone(); self.call_builder .instantiate_raw( - host, + root, limits, value, code_hash, @@ -1686,14 +2303,14 @@ mod test { input_buf.as_mut_slice(), )?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; let output = self .call_builder - .extract_output(host, output_buf.as_mut_slice())?; + .extract_output(&host, output_buf.as_mut_slice())?; Ok((address_buf.into(), output)) } - /// Set the transfer `.value` of the call + /// Set the transfer `.value` of the call. pub fn set_value(mut self, value: u128) -> Self { self.call_builder = self.call_builder.set_value(value); self @@ -1823,58 +2440,154 @@ mod test { Inputs: SolEncode, Outputs: SolDecode, > Ballot { - /// Set call limits for the given call + /// Set call limits for the given call. pub fn set_call_limits(mut self, limits: CallLimits) -> Self { self.call_builder = self.call_builder.set_call_limits(limits); self } - /// Perform a call to another contract - pub fn call_raw( + /// Perform a delegated call to another contract. + /// + /// Always requires `&mut impl ContractContext` regardless of the + /// callee's declared mutability: the callee runs in caller's + /// storage context, so even a "view" callee can mutate state. + pub fn delegate_call_raw( &self, - host: &Host, + root: &mut R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.call(host, self.address, input_buf, output_buf) + self.call_builder.delegate_call(root, self.address, input_buf, output_buf) } - /// Perform a delegated call to another contract - pub fn delegate_call_raw( + /// Perform a delegated call to another contract. + pub fn delegate_call( &self, - host: &Host, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder + .delegate_call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + } + impl Ballot { + /// Perform a call to a `view` callee. + pub fn call_raw( + &self, + root: &R0, input_buf: &mut [u8], output_buf: &mut [u8], ) -> Result { - self.call_builder.delegate_call(host, self.address, input_buf, output_buf) + self.call_builder.call(root, self.address, input_buf, output_buf) } - /// Perform a call to another contract - pub fn call(&self, host: &Host) -> Result { + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder.call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(root.host()).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) } - /// Perform a delegated call to another contract - pub fn delegate_call(&self, host: &Host) -> Result { + } + impl Ballot { + /// Perform a call to a `pure` callee. + pub fn call_raw( + &self, + root: &R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &R0, + ) -> Result { let mut input_buf: alloc::vec::Vec = alloc::vec![ 0; 4 + self.call_builder.payload.encode_len() ]; - self.call_builder - .delegate_call_raw(host, self.address, input_buf.as_mut_slice())?; + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(root.host()).max(512) + ]; + self.call_builder.extract_output(root.host(), output_buf.as_mut_slice()) + } + } + impl< + Inputs: SolEncode, + Outputs: SolDecode, + > Ballot { + /// Perform a call to a `nonpayable` callee. Caller must take + /// `&mut self` — `&self` (view) caller methods cannot construct + /// the `&mut impl ContractContext` argument. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; - self.call_builder.extract_output(host, output_buf.as_mut_slice()) + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) } } impl Ballot { - /// Instantiate another contract by it's code_hash - pub fn instantiate_raw( + /// Perform a call to a `payable` callee. Caller must take + /// `&mut self`. + pub fn call_raw( + &self, + root: &mut R0, + input_buf: &mut [u8], + output_buf: &mut [u8], + ) -> Result { + self.call_builder.call(root, self.address, input_buf, output_buf) + } + /// Perform a call to another contract. + pub fn call( + &self, + root: &mut R0, + ) -> Result { + let mut input_buf: alloc::vec::Vec = alloc::vec![ + 0; 4 + self.call_builder.payload.encode_len() + ]; + let host = root.host().clone(); + self.call_builder.call_raw(root, self.address, input_buf.as_mut_slice())?; + let mut output_buf: alloc::vec::Vec = alloc::vec![ + 0; self.call_builder.output_size(& host).max(512) + ]; + self.call_builder.extract_output(&host, output_buf.as_mut_slice()) + } + /// Instantiate another contract by it's code_hash. Always + /// requires `&mut impl ContractContext`: instantiation transfers + /// value, emits a deploy event, and bumps the caller's nonce. + pub fn instantiate_raw( &self, - host: &Host, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1886,7 +2599,7 @@ mod test { let result = self .call_builder .instantiate( - host, + root, limits, value, code_hash, @@ -1898,9 +2611,9 @@ mod test { Ok((address_buf.into(), result)) } /// Instantiate another contract by it's code_hash - pub fn instantiate( + pub fn instantiate( &self, - host: &Host, + root: &mut R0, code_hash: &[u8; 32], value: u128, limits: RefTimeAndProofSizeLimits, @@ -1910,9 +2623,10 @@ mod test { 0; 32 + self.call_builder.payload.encode_len() ]; let mut address_buf = [0u8; 20]; + let host = root.host().clone(); self.call_builder .instantiate_raw( - host, + root, limits, value, code_hash, @@ -1921,14 +2635,14 @@ mod test { input_buf.as_mut_slice(), )?; let mut output_buf: alloc::vec::Vec = alloc::vec![ - 0; self.call_builder.output_size(host).max(512) + 0; self.call_builder.output_size(& host).max(512) ]; let output = self .call_builder - .extract_output(host, output_buf.as_mut_slice())?; + .extract_output(&host, output_buf.as_mut_slice())?; Ok((address_buf.into(), output)) } - /// Set the transfer `.value` of the call + /// Set the transfer `.value` of the call. pub fn set_value(mut self, value: u128) -> Self { self.call_builder = self.call_builder.set_value(value); self diff --git a/crates/pvm-contract-macros/src/codegen/abi_gen.rs b/crates/pvm-contract-macros/src/codegen/abi_gen.rs index 47bc4998..5716de09 100644 --- a/crates/pvm-contract-macros/src/codegen/abi_gen.rs +++ b/crates/pvm-contract-macros/src/codegen/abi_gen.rs @@ -48,12 +48,60 @@ pub fn generate_abi_gen( } /// Generate a module-level `__storage_layout_json()` function that builds the -/// JSON layout from the `#[slot(N)]` fields on the contract struct. +/// JSON layout from the storage fields on the contract struct. +/// +/// Auto-numbered slots reference a chain of `__pvm_storage_slot_*` const items +/// declared inside the helper fn (mirroring the chain produced for the +/// `this` construction in `deploy()`/`call()`). This way the slot value is +/// const-evaluated at compile time even when `::SLOTS` +/// is not trivially 1 (e.g. for embedded sub-storage structs). fn storage_layout_helper(slot_fields: &[SlotField]) -> TokenStream { + use super::contract::Slot; + + let auto_slot_consts: Vec = slot_fields + .iter() + .filter_map(|sf| match sf.slot { + Slot::Auto { index } => { + let name = &sf.name; + let const_ident = quote::format_ident!("__pvm_storage_slot_{}", name); + let cfgs = &sf.cfg_attrs; + if index == 0 { + Some(quote! { + #(#cfgs)* + #[allow(non_upper_case_globals)] + const #const_ident: u64 = 0; + }) + } else { + let prev = slot_fields + .iter() + .filter(|s| matches!(s.slot, Slot::Auto { .. })) + .nth(index - 1) + .expect("auto_index walks in order"); + let prev_const = quote::format_ident!("__pvm_storage_slot_{}", &prev.name); + let prev_ty = &prev.ty; + Some(quote! { + #(#cfgs)* + #[allow(non_upper_case_globals)] + const #const_ident: u64 = #prev_const + + <#prev_ty as ::pvm_contract_sdk::StorageComponent>::SLOTS; + }) + } + } + Slot::Explicit(_) => None, + }) + .collect(); + let layout_pushes: Vec = slot_fields .iter() .map(|sf| { - let entry = generate_layout_entry(&sf.name.to_string(), &sf.ty, sf.slot); + let slot_expr: TokenStream = match sf.slot { + Slot::Explicit(n) => quote! { #n }, + Slot::Auto { .. } => { + let const_ident = quote::format_ident!("__pvm_storage_slot_{}", &sf.name); + quote! { #const_ident } + } + }; + let entry = generate_layout_entry(&sf.name.to_string(), &sf.ty, slot_expr); let cfgs = &sf.cfg_attrs; quote! { #(#cfgs)* @@ -67,6 +115,7 @@ fn storage_layout_helper(slot_fields: &[SlotField]) -> TokenStream { #[cfg(feature = "abi-gen")] #[doc(hidden)] pub fn __storage_layout_json() -> ::std::string::String { + #(#auto_slot_consts)* let mut entries: ::std::vec::Vec<::pvm_contract_sdk::StorageLayoutEntry> = ::std::vec::Vec::new(); #(#layout_pushes)* #json_assembly @@ -428,6 +477,7 @@ mod tests { returns_result: false, mutability: StateMutability::View, precomputed_selector: None, + trait_path: None, }; let parsed = parsed_contract_with_method(method); let (helper, _main_fn) = generate_abi_gen(&parsed, false, &[]); @@ -449,6 +499,7 @@ mod tests { returns_result: false, mutability: StateMutability::Pure, precomputed_selector: None, + trait_path: None, }; let parsed = parsed_contract_with_method(method); let (helper, _main_fn) = generate_abi_gen(&parsed, false, &[]); @@ -508,7 +559,7 @@ mod tests { let slot_fields = vec![SlotField { name: syn::parse_str("total_supply").unwrap(), ty: syn::parse_str("Lazy").unwrap(), - slot: 0, + slot: crate::codegen::contract::Slot::Explicit(0), cfg_attrs: vec![], }]; let (helper, main_fn) = generate_abi_gen(&parsed, true, &slot_fields); @@ -593,7 +644,7 @@ mod tests { let slot_fields = vec![SlotField { name: syn::parse_str("balances").unwrap(), ty: syn::parse_str("Mapping").unwrap(), - slot: 1, + slot: crate::codegen::contract::Slot::Explicit(1), cfg_attrs: vec![], }]; let (helper, main_fn) = generate_abi_gen(&parsed, false, &slot_fields); @@ -619,7 +670,7 @@ mod tests { let slot_fields = vec![SlotField { name: syn::parse_str("data").unwrap(), ty: syn::parse_str("Lazy").unwrap(), - slot: 0, + slot: crate::codegen::contract::Slot::Explicit(0), cfg_attrs: vec![cfg_attr], }]; let helper = storage_layout_helper(&slot_fields); diff --git a/crates/pvm-contract-macros/src/codegen/contract.rs b/crates/pvm-contract-macros/src/codegen/contract.rs index 18cee30a..a68d8a04 100644 --- a/crates/pvm-contract-macros/src/codegen/contract.rs +++ b/crates/pvm-contract-macros/src/codegen/contract.rs @@ -18,6 +18,11 @@ pub struct ContractArgs { pub sol_path: Option, pub allocator: Option, pub allocator_size: usize, + /// Traits whose `impl Trait for Contract { ... }` blocks should be + /// included in dispatch. Parsed from `implements(IErc20, + /// IErc721)` — each entry is the trait path including any + /// associated-type bindings (`` etc.). + pub implements: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -33,6 +38,7 @@ impl Default for ContractArgs { sol_path: None, allocator: None, allocator_size: 1024, + implements: Vec::new(), } } } @@ -84,6 +90,31 @@ impl Parse for ContractArgs { args.allocator_size = size.base10_parse()?; allocator_size_set = true; } + "implements" => { + // Grammar: implements(Path1, Path2, ...) + // Each `Path` may carry associated-type bindings such as + // `IErc20` (parsed as a generic args + // segment on the final ident). + let content; + syn::parenthesized!(content in input); + let paths: syn::punctuated::Punctuated = + content.parse_terminated(syn::Path::parse, Token![,])?; + args.implements = paths.into_iter().collect(); + } + // Reserved keywords — recognise them so the grammar is stable + // even though dispatch for them is not wired up yet. This + // matches the plan in the SDK roadmap (Tier A #5): reserve + // future attribute names early so adding semantics later is + // not a breaking change. + "abi_source" | "selector" => { + return Err(syn::Error::new( + ident.span(), + format!( + "`{other}` is reserved for future use but not yet implemented.", + other = ident + ), + )); + } other => { return Err(syn::Error::new( ident.span(), @@ -146,16 +177,42 @@ pub(super) struct ParsedContract { pub(super) error_types: Vec, } -/// A storage field annotated with `#[slot(N)]` on the contract struct. +/// A storage field on the contract struct. +/// +/// `slot` is either pinned by an explicit `#[slot(N)]` attribute (`Slot::Explicit`) +/// or auto-numbered by declaration order (`Slot::Auto`). Auto-numbering computes +/// the slot at compile time via the field type's [`StorageComponent::SLOTS`], +/// summed left-to-right across all preceding auto-numbered fields. #[derive(Debug, Clone)] pub(super) struct SlotField { pub name: Ident, pub ty: syn::Type, - pub slot: u64, + pub slot: Slot, /// `#[cfg(...)]` attributes on the field, propagated into construction and layout. pub cfg_attrs: Vec, } +/// How a storage field's slot is determined. +#[derive(Debug, Clone)] +pub(super) enum Slot { + /// Explicit `#[slot(N)]` attribute. + Explicit(u64), + /// Auto-numbered: computed at compile time from declaration order and + /// each preceding field's `::SLOTS`. + /// `index` is the position among auto-numbered fields (0, 1, 2, ...). + Auto { index: usize }, +} + +impl SlotField { + /// Explicit slot value, or `None` if auto-numbered. + pub(super) fn explicit_slot(&self) -> Option { + match self.slot { + Slot::Explicit(n) => Some(n), + Slot::Auto { .. } => None, + } + } +} + const VALID_PREFIXES: &[&str] = &[ "pvm", "pvm_contract", @@ -300,23 +357,7 @@ fn collect_error_type( } } -fn to_camel_case(snake: &str) -> String { - let mut result = String::new(); - let mut next_upper = false; - for (i, c) in snake.chars().enumerate() { - if c == '_' { - next_upper = true; - } else if i == 0 { - result.push(c); - } else if next_upper { - result.push(c.to_ascii_uppercase()); - next_upper = false; - } else { - result.push(c); - } - } - result -} +use crate::utils::to_camel_case; fn extract_return_types(output: &syn::ReturnType) -> Vec { match output { @@ -358,39 +399,31 @@ fn extract_result_ok_type(ty: &syn::Type) -> Option { /// Extract typed params from an impl-method's `FnArg` list. /// -/// Requires the first parameter to be a `self` receiver (`&self`, `&mut self`, -/// or owned `self`) — without one, dispatch can't call `this.method(...)` on -/// the generated contract struct, so we error loudly here instead of producing -/// a cryptic "method not found" error from expanded code. +/// Accepts methods with `&self`, `&mut self`, or no receiver (associated +/// functions, used for `pure` methods). Owned `self` is rejected — it would +/// consume the contract instance. The receiver, when present, is skipped; +/// remaining typed params are returned in order. +/// +/// Mutability/payable enforcement is done by [`classify_receiver`] and +/// [`infer_method_mutability`] at the call site. fn extract_typed_params_impl( - func: &syn::ImplItemFn, + _func: &syn::ImplItemFn, inputs: &syn::punctuated::Punctuated, ) -> syn::Result> { - let Some(first) = inputs.first() else { - return Err(syn::Error::new_spanned( - &func.sig, - "Contract methods must take `&self` or `&mut self` as the first parameter", - )); - }; - match first { - syn::FnArg::Receiver(r) if r.reference.is_none() => { + let skip = match inputs.first() { + Some(syn::FnArg::Receiver(r)) if r.reference.is_none() => { return Err(syn::Error::new_spanned( r, - "Contract methods must take a borrowed self (`&self` / `&mut self`); owning `self` would consume the contract instance", - )); - } - syn::FnArg::Receiver(_) => {} - syn::FnArg::Typed(_) => { - return Err(syn::Error::new_spanned( - first, - "Contract methods must take `&self` or `&mut self` as the first parameter", + "owning `self` would consume the contract instance; use `&self` or `&mut self`", )); } - } + Some(syn::FnArg::Receiver(_)) => 1, + _ => 0, + }; inputs .iter() - .skip(1) + .skip(skip) .map(|arg| match arg { syn::FnArg::Receiver(r) => Err(syn::Error::new_spanned( r, @@ -411,6 +444,31 @@ fn extract_typed_params_impl( .collect() } +/// Scan struct attributes for `#[derive(..., Clone, ...)]` (any path that +/// resolves to `Clone`, ignoring fully-qualified prefixes like +/// `core::clone::Clone`). Returns the offending derive token for span +/// reporting, or `None` if no `Clone` is derived. +fn find_derive_clone(attrs: &[Attribute]) -> Option<&Attribute> { + for attr in attrs { + if !attr.path().is_ident("derive") { + continue; + } + let derives_clone = attr + .parse_args_with(syn::punctuated::Punctuated::::parse_terminated) + .ok() + .map(|paths| { + paths + .iter() + .any(|p| p.segments.last().is_some_and(|s| s.ident == "Clone")) + }) + .unwrap_or(false); + if derives_clone { + return Some(attr); + } + } + None +} + /// `true` iff the function's first parameter is `&mut self`. fn receiver_is_mut(inputs: &syn::punctuated::Punctuated) -> bool { matches!( @@ -420,6 +478,120 @@ fn receiver_is_mut(inputs: &syn::punctuated::Punctuated T`. Maps to Solidity `pure`. + None, + /// `&self` — read-only. Maps to Solidity `view`. + Ref, + /// `&mut self` — mutating. Maps to `nonpayable` / `payable`. + RefMut, +} + +fn classify_receiver( + inputs: &syn::punctuated::Punctuated, +) -> syn::Result { + match inputs.first() { + None | Some(syn::FnArg::Typed(_)) => Ok(ReceiverKind::None), + Some(syn::FnArg::Receiver(r)) => { + if r.reference.is_none() { + return Err(syn::Error::new_spanned( + r, + "consuming `self` receiver is not supported; use `&self` or `&mut self`", + )); + } + if r.mutability.is_some() { + Ok(ReceiverKind::RefMut) + } else { + Ok(ReceiverKind::Ref) + } + } + } +} + +/// Infer Solidity stateMutability from receiver + `#[payable]`. +/// +/// | Receiver | `#[payable]` | Result | +/// |----------------|--------------|---------------| +/// | none | no | `Pure` | +/// | none | yes | error | +/// | `&self` | no | `View` | +/// | `&self` | yes | error | +/// | `&mut self` | no | `NonPayable` | +/// | `&mut self` | yes | `Payable` | +fn infer_method_mutability( + func: &syn::ImplItemFn, + is_payable: bool, +) -> syn::Result { + let kind = classify_receiver(&func.sig.inputs)?; + match (kind, is_payable) { + (ReceiverKind::None, false) => Ok(StateMutability::Pure), + (ReceiverKind::Ref, false) => Ok(StateMutability::View), + (ReceiverKind::RefMut, false) => Ok(StateMutability::NonPayable), + (ReceiverKind::RefMut, true) => Ok(StateMutability::Payable), + (ReceiverKind::None, true) => Err(syn::Error::new_spanned( + func, + "associated function (no `self` receiver) cannot be marked `#[payable]`; \ + payable callables must take `&mut self`", + )), + (ReceiverKind::Ref, true) => Err(syn::Error::new_spanned( + func, + "method is marked `#[payable]` but takes `&self`; \ + payable callables must take `&mut self`", + )), + } +} + +/// Format a `.sol` vs Rust mutability mismatch into a human-readable error +/// pointing at the Rust method. +fn mutability_mismatch_error( + func: &syn::ImplItemFn, + fn_name: &str, + sol: StateMutability, + rust: StateMutability, +) -> syn::Error { + let hint = match (sol, rust) { + (StateMutability::View, StateMutability::NonPayable) => "change Rust receiver to `&self`", + (StateMutability::View, StateMutability::Payable) => { + "remove `#[payable]` and change to `&self`" + } + (StateMutability::View, StateMutability::Pure) => "change Rust signature to take `&self`", + (StateMutability::Pure, StateMutability::View) => { + "remove `&self` (associated functions are pure)" + } + (StateMutability::Pure, StateMutability::NonPayable) => { + "remove `&mut self` (associated functions are pure)" + } + (StateMutability::Pure, StateMutability::Payable) => { + "remove `&mut self` and `#[payable]` (associated functions are pure)" + } + (StateMutability::NonPayable, StateMutability::View) => { + "change Rust receiver to `&mut self`" + } + (StateMutability::NonPayable, StateMutability::Pure) => "add a `&mut self` receiver", + (StateMutability::NonPayable, StateMutability::Payable) => "remove `#[payable]`", + (StateMutability::Payable, StateMutability::NonPayable) => "add `#[payable]`", + (StateMutability::Payable, StateMutability::View) => { + "change to `&mut self` and add `#[payable]`" + } + (StateMutability::Payable, StateMutability::Pure) => { + "add a `&mut self` receiver and `#[payable]`" + } + _ => "update either the `.sol` interface or the Rust signature", + }; + syn::Error::new_spanned( + func, + format!( + "method `{fn_name}` mutability mismatch: `.sol` declares `{}`, \ + Rust signature is `{}`. {}.", + sol.as_abi_str(), + rust.as_abi_str(), + hint, + ), + ) +} + /// Shared payable helpers emitted once per contract module so call sites /// collapse to a single function call. `__pvm_assert_value_zero` reverts on a /// boolean flag so mixed-payability dispatchers can read `value_transferred` @@ -462,9 +634,23 @@ fn build_assert_non_payable_call(emit: bool) -> TokenStream { quote! { __pvm_assert_non_payable(this.host()); } } +/// Back-compat wrapper used by the existing test suite. +#[cfg(test)] fn parse_contract( input: &ItemMod, sol_interface: Option<&syn_solidity::File>, +) -> syn::Result { + parse_contract_with_implements(input, sol_interface, &[]) +} + +/// Like [`parse_contract`] but takes the list of trait paths the contract is +/// declared to `implements(...)`. Trait impls matching one of these paths are +/// scanned for dispatched methods, with each `fn` becoming an implicit +/// `#[method]` callable through UFCS. +fn parse_contract_with_implements( + input: &ItemMod, + sol_interface: Option<&syn_solidity::File>, + implements: &[syn::Path], ) -> syn::Result { let mod_name = input.ident.clone(); let content = input @@ -510,12 +696,28 @@ fn parse_contract( // concrete type arguments at the impl site. if let syn::Item::Struct(item_struct) = item && &item_struct.ident == expected - && !item_struct.generics.params.is_empty() { - return Err(syn::Error::new_spanned( - &item_struct.generics.params, - "contract structs must not be generic", - )); + if !item_struct.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &item_struct.generics.params, + "contract structs must not be generic", + )); + } + // The contract storage struct is the borrow-check root for + // mutation gating: a `&self` method holds `&Storage`, a + // `&mut self` method holds `&mut Storage`. If the user + // derives `Clone`, a view method could clone the storage and + // get a fresh `&mut Storage` — bypassing the gate and lying + // to the ABI. Reject `#[derive(Clone)]` syntactically. + if let Some(bad) = find_derive_clone(&item_struct.attrs) { + return Err(syn::Error::new_spanned( + bad, + "contract storage structs must not derive `Clone`; the \ + mutation gate (`&self` vs `&mut self`) relies on \ + `Storage: !Clone` to prevent view methods from \ + smuggling out a `&mut Storage`", + )); + } } let syn::Item::Impl(item_impl) = item else { @@ -610,10 +812,13 @@ fn parse_contract( constructor_name = Some(func.sig.ident.clone()); constructor_returns_result = is_result_return_type(&func.sig.output); constructor_is_payable = has_pvm_attr(&func.attrs, "payable"); - if constructor_is_payable && !receiver_is_mut(&func.sig.inputs) { + // Constructors must take `&mut self`. A view (`&self`) or pure + // (no receiver) constructor cannot initialize storage, so it + // would never be a useful entry point. Reject explicitly. + if !receiver_is_mut(&func.sig.inputs) { return Err(syn::Error::new_spanned( func, - "constructor is marked `#[payable]` but takes `&self`; payable callables must take `&mut self`", + "constructor must take `&mut self`; it always initializes storage", )); } constructor_inputs = extract_typed_params_impl(func, &func.sig.inputs)?; @@ -623,22 +828,28 @@ fn parse_contract( fallback_name = Some(func.sig.ident.clone()); fallback_returns_result = is_result_return_type(&func.sig.output); fallback_is_payable = has_pvm_attr(&func.attrs, "payable"); - if fallback_is_payable && !receiver_is_mut(&func.sig.inputs) { - return Err(syn::Error::new_spanned( - func, - "fallback is marked `#[payable]` but takes `&self`; payable callables must take `&mut self`", - )); + // Fallback dispatch generates `this.fallback_name()` (method + // call), so the fallback must have a receiver. `&self` and + // `&mut self` are both valid; no-receiver (pure) fallback is + // rejected here — a pure fallback has no host access and is + // never useful (can't read calldata, return values, or state). + match classify_receiver(&func.sig.inputs)? { + ReceiverKind::Ref | ReceiverKind::RefMut => {} + ReceiverKind::None => { + return Err(syn::Error::new_spanned( + func, + "fallback must take `&self` or `&mut self`; \ + a no-receiver fallback has no access to host or calldata", + )); + } } + // Reuses the payable+receiver consistency check. + let _ = infer_method_mutability(func, fallback_is_payable)?; collect_error_type(&func.sig.output, &mut error_types, &mut seen_error_names); } else if has_pvm_attr(&func.attrs, "method") { let typed_params = extract_typed_params_impl(func, &func.sig.inputs)?; let is_payable = has_pvm_attr(&func.attrs, "payable"); - if is_payable && !receiver_is_mut(&func.sig.inputs) { - return Err(syn::Error::new_spanned( - func, - "method is marked `#[payable]` but takes `&self`; payable callables must take `&mut self`", - )); - } + let inferred_mutability = infer_method_mutability(func, is_payable)?; let param_names: Vec = typed_params.iter().map(|(n, _)| n.clone()).collect(); let param_types: Vec = typed_params.into_iter().map(|(_, t)| t).collect(); @@ -708,34 +919,23 @@ fn parse_contract( Some(syn_solidity::Mutability::Payable(_)) => StateMutability::Payable, _ => StateMutability::NonPayable, }; - let sol_is_payable = sol_mutability == StateMutability::Payable; - let fn_name = func.sig.ident.to_string(); - if sol_is_payable && !is_payable { - return Err(syn::Error::new_spanned( - func, - format!( - "method '{fn_name}' is declared payable in the Solidity interface but the Rust signature is not marked `#[payable]`" - ), - )); - } - if !sol_is_payable && is_payable { - return Err(syn::Error::new_spanned( + if sol_mutability != inferred_mutability { + return Err(mutability_mismatch_error( func, - format!( - "method '{fn_name}' is not declared payable in the Solidity interface but the Rust signature is marked `#[payable]`" - ), + &func.sig.ident.to_string(), + sol_mutability, + inferred_mutability, )); } - (sol_func.name().to_string(), Some(selector), sol_mutability) + ( + sol_func.name().to_string(), + Some(selector), + inferred_mutability, + ) } else { let sol_name = extract_method_rename(&func.attrs)? .unwrap_or_else(|| to_camel_case(&func.sig.ident.to_string())); - let mutability = if is_payable { - StateMutability::Payable - } else { - StateMutability::NonPayable - }; - (sol_name, None, mutability) + (sol_name, None, inferred_mutability) }; methods.push(MethodInfo { @@ -747,6 +947,126 @@ fn parse_contract( returns_result, mutability, precomputed_selector, + trait_path: None, + }); + collect_error_type(&func.sig.output, &mut error_types, &mut seen_error_names); + } + } + } + + // --------------------------------------------------------------------- + // Trait-impl dispatch: walk `impl for ` blocks for every + // trait listed in `implements(...)`. Each `fn` in a matched trait impl + // becomes an implicit dispatched method — no `#[method]` attribute + // required, with selector computed from the camelCase fn name + Solidity + // param types. Dispatch goes through UFCS so inherent name collisions + // produce a clear "duplicate selector" compile error rather than a silent + // override. + // + // Path matching is "last-segment-ident equality": `implements(IErc20)` and + // `implements(super::IErc20)` both match `impl IErc20 for X` + // and `impl crate::traits::IErc20 for X`. This is intentionally loose — + // proc macros can't resolve paths to concrete types, so we accept any + // import alias of the same trait name. + if !implements.is_empty() { + let expected_struct = struct_name + .as_ref() + .ok_or_else(|| { + syn::Error::new_spanned( + input, + "`implements(...)` was specified but the contract has no \ + storage struct. Define `pub struct YourContract { ... }` \ + inside the module.", + ) + })? + .clone(); + + for item in &content.1 { + let syn::Item::Impl(item_impl) = item else { + continue; + }; + let Some((_, trait_path, _)) = &item_impl.trait_ else { + continue; + }; + + // Match the trait path against the declared `implements` list by + // the last segment's ident. See comment above for rationale. + let trait_ident = match trait_path.segments.last() { + Some(seg) => &seg.ident, + None => continue, + }; + let declared = implements.iter().any(|p| { + p.segments + .last() + .map(|s| &s.ident == trait_ident) + .unwrap_or(false) + }); + if !declared { + continue; + } + + // Validate that the trait impl targets the contract struct. + let target_ident = match item_impl.self_ty.as_ref() { + syn::Type::Path(type_path) => type_path.path.segments.last().map(|s| &s.ident), + _ => None, + }; + let Some(target_ident) = target_ident else { + return Err(syn::Error::new_spanned( + &item_impl.self_ty, + "`impl Trait for ...`: target type must be a named struct", + )); + }; + if target_ident != &expected_struct { + return Err(syn::Error::new_spanned( + &item_impl.self_ty, + format!( + "trait impl targets `{target_ident}` but the contract \ + struct is `{expected_struct}`. Move the impl to the \ + contract struct, or remove the trait from \ + `implements(...)`." + ), + )); + } + + // Walk every `fn` in this trait impl and record it as a method. + for impl_item in &item_impl.items { + let syn::ImplItem::Fn(func) = impl_item else { + continue; + }; + + // Generics on trait methods are not supported — selectors are + // concrete. + if !func.sig.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &func.sig.generics.params, + "contract methods (including trait-impl methods) \ + must not be generic", + )); + } + + let typed_params = extract_typed_params_impl(func, &func.sig.inputs)?; + let is_payable = has_pvm_attr(&func.attrs, "payable"); + let inferred_mutability = infer_method_mutability(func, is_payable)?; + let param_names: Vec = + typed_params.iter().map(|(n, _)| n.clone()).collect(); + let param_types: Vec = + typed_params.into_iter().map(|(_, t)| t).collect(); + let returns_result = is_result_return_type(&func.sig.output); + let return_types = extract_return_types(&func.sig.output); + + let sol_name = extract_method_rename(&func.attrs)? + .unwrap_or_else(|| to_camel_case(&func.sig.ident.to_string())); + + methods.push(MethodInfo { + fn_name: func.sig.ident.clone(), + sol_name, + param_names, + param_types, + return_types, + returns_result, + mutability: inferred_mutability, + precomputed_selector: None, + trait_path: Some(trait_path.clone()), }); collect_error_type(&func.sig.output, &mut error_types, &mut seen_error_names); } @@ -819,7 +1139,7 @@ pub fn expand_contract(args: ContractArgs, input: ItemMod) -> syn::Result syn::Result::SLOTS`. The compiler + // evaluates the chain at monomorphization, so user-defined storage + // components with `SLOTS > 1` (embedded sub-storage structs) get the + // correct contiguous range without the macro knowing their size. + // + // The `#[allow(non_upper_case_globals)]` is needed because the const idents + // include the field name verbatim (which is snake_case). + let auto_slot_consts: Vec = slot_fields + .iter() + .filter_map(|sf| match sf.slot { + Slot::Auto { index } => { + let name = &sf.name; + let const_ident = + quote::format_ident!("__pvm_storage_slot_{}", name); + let cfgs = &sf.cfg_attrs; + if index == 0 { + Some(quote! { + #(#cfgs)* + #[allow(non_upper_case_globals)] + const #const_ident: u64 = 0; + }) + } else { + // Reference the previous auto-numbered field's const. + // We need to look up the previous auto-numbered name. + let prev = slot_fields + .iter() + .filter(|s| matches!(s.slot, Slot::Auto { .. })) + .nth(index - 1) + .expect("auto_index walks in order"); + let prev_const = + quote::format_ident!("__pvm_storage_slot_{}", &prev.name); + let prev_ty = &prev.ty; + Some(quote! { + #(#cfgs)* + #[allow(non_upper_case_globals)] + const #const_ident: u64 = #prev_const + + <#prev_ty as ::pvm_contract_sdk::StorageComponent>::SLOTS; + }) + } + } + Slot::Explicit(_) => None, + }) + .collect(); + let slot_field_inits: Vec = slot_fields .iter() .map(|sf| { let name = &sf.name; let ty = &sf.ty; - let slot = sf.slot; let cfgs = &sf.cfg_attrs; + let slot_expr: TokenStream = match sf.slot { + Slot::Explicit(n) => quote! { #n }, + Slot::Auto { .. } => { + let const_ident = + quote::format_ident!("__pvm_storage_slot_{}", name); + quote! { #const_ident } + } + }; quote! { #(#cfgs)* - #name: <#ty>::new( - ::pvm_contract_sdk::StorageKey::from_slot(#slot), + #name: <#ty as ::pvm_contract_sdk::StorageComponent>::new_at( + #slot_expr, host.clone(), ) } @@ -921,6 +1298,7 @@ pub fn expand_contract(args: ContractArgs, input: ItemMod) -> syn::Result syn::Result syn::Result &::pvm_contract_sdk::Host { + &self.host + } + } }; Ok(quote! { @@ -1318,7 +1713,16 @@ fn extract_slot_fields_from_struct(item_struct: &syn::ItemStruct) -> syn::Result syn::Fields::Unnamed(_) => return Ok(vec![]), }; - let mut fields = Vec::new(); + // First pass: gather raw field info + their explicit-slot annotations. + struct Raw { + name: Ident, + ty: syn::Type, + explicit: Option, + cfg_attrs: Vec, + original_field: syn::Field, + } + + let mut raws: Vec = Vec::new(); for field in &named.named { let Some(ident) = &field.ident else { continue; @@ -1333,38 +1737,87 @@ fn extract_slot_fields_from_struct(item_struct: &syn::ItemStruct) -> syn::Result } continue; } - let Some(slot) = extract_optional_slot_attr(field)? else { - return Err(syn::Error::new_spanned( - field, - format!( - "field `{ident}` must have a `#[slot(N)]` attribute. \ - All non-host fields on the contract struct are storage fields \ - and require a slot number." - ), - )); - }; + let explicit = extract_optional_slot_attr(field)?; let cfg_attrs: Vec = field .attrs .iter() .filter(|a| a.path().is_ident("cfg")) .cloned() .collect(); - fields.push(SlotField { + raws.push(Raw { name: ident.clone(), ty: field.ty.clone(), - slot, + explicit, cfg_attrs, + original_field: field.clone(), + }); + } + + if raws.is_empty() { + return Ok(vec![]); + } + + // Mode decision: either ALL fields have `#[slot(N)]` (explicit mode, the + // original behavior) or NONE do (auto-numbered, the new behavior). Mixing + // is rejected so users don't end up with surprising slot assignments where + // an explicit slot collides with an auto-assigned one. + let any_explicit = raws.iter().any(|r| r.explicit.is_some()); + let all_explicit = raws.iter().all(|r| r.explicit.is_some()); + + if any_explicit && !all_explicit { + // Find the first un-annotated field to attach the error span. + let offender = raws + .iter() + .find(|r| r.explicit.is_none()) + .expect("checked any_explicit && !all_explicit"); + return Err(syn::Error::new_spanned( + &offender.original_field, + format!( + "field `{}` is missing `#[slot(N)]`. \ + Storage fields must all be annotated with `#[slot(N)]`, or all \ + left un-annotated for auto-numbering by declaration order. \ + Mixing the two modes is not supported.", + offender.name, + ), + )); + } + + let mut fields = Vec::new(); + let mut auto_index = 0usize; + for raw in raws { + let slot = if let Some(n) = raw.explicit { + Slot::Explicit(n) + } else { + let s = Slot::Auto { index: auto_index }; + auto_index += 1; + s + }; + fields.push(SlotField { + name: raw.name, + ty: raw.ty, + slot, + cfg_attrs: raw.cfg_attrs, }); } - // Reject duplicate slot numbers. When both fields are #[cfg]-gated - // AND share the same name, we allow it. The compiler enforces that - // only one field with a given name exists, so exactly one cfg branch - // will be active. Different names with the same slot are always - // rejected because the compiler can't catch the aliasing. + // Reject duplicate explicit slot numbers. (Auto-numbered slots cannot + // collide with each other by construction; they can only collide with + // explicit slots when both modes are mixed, which we already rejected + // above.) + // + // When both fields are #[cfg]-gated AND share the same name, we allow it. + // The compiler enforces that only one field with a given name exists, so + // exactly one cfg branch will be active. Different names with the same + // slot are always rejected because the compiler can't catch the aliasing. for (i, a) in fields.iter().enumerate() { + let Some(a_slot) = a.explicit_slot() else { + continue; + }; for b in &fields[i + 1..] { - if a.slot != b.slot { + let Some(b_slot) = b.explicit_slot() else { + continue; + }; + if a_slot != b_slot { continue; } let both_cfg = !a.cfg_attrs.is_empty() && !b.cfg_attrs.is_empty(); @@ -1376,7 +1829,7 @@ fn extract_slot_fields_from_struct(item_struct: &syn::ItemStruct) -> syn::Result item_struct, format!( "duplicate slot {}: fields `{}` and `{}` use the same slot number", - a.slot, a.name, b.name + a_slot, a.name, b.name ), )); } @@ -1539,7 +1992,7 @@ mod tests { pub struct C; impl C { #[pvm_contract_macros::method] - pub fn add(&self, a: U256, b: U256) -> U256 { U256::ZERO } + pub fn add(a: U256, b: U256) -> U256 { U256::ZERO } } } }; @@ -1548,6 +2001,68 @@ mod tests { assert_eq!(method.mutability, StateMutability::Pure); } + #[test] + fn parse_contract_pure_with_self_rejected() { + // `.sol` declares `pure`, but Rust takes `&self` — pure functions + // cannot have host access, so the receiver must be absent. + let iface = parse_sol( + r#" + interface I { + function add(uint256 a, uint256 b) external pure returns (uint256); + } + "#, + ); + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::method] + pub fn add(&self, a: U256, b: U256) -> U256 { U256::ZERO } + } + } + }; + let err = match parse_contract(&input, Some(&iface)) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + let msg = err.to_string(); + assert!( + msg.contains("mutability mismatch") && msg.contains("pure") && msg.contains("view"), + "expected pure/view mismatch, got: {msg}" + ); + } + + #[test] + fn parse_contract_view_mismatch_with_mut_self_rejected() { + let iface = parse_sol( + r#" + interface I { + function balance() external view returns (uint256); + } + "#, + ); + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::method] + pub fn balance(&mut self) -> U256 { U256::ZERO } + } + } + }; + let err = match parse_contract(&input, Some(&iface)) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + let msg = err.to_string(); + assert!( + msg.contains("mutability mismatch") + && msg.contains("view") + && msg.contains("nonpayable"), + "expected view/nonpayable mismatch, got: {msg}" + ); + } + #[test] fn parse_contract_nonpayable_from_sol_leaves_flags_false() { let iface = parse_sol( @@ -1576,7 +2091,7 @@ mod tests { } #[test] - fn parse_contract_without_sol_leaves_view_pure_false() { + fn parse_contract_without_sol_infers_view_from_ref_self() { let input: syn::ItemMod = syn::parse_quote! { mod c { pub struct C; @@ -1592,9 +2107,208 @@ mod tests { .iter() .find(|m| m.fn_name == "balance") .unwrap(); + assert_eq!(method.mutability, StateMutability::View); + } + + #[test] + fn parse_contract_without_sol_infers_pure_from_no_receiver() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::method] + pub fn version() -> u32 { 1 } + } + } + }; + let parsed = parse_contract(&input, None).unwrap(); + let method = parsed + .methods + .iter() + .find(|m| m.fn_name == "version") + .unwrap(); + assert_eq!(method.mutability, StateMutability::Pure); + } + + #[test] + fn parse_contract_without_sol_infers_nonpayable_from_mut_self() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::method] + pub fn transfer(&mut self) {} + } + } + }; + let parsed = parse_contract(&input, None).unwrap(); + let method = parsed + .methods + .iter() + .find(|m| m.fn_name == "transfer") + .unwrap(); assert_eq!(method.mutability, StateMutability::NonPayable); } + #[test] + fn parse_contract_payable_on_ref_self_rejected() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::method] + #[pvm_contract_macros::payable] + pub fn deposit(&self) {} + } + } + }; + let err = match parse_contract(&input, None) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!( + err.to_string().contains("payable") && err.to_string().contains("&self"), + "expected payable+&self error, got: {}", + err + ); + } + + #[test] + fn parse_contract_rejects_clone_on_storage_struct() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + #[derive(Clone)] + pub struct C; + impl C { + #[pvm_contract_macros::method] + pub fn balance(&self) -> U256 { U256::ZERO } + } + } + }; + let err = match parse_contract(&input, None) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!( + err.to_string().contains("must not derive `Clone`"), + "expected Clone rejection, got: {err}" + ); + } + + #[test] + fn parse_contract_rejects_clone_in_multi_derive() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + #[derive(Debug, Clone, PartialEq)] + pub struct C; + impl C { + #[pvm_contract_macros::method] + pub fn balance(&self) -> U256 { U256::ZERO } + } + } + }; + let err = match parse_contract(&input, None) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!( + err.to_string().contains("must not derive `Clone`"), + "expected Clone rejection in multi-derive, got: {err}" + ); + } + + #[test] + fn parse_contract_constructor_with_ref_self_rejected() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::constructor] + pub fn new(&self) {} + } + } + }; + let err = match parse_contract(&input, None) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!( + err.to_string() + .contains("constructor must take `&mut self`"), + "expected constructor mutability rejection, got: {err}" + ); + } + + #[test] + fn parse_contract_fallback_with_no_receiver_rejected() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::constructor] + pub fn new(&mut self) {} + + #[pvm_contract_macros::fallback] + pub fn fb() {} + } + } + }; + let err = match parse_contract(&input, None) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!( + err.to_string().contains("fallback must take") + || err.to_string().contains("no-receiver"), + "expected fallback receiver rejection, got: {err}" + ); + } + + #[test] + fn parse_contract_constructor_with_no_receiver_rejected() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::constructor] + pub fn new() {} + } + } + }; + let err = match parse_contract(&input, None) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!( + err.to_string() + .contains("constructor must take `&mut self`"), + "expected constructor mutability rejection, got: {err}" + ); + } + + #[test] + fn parse_contract_payable_on_no_receiver_rejected() { + let input: syn::ItemMod = syn::parse_quote! { + mod c { + pub struct C; + impl C { + #[pvm_contract_macros::method] + #[pvm_contract_macros::payable] + pub fn deposit() {} + } + } + }; + let err = match parse_contract(&input, None) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + assert!( + err.to_string().contains("payable"), + "expected payable error on no-receiver method, got: {}", + err + ); + } + #[test] fn parse_contract_non_payable_fallback() { let input: syn::ItemMod = syn::parse_quote! { @@ -1622,6 +2336,7 @@ mod tests { sol_path: Some("MyToken.sol".to_string()), allocator: None, allocator_size: 1024, + implements: Vec::new(), } ); } @@ -1638,10 +2353,65 @@ mod tests { sol_path: None, allocator: Some(super::AllocatorKind::Pico), allocator_size: 2048, + implements: Vec::new(), } ); } + #[test] + fn parses_implements_single_trait() { + let args = syn::parse_str::("implements(IErc20)") + .expect("single-trait implements should parse"); + assert_eq!(args.implements.len(), 1); + let path = &args.implements[0]; + assert_eq!(quote::quote! { #path }.to_string(), "IErc20"); + } + + #[test] + fn parses_implements_multi_trait_with_associated_types() { + let args = syn::parse_str::( + "implements(IErc20, IErc721, IErc165)", + ) + .expect("multi-trait implements with associated-type bindings should parse"); + assert_eq!(args.implements.len(), 3); + // Cross-check: each trait path should round-trip through quote without + // losing its generic args. + let rendered: Vec = args + .implements + .iter() + .map(|p| quote::quote! { #p }.to_string()) + .collect(); + assert!( + rendered[0].contains("IErc20") && rendered[0].contains("Error = MyError"), + "Got: {:?}", + rendered + ); + assert!(rendered[1].contains("IErc721"), "Got: {:?}", rendered); + assert_eq!(rendered[2], "IErc165"); + } + + #[test] + fn reserved_keywords_rejected_with_clear_message() { + // `abi_source` and `selector` are parked for future use — must error + // today so a future implementation can introduce them without breaking + // anyone who picked the same name for their own attribute. + let err = syn::parse_str::("abi_source = \"rust\"") + .unwrap_err() + .to_string(); + assert!( + err.contains("reserved for future use"), + "Expected reserved-keyword rejection. Got: {err}" + ); + + let err = syn::parse_str::("selector = 0x12345678") + .unwrap_err() + .to_string(); + assert!( + err.contains("reserved for future use"), + "Expected reserved-keyword rejection. Got: {err}" + ); + } + #[test] fn rejects_removed_no_alloc_argument() { let error = syn::parse_str::("no_alloc") @@ -1660,10 +2430,10 @@ mod tests { } #[test] - fn errors_when_method_missing_self() { - // A `#[method]` without a `self` receiver would expand into - // `this.foo(args)` where `foo` is a free associated function — producing - // a cryptic "no method named" error. Catch it at parse time instead. + fn method_without_receiver_is_pure() { + // No `self` receiver = associated function = `pure` mutability. + // Dispatch generates `MyContract::foo(args)` (UFCS) instead of + // `this.foo(args)` so the call type-checks. let input: ItemMod = syn::parse_quote! { mod my_contract { pub struct MyContract; @@ -1673,11 +2443,8 @@ mod tests { } } }; - let err = expand_contract(ContractArgs::default(), input).unwrap_err(); - assert!( - err.to_string().contains("&self"), - "error should mention &self: {err}" - ); + let _ts = expand_contract(ContractArgs::default(), input) + .expect("no-receiver method should be accepted as `pure`"); } #[test] @@ -1695,9 +2462,10 @@ mod tests { } }; let err = expand_contract(ContractArgs::default(), input).unwrap_err(); + let msg = err.to_string(); assert!( - err.to_string().contains("borrowed self"), - "error should mention borrowed self: {err}" + msg.contains("consume the contract") || msg.contains("&self"), + "error should reject owning self, got: {msg}" ); } @@ -1770,11 +2538,10 @@ mod tests { output.contains("fn route"), "route() function should be generated" ); - // The Router trait is instantiated at the concrete Host type + // The Router trait impl is emitted (no generic parameter). assert!( - output.contains("Router :: < :: pvm_contract_sdk :: Host >") - || output.contains(":: pvm_contract_sdk :: Router"), - "Router impl should target concrete Host" + output.contains(":: pvm_contract_sdk :: Router"), + "Router impl should be generated" ); // call() delegates to route() with the constructed `this` and falls // through to the unknown-selector handler when the Option is None. @@ -2194,10 +2961,15 @@ mod tests { .unwrap() .to_string(); - // Each slot field is constructed with StorageKey::from_slot(N) and host.clone() + // Each slot field is constructed via StorageComponent::new_at(N, host.clone()) + assert!( + output.contains("StorageComponent > :: new_at (0u64"), + "Slot field 0 should use StorageComponent::new_at(0u64, ...).\n\ + Expanded output:\n{output}" + ); assert!( - output.contains("from_slot (0u64") && output.contains("from_slot (1u64"), - "Slot fields should produce from_slot construction.\n\ + output.contains("StorageComponent > :: new_at (1u64"), + "Slot field 1 should use StorageComponent::new_at(1u64, ...).\n\ Expanded output:\n{output}" ); assert!( @@ -2241,7 +3013,7 @@ mod tests { .unwrap() .to_string(); - let slot_init_count = output.matches("from_slot (0u64").count(); + let slot_init_count = output.matches("new_at (0u64").count(); assert!( slot_init_count >= 2, "Slot field should be initialized in both deploy() and call().\n\ @@ -2249,6 +3021,94 @@ mod tests { ); } + #[test] + fn auto_numbered_storage_fields_compose_via_storage_component_slots() { + // When NO field carries `#[slot(N)]`, the macro auto-numbers fields in + // declaration order. Slots are computed at codegen time as a chain of + // `const` items so embedded sub-storage components (with SLOTS > 1) + // get a contiguous range without the macro knowing their layout. + let item: ItemMod = syn::parse_str( + r#" + mod my_contract { + pub struct MyContract { + counter: Lazy, + balances: Mapping, + allowances: Mapping>, + } + impl MyContract { + #[pvm_contract_macros::constructor] + pub fn new(&mut self) {} + } + } + "#, + ) + .unwrap(); + + let output = expand_contract(ContractArgs::default(), item) + .unwrap() + .to_string(); + + // The first auto-numbered field gets slot const = 0. + assert!( + output.contains("const __pvm_storage_slot_counter : u64 = 0 ;"), + "First auto-numbered field should declare its slot const = 0.\n\ + Expanded output:\n{output}" + ); + // Each subsequent field's slot is the previous field's slot plus its + // StorageComponent::SLOTS, evaluated at compile time. + assert!( + output.contains( + "const __pvm_storage_slot_balances : u64 = __pvm_storage_slot_counter + < Lazy < U256 > as :: pvm_contract_sdk :: StorageComponent > :: SLOTS" + ), + "Second field should chain off the first via StorageComponent::SLOTS.\n\ + Expanded output:\n{output}" + ); + assert!( + output.contains( + "const __pvm_storage_slot_allowances : u64 = __pvm_storage_slot_balances + < Mapping < Address , U256 > as :: pvm_contract_sdk :: StorageComponent > :: SLOTS" + ), + "Third field should chain off the second.\n\ + Expanded output:\n{output}" + ); + // Field construction references the slot consts (rather than literals). + assert!( + output.contains("StorageComponent > :: new_at (__pvm_storage_slot_counter ,"), + "Auto-numbered fields should pass their slot const to new_at.\n\ + Expanded output:\n{output}" + ); + } + + #[test] + fn mixing_explicit_and_auto_slots_rejected() { + // Mixing `#[slot(N)]` and unannotated fields is rejected to keep the + // mental model simple: either all slots are explicit or all are + // auto-numbered. + let item: ItemMod = syn::parse_str( + r#" + mod my_contract { + pub struct MyContract { + #[slot(0)] + counter: Lazy, + balances: Mapping, + } + impl MyContract { + #[pvm_contract_macros::constructor] + pub fn new(&mut self) {} + } + } + "#, + ) + .unwrap(); + + let err = expand_contract(ContractArgs::default(), item) + .unwrap_err() + .to_string(); + assert!( + err.contains("Mixing the two modes is not supported"), + "Expected mixed-mode rejection. Got: {err}" + ); + } + #[test] fn no_slot_fields_no_storage_construction() { let item: ItemMod = syn::parse_str( @@ -2280,7 +3140,9 @@ mod tests { } #[test] - fn missing_slot_attr_rejected_for_non_host_fields() { + fn lone_unannotated_field_auto_numbers_to_slot_zero() { + // Previously this was rejected ("must have a #[slot(N)] attribute"). + // It is now auto-numbered to slot 0 — `#[slot(N)]` is optional. let item: ItemMod = syn::parse_str( r#" mod my_contract { @@ -2296,12 +3158,13 @@ mod tests { ) .unwrap(); - let err = expand_contract(ContractArgs::default(), item) - .unwrap_err() + let output = expand_contract(ContractArgs::default(), item) + .unwrap() .to_string(); assert!( - err.contains("must have a `#[slot(N)]` attribute"), - "Expected missing-slot validation. Got: {err}" + output.contains("const __pvm_storage_slot_counter : u64 = 0 ;"), + "Single unannotated field should auto-number to slot 0.\n\ + Expanded output:\n{output}" ); } diff --git a/crates/pvm-contract-macros/src/codegen/dispatch.rs b/crates/pvm-contract-macros/src/codegen/dispatch.rs index 4d0389ff..7e12efc4 100644 --- a/crates/pvm-contract-macros/src/codegen/dispatch.rs +++ b/crates/pvm-contract-macros/src/codegen/dispatch.rs @@ -89,6 +89,14 @@ pub struct MethodInfo { pub mutability: StateMutability, /// When set, the selector is precomputed (e.g. from a `.sol` file). pub precomputed_selector: Option<[u8; 4]>, + /// Path to the trait this method is implemented on, when the method came + /// from `impl Trait for Contract { ... }`. `None` for inherent methods. + /// + /// When set, the dispatch arm calls the method through UFCS + /// (`::method`) instead of inherent method-call syntax + /// (`this.method`), which side-steps any name collision with an inherent + /// `method` on the contract and makes the binding explicit. + pub trait_path: Option, } pub(super) struct ParamDecoding { @@ -144,8 +152,27 @@ pub(super) fn generate_param_decoding( } } +/// Compose the per-method selector const ident. +/// +/// For inherent methods: `__SEL_{fn_name}`. For trait-impl methods we prefix +/// with the trait's last segment ident, so `::transfer` and +/// `::transfer` don't produce duplicate const names. +fn selector_const_ident(method: &MethodInfo) -> syn::Ident { + match &method.trait_path { + Some(path) => { + let trait_seg = path + .segments + .last() + .map(|s| s.ident.to_string()) + .unwrap_or_default(); + quote::format_ident!("__SEL_{}_{}", trait_seg, method.fn_name) + } + None => quote::format_ident!("__SEL_{}", method.fn_name), + } +} + fn build_selector_const(method: &MethodInfo) -> TokenStream { - let sel_ident = quote::format_ident!("__SEL_{}", method.fn_name); + let sel_ident = selector_const_ident(method); if let Some(selector) = method.precomputed_selector { let [s0, s1, s2, s3] = selector; @@ -217,10 +244,11 @@ pub(super) fn boundary_size_check(has_params: bool, min_size_expr: &TokenStream) pub fn generate_dispatch_arm( method: &MethodInfo, + struct_name: &syn::Ident, use_alloc: bool, guard_hoisted: bool, ) -> (TokenStream, TokenStream) { - let sel_ident = quote::format_ident!("__SEL_{}", method.fn_name); + let sel_ident = selector_const_ident(method); let const_def = build_selector_const(method); let fn_name = &method.fn_name; @@ -245,10 +273,45 @@ pub fn generate_dispatch_arm( } }; + // Build the function-invocation expression. Three shapes: + // + // - Pure (no receiver): `Self::name(args)`. Today's behavior unchanged. + // - Inherent method (`&self`/`&mut self`): `this.name(args)`. Today's + // behavior unchanged. + // - Trait-impl method: UFCS through the trait — + // `::name(this, args)`. This side-steps any name + // collision with an inherent `name` on the contract struct. + // + // For trait methods we pass `this` as the explicit receiver. Inside the + // generated `route()` body `this` is already a `&mut Self`, so it + // satisfies `&mut self` trait methods directly; for `&self` trait + // methods, Rust reborrows `&mut Self` as `&Self` at the call site. + let (invoke, prepend_receiver): (TokenStream, bool) = + match (&method.trait_path, method.mutability) { + (Some(trait_path), StateMutability::Pure) => { + (quote! { <#struct_name as #trait_path>::#fn_name }, false) + } + (Some(trait_path), _) => ( + quote! { <#struct_name as #trait_path>::#fn_name }, + true, + ), + (None, StateMutability::Pure) => (quote! { #struct_name::#fn_name }, false), + (None, _) => (quote! { this.#fn_name }, false), + }; + + // Compose the call argument list, optionally prepending the explicit + // `this` receiver for UFCS calls. For trait `&self` methods, Rust's + // standard argument coercion rules reborrow `&mut Self` → `&Self`. + let invoke_call = if prepend_receiver { + quote! { #invoke(this, #(#call_args),*) } + } else { + quote! { #invoke(#(#call_args),*) } + }; + let body = if method.returns_result { if has_return { quote! { - match this.#fn_name(#(#call_args),*) { + match #invoke_call { Ok(result) => { #encode_and_return } Err(e) => { #revert_err @@ -257,7 +320,7 @@ pub fn generate_dispatch_arm( } } else { quote! { - match this.#fn_name(#(#call_args),*) { + match #invoke_call { Ok(()) => { <::pvm_contract_sdk::Host as ::pvm_contract_sdk::HostApi>::return_value( this.host(), @@ -275,12 +338,12 @@ pub fn generate_dispatch_arm( } } else if has_return { quote! { - let result = this.#fn_name(#(#call_args),*); + let result = #invoke_call; #encode_and_return } } else { quote! { - this.#fn_name(#(#call_args),*); + #invoke_call; <::pvm_contract_sdk::Host as ::pvm_contract_sdk::HostApi>::return_value( this.host(), ::pvm_contract_sdk::ReturnFlags::empty(), @@ -309,7 +372,7 @@ pub struct RouteItems { pub route_fn: TokenStream, } -/// `impl Router for mod_name::StructName` block, placed outside the module. +/// `impl Router for mod_name::StructName` block, placed outside the module. pub struct RouterImpl { pub tokens: TokenStream, } @@ -344,7 +407,7 @@ pub fn generate_router( let (selector_consts, dispatch_arms): (Vec<_>, Vec<_>) = methods .iter() - .map(|m| generate_dispatch_arm(m, use_alloc, all_non_payable)) + .map(|m| generate_dispatch_arm(m, struct_name, use_alloc, all_non_payable)) .unzip(); let prelude = if all_non_payable { @@ -380,9 +443,7 @@ pub fn generate_router( let router_impl = RouterImpl { tokens: quote! { - impl ::pvm_contract_sdk::Router<::pvm_contract_sdk::Host> - for #mod_name::#struct_name - { + impl ::pvm_contract_sdk::Router for #mod_name::#struct_name { fn route( &mut self, selector: [u8; 4], @@ -534,13 +595,15 @@ mod tests { returns_result: false, mutability, precomputed_selector: Some([0xde, 0xad, 0xbe, 0xef]), + trait_path: None, } } #[test] fn non_payable_arm_emits_value_zero_assert() { let m = sample_method("transfer", StateMutability::NonPayable); - let (_, arm) = generate_dispatch_arm(&m, false, false); + let struct_name: syn::Ident = syn::parse_quote!(Contract); + let (_, arm) = generate_dispatch_arm(&m, &struct_name, false, false); let expected = expect_test::expect![[r#" fn __w(selector: [u8; 4], input: &[u8], this: &mut Contract) { match selector { @@ -582,7 +645,8 @@ mod tests { #[test] fn payable_arm_omits_value_zero_assert() { let m = sample_method("deposit", StateMutability::Payable); - let (_, arm) = generate_dispatch_arm(&m, false, false); + let struct_name: syn::Ident = syn::parse_quote!(Contract); + let (_, arm) = generate_dispatch_arm(&m, &struct_name, false, false); let expected = expect_test::expect![[r#" fn __w(selector: [u8; 4], input: &[u8], this: &mut Contract) { match selector { @@ -623,7 +687,8 @@ mod tests { #[test] fn hoisted_non_payable_arm_omits_value_zero_assert() { let m = sample_method("transfer", StateMutability::NonPayable); - let (_, arm) = generate_dispatch_arm(&m, false, true); + let struct_name: syn::Ident = syn::parse_quote!(Contract); + let (_, arm) = generate_dispatch_arm(&m, &struct_name, false, true); let expected = expect_test::expect![[r#" fn __w(selector: [u8; 4], input: &[u8], this: &mut Contract) { match selector { diff --git a/crates/pvm-contract-macros/src/codegen/interface_id.rs b/crates/pvm-contract-macros/src/codegen/interface_id.rs new file mode 100644 index 00000000..81c171f3 --- /dev/null +++ b/crates/pvm-contract-macros/src/codegen/interface_id.rs @@ -0,0 +1,379 @@ +//! `#[interface_id]` attribute macro: adds an `interface_id() -> [u8; 4]` +//! provided method to a trait. +//! +//! The interface ID matches Solidity's ERC-165 convention: the XOR of the +//! function selectors (first 4 bytes of `keccak256(canonical_signature)`) of +//! every method declared on the trait. +//! +//! Each method's signature is built at compile time from the Rust parameter +//! types via their [`SolEncode::SOL_NAME`] constants and the method's name in +//! camelCase. The resulting `interface_id()` body is `const`-friendly: every +//! per-method selector is computed in a `const` block; only the final XOR +//! reduction happens at runtime, which is negligible because `interface_id()` +//! is itself rarely called (only from `supportsInterface`). +//! +//! # Example +//! +//! ```ignore +//! #[pvm_contract_sdk::interface_id] +//! pub trait IErc20 { +//! fn total_supply(&self) -> U256; +//! fn balance_of(&self, account: Address) -> U256; +//! fn transfer(&mut self, to: Address, value: U256) -> Result; +//! } +//! ``` +//! +//! generates (roughly): +//! +//! ```ignore +//! pub trait IErc20 { +//! fn total_supply(&self) -> U256; +//! fn balance_of(&self, account: Address) -> U256; +//! fn transfer(&mut self, to: Address, value: U256) -> Result; +//! +//! fn interface_id() -> [u8; 4] where Self: Sized { +//! let mut id = [0u8; 4]; +//! const SEL_0: [u8; 4] = ::pvm_contract_sdk::const_selector( +//! ::pvm_contract_sdk::const_format::concatcp!("totalSupply", "(", ")") +//! ); +//! id[0] ^= SEL_0[0]; id[1] ^= SEL_0[1]; id[2] ^= SEL_0[2]; id[3] ^= SEL_0[3]; +//! /* ...one block per method... */ +//! id +//! } +//! } +//! ``` +//! +//! # Method names and renames +//! +//! Rust `snake_case` names are converted to Solidity `camelCase` (e.g. +//! `balance_of` → `balanceOf`). Explicit renames are supported via +//! `#[selector(name = "exactSolidityName")]` on individual trait methods. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{FnArg, ItemTrait, LitStr, Token, TraitItem}; + +use crate::utils::to_camel_case; + +/// Top-level entry point. Validates the trait has at least one method and +/// generates the augmented trait with `interface_id()` appended. +pub fn expand_interface_id(input: ItemTrait) -> syn::Result { + let methods: Vec<&syn::TraitItemFn> = input + .items + .iter() + .filter_map(|item| match item { + TraitItem::Fn(f) => Some(f), + _ => None, + }) + .collect(); + + if methods.is_empty() { + return Err(syn::Error::new_spanned( + &input.ident, + "#[interface_id] requires at least one method on the trait", + )); + } + + // Build a (selector_const_ident, selector_const_decl) pair per method. + let mut method_blocks: Vec = Vec::with_capacity(methods.len()); + let mut xor_lines: Vec = Vec::with_capacity(methods.len()); + + for (i, func) in methods.iter().enumerate() { + // Determine the Solidity name: explicit `#[selector(name = "...")]` + // wins; otherwise convert snake_case → camelCase. + let solidity_name = extract_selector_rename(&func.attrs)? + .unwrap_or_else(|| to_camel_case(&func.sig.ident.to_string())); + + // Collect typed parameters (skip `&self`/`&mut self`/`self`). + let param_types: Vec<&syn::Type> = func + .sig + .inputs + .iter() + .filter_map(|arg| match arg { + FnArg::Typed(pat) => Some(&*pat.ty), + FnArg::Receiver(_) => None, + }) + .collect(); + + // Build the canonical signature as a const concatenation: + // "(" + + "," + + ... + ")" + let sig_concat = build_signature_concat(&solidity_name, ¶m_types); + + let const_ident = format_ident!("__pvm_interface_id_sel_{}", i); + method_blocks.push(quote! { + #[allow(non_upper_case_globals)] + const #const_ident: [u8; 4] = ::pvm_contract_sdk::const_selector(#sig_concat); + }); + xor_lines.push(quote! { + id[0] ^= #const_ident[0]; + id[1] ^= #const_ident[1]; + id[2] ^= #const_ident[2]; + id[3] ^= #const_ident[3]; + }); + } + + // Strip our `#[selector(name = "...")]` attributes from the trait items + // before re-emitting; they are processed here and not by rustc. + let mut output_trait = input.clone(); + for item in output_trait.items.iter_mut() { + if let TraitItem::Fn(f) = item { + f.attrs.retain(|a| !a.path().is_ident("selector")); + } + } + + // Append the interface_id() provided method. + let trait_attrs = &output_trait.attrs; + let trait_vis = &output_trait.vis; + let trait_unsafety = &output_trait.unsafety; + let trait_ident = &output_trait.ident; + let (impl_generics, _ty_generics, where_clause) = output_trait.generics.split_for_impl(); + let _ = impl_generics; // generics are already attached to the trait token below + let supertraits = if output_trait.supertraits.is_empty() { + quote! {} + } else { + let st = &output_trait.supertraits; + quote! { : #st } + }; + let generics = &output_trait.generics; + let trait_items = &output_trait.items; + + Ok(quote! { + #(#trait_attrs)* + #trait_vis #trait_unsafety trait #trait_ident #generics #supertraits #where_clause { + #(#trait_items)* + + #[doc = concat!( + "ERC-165 interface ID for `", + stringify!(#trait_ident), + "`. Computed as the XOR of the function selectors of every \ + method declared on this trait." + )] + fn interface_id() -> [u8; 4] + where + Self: Sized, + { + #(#method_blocks)* + let mut id = [0u8; 4]; + #(#xor_lines)* + id + } + } + }) +} + +/// Build a const expression of type `&'static str` representing the canonical +/// Solidity signature: `name(type1,type2,...)` where `typeN` is each parameter +/// type's `::SOL_NAME`. +/// +/// We splice `SolEncode::SOL_NAME` through `const_format::concatcp!` so the +/// full signature is concatenated at compile time. The result is then fed to +/// `const_selector` which is itself a `const fn`. +fn build_signature_concat(name: &str, param_types: &[&syn::Type]) -> TokenStream { + if param_types.is_empty() { + let suffix = format!("{}()", name); + return quote! { #suffix }; + } + + let name_open = format!("{}(", name); + let close = ")"; + + // Build comma-separated `::SOL_NAME` expressions. + let mut pieces: Vec = Vec::with_capacity(param_types.len() * 2 + 2); + pieces.push(quote! { #name_open }); + for (i, ty) in param_types.iter().enumerate() { + if i > 0 { + pieces.push(quote! { "," }); + } + pieces.push(quote! { <#ty as ::pvm_contract_sdk::SolEncode>::SOL_NAME }); + } + pieces.push(quote! { #close }); + + quote! { + ::pvm_contract_sdk::const_format::concatcp!(#(#pieces),*) + } +} + +/// Parses `#[selector(name = "myFunctionName")]`. +struct SelectorArgs { + name: String, +} + +impl syn::parse::Parse for SelectorArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let ident: syn::Ident = input.parse()?; + if ident != "name" { + return Err(syn::Error::new( + ident.span(), + "expected `name = \"...\"` inside `#[selector(...)]`", + )); + } + let _: Token![=] = input.parse()?; + let lit: LitStr = input.parse()?; + Ok(SelectorArgs { name: lit.value() }) + } +} + +fn extract_selector_rename(attrs: &[syn::Attribute]) -> syn::Result> { + let mut found: Option = None; + for attr in attrs { + if !attr.path().is_ident("selector") { + continue; + } + if found.is_some() { + return Err(syn::Error::new_spanned( + attr, + "duplicate `#[selector(...)]` attribute; only one is allowed per method", + )); + } + let args: SelectorArgs = attr.parse_args()?; + if args.name.is_empty() { + return Err(syn::Error::new_spanned( + attr, + "`#[selector(name = \"\")]`: name must be a non-empty string", + )); + } + found = Some(args.name); + } + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_trait(src: &str) -> ItemTrait { + syn::parse_str(src).expect("trait parses") + } + + #[test] + fn empty_trait_rejected() { + let t = parse_trait("pub trait Empty {}"); + let err = expand_interface_id(t).unwrap_err().to_string(); + assert!( + err.contains("at least one method"), + "Expected empty-trait rejection. Got: {err}" + ); + } + + #[test] + fn camel_case_conversion_for_method_names() { + let t = parse_trait( + r#" + pub trait IErc20 { + fn balance_of(&self, account: Address) -> U256; + } + "#, + ); + let output = expand_interface_id(t).unwrap().to_string(); + + // The canonical signature should be built with "balanceOf(", not + // "balance_of(". + assert!( + output.contains("\"balanceOf(\""), + "Generated code should use camelCase method names.\nGot: {output}" + ); + } + + #[test] + fn nullary_method_omits_param_concat() { + let t = parse_trait( + r#" + pub trait Trivial { + fn ping(&self) -> bool; + } + "#, + ); + let output = expand_interface_id(t).unwrap().to_string(); + // A method with no parameters is encoded as a single string literal, + // not a `concatcp!` invocation. + assert!( + output.contains("const_selector (\"ping()\")"), + "Nullary methods should use a literal signature.\nGot: {output}" + ); + } + + #[test] + fn parametric_method_uses_const_format_concat() { + let t = parse_trait( + r#" + pub trait IErc20 { + fn transfer(&mut self, to: Address, value: U256) -> Result; + } + "#, + ); + let output = expand_interface_id(t).unwrap().to_string(); + // We expect const_format::concatcp! to splice in SOL_NAME for each + // parameter type. + assert!( + output.contains("const_format :: concatcp !"), + "Parametric methods should use concatcp! for the signature.\nGot: {output}" + ); + assert!( + output.contains("< Address as :: pvm_contract_sdk :: SolEncode > :: SOL_NAME"), + "Generated signature should reference SolEncode::SOL_NAME for each param.\nGot: {output}" + ); + } + + #[test] + fn selector_rename_attribute() { + let t = parse_trait( + r#" + pub trait Renamed { + #[selector(name = "customName")] + fn original_name(&self) -> bool; + } + "#, + ); + let output = expand_interface_id(t).unwrap().to_string(); + assert!( + output.contains("const_selector (\"customName()\")"), + "Selector rename should override the camelCase default.\nGot: {output}" + ); + // The `#[selector(...)]` attribute should be stripped from the + // output (rustc has no idea what it means). + assert!( + !output.contains("# [selector"), + "#[selector] attribute should be stripped from the emitted trait.\nGot: {output}" + ); + } + + #[test] + fn xor_count_matches_method_count() { + let t = parse_trait( + r#" + pub trait Three { + fn a(&self); + fn b(&self); + fn c(&self); + } + "#, + ); + let output = expand_interface_id(t).unwrap().to_string(); + // Three methods → three XOR-accumulate blocks. + let xor_count = output.matches("id [0] ^=").count(); + assert_eq!( + xor_count, 3, + "Expected one XOR-accumulate per method.\nGot: {output}" + ); + } + + #[test] + fn appends_interface_id_method() { + let t = parse_trait( + r#" + pub trait HasOne { + fn a(&self); + } + "#, + ); + let output = expand_interface_id(t).unwrap().to_string(); + assert!( + output.contains("fn interface_id ()"), + "Trait should gain an `interface_id()` provided method.\nGot: {output}" + ); + assert!( + output.contains("Self : Sized"), + "`interface_id()` should require `Self: Sized` so dyn traits don't break.\nGot: {output}" + ); + } +} diff --git a/crates/pvm-contract-macros/src/codegen/mod.rs b/crates/pvm-contract-macros/src/codegen/mod.rs index 1e385e43..a829d8fc 100644 --- a/crates/pvm-contract-macros/src/codegen/mod.rs +++ b/crates/pvm-contract-macros/src/codegen/mod.rs @@ -2,12 +2,16 @@ mod abi_gen; mod contract; mod decode; mod dispatch; +mod interface_id; mod method; mod sol_error; mod sol_storage; mod sol_type; +mod storage_struct; pub use contract::{ContractArgs, expand_contract}; +pub use interface_id::expand_interface_id; pub use method::{MethodArgs, expand_constructor, expand_fallback, expand_method}; pub use sol_error::expand_sol_error; pub use sol_type::expand_sol_type; +pub use storage_struct::expand_storage_struct; diff --git a/crates/pvm-contract-macros/src/codegen/sol_storage.rs b/crates/pvm-contract-macros/src/codegen/sol_storage.rs index 00350697..702503f6 100644 --- a/crates/pvm-contract-macros/src/codegen/sol_storage.rs +++ b/crates/pvm-contract-macros/src/codegen/sol_storage.rs @@ -3,13 +3,25 @@ use quote::quote; /// Generate the TokenStream that constructs a `StorageLayoutEntry` for one field. /// +/// `slot_expr` is a const-evaluable `u64` expression — either a literal (for +/// explicit `#[slot(N)]`) or a reference to a chained `__pvm_storage_slot_*` +/// const (for auto-numbered fields). +/// /// Used by the `#[contract]` slot-field layout generation in `abi_gen.rs`. -pub(super) fn generate_layout_entry(name_str: &str, ty: &syn::Type, slot: u64) -> TokenStream { - let slot_str = format!("{}", slot); +pub(super) fn generate_layout_entry( + name_str: &str, + ty: &syn::Type, + slot_expr: TokenStream, +) -> TokenStream { quote! { ::pvm_contract_sdk::StorageLayoutEntry { label: ::std::string::String::from(#name_str), - slot: ::std::string::String::from(#slot_str), + slot: { + // The slot expr is `u64`. Stringify it at runtime since auto-numbered + // slots are only known after const evaluation of the chain. + let slot_value: u64 = #slot_expr; + ::std::format!("{}", slot_value) + }, ty: <#ty as ::pvm_contract_sdk::StorageLayoutType>::sol_type_name(), } } diff --git a/crates/pvm-contract-macros/src/codegen/storage_struct.rs b/crates/pvm-contract-macros/src/codegen/storage_struct.rs new file mode 100644 index 00000000..fb2aa468 --- /dev/null +++ b/crates/pvm-contract-macros/src/codegen/storage_struct.rs @@ -0,0 +1,272 @@ +//! `#[storage]` attribute macro: derive [`StorageComponent`] for a user struct +//! whose fields are themselves storage components (typically `Lazy` and +//! `Mapping`, but also nested `#[storage]` structs). +//! +//! Generated code is a thin shell over the same auto-numbering const chain +//! used by `#[contract]`: +//! +//! ```ignore +//! #[pvm_contract_sdk::storage] +//! pub struct Erc20 { +//! total_supply: Lazy, +//! balances: Mapping, +//! allowances: Mapping>, +//! } +//! ``` +//! +//! expands (roughly) to: +//! +//! ```ignore +//! pub struct Erc20 { +//! total_supply: Lazy, +//! balances: Mapping, +//! allowances: Mapping>, +//! } +//! +//! impl ::pvm_contract_sdk::StorageComponent for Erc20 { +//! const SLOTS: u64 = +//! as StorageComponent>::SLOTS +//! + as StorageComponent>::SLOTS +//! + > as StorageComponent>::SLOTS; +//! +//! fn new_at(base: u64, host: ::pvm_contract_sdk::Host) -> Self { +//! const __OFF_total_supply: u64 = 0; +//! const __OFF_balances: u64 = +//! __OFF_total_supply + as StorageComponent>::SLOTS; +//! const __OFF_allowances: u64 = +//! __OFF_balances + as StorageComponent>::SLOTS; +//! Erc20 { +//! total_supply: as StorageComponent>::new_at( +//! base + __OFF_total_supply, host.clone()), +//! balances: <_ as StorageComponent>::new_at( +//! base + __OFF_balances, host.clone()), +//! allowances: <_ as StorageComponent>::new_at( +//! base + __OFF_allowances, host), +//! } +//! } +//! } +//! ``` +//! +//! Notes: +//! - Tuple and unit structs are rejected; only named-field structs make sense +//! here because slot ordering is meaningful. +//! - `#[storage]` does NOT yet support pinning individual offsets via +//! `#[slot(N)]`. The expectation is that embedded storage structs are +//! declared in their natural field order; if the user wants a specific +//! layout they can declare the leaf fields directly on the contract struct. +//! - The macro must be placed *before* any field uses the type, but doesn't +//! need to be in the same module — the generated trait impl lives next to +//! the struct, so it's visible wherever the struct is. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Fields, ItemStruct}; + +pub fn expand_storage_struct(input: ItemStruct) -> syn::Result { + let struct_name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let named = match &input.fields { + Fields::Named(named) => named, + Fields::Unit => { + return Err(syn::Error::new_spanned( + &input, + "#[storage] requires a struct with named fields. Unit and tuple structs are not supported.", + )); + } + Fields::Unnamed(_) => { + return Err(syn::Error::new_spanned( + &input, + "#[storage] requires a struct with named fields. Tuple structs are not supported.", + )); + } + }; + + if named.named.is_empty() { + return Err(syn::Error::new_spanned( + &input, + "#[storage] requires at least one storage field.", + )); + } + + let field_names: Vec<&syn::Ident> = named + .named + .iter() + .map(|f| f.ident.as_ref().expect("named fields")) + .collect(); + let field_types: Vec<&syn::Type> = named.named.iter().map(|f| &f.ty).collect(); + let field_cfgs: Vec> = named + .named + .iter() + .map(|f| f.attrs.iter().filter(|a| a.path().is_ident("cfg")).collect()) + .collect(); + + // The SLOTS const sums every field's contribution. + let slot_terms: Vec = field_types + .iter() + .map(|ty| quote! { <#ty as ::pvm_contract_sdk::StorageComponent>::SLOTS }) + .collect(); + let slots_expr = if slot_terms.is_empty() { + quote! { 0u64 } + } else { + quote! { #(#slot_terms)+* } + }; + + // Per-field offset const chain (relative to base). + let offset_consts: Vec = field_names + .iter() + .enumerate() + .map(|(i, name)| { + let const_ident = format_ident!("__pvm_storage_offset_{}", name); + let cfgs = &field_cfgs[i]; + if i == 0 { + quote! { + #(#cfgs)* + #[allow(non_upper_case_globals)] + const #const_ident: u64 = 0; + } + } else { + let prev_name = field_names[i - 1]; + let prev_ty = field_types[i - 1]; + let prev_const = format_ident!("__pvm_storage_offset_{}", prev_name); + quote! { + #(#cfgs)* + #[allow(non_upper_case_globals)] + const #const_ident: u64 = #prev_const + + <#prev_ty as ::pvm_contract_sdk::StorageComponent>::SLOTS; + } + } + }) + .collect(); + + let field_inits: Vec = field_names + .iter() + .enumerate() + .map(|(i, name)| { + let ty = field_types[i]; + let cfgs = &field_cfgs[i]; + let const_ident = format_ident!("__pvm_storage_offset_{}", name); + quote! { + #(#cfgs)* + #name: <#ty as ::pvm_contract_sdk::StorageComponent>::new_at( + base + #const_ident, + host.clone(), + ) + } + }) + .collect(); + + // The user's struct, unchanged. + let user_struct = &input; + + Ok(quote! { + #user_struct + + impl #impl_generics ::pvm_contract_sdk::StorageComponent + for #struct_name #ty_generics + #where_clause + { + const SLOTS: u64 = #slots_expr; + + fn new_at(base: u64, host: ::pvm_contract_sdk::Host) -> Self { + #(#offset_consts)* + #struct_name { + #(#field_inits),* + } + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(src: &str) -> ItemStruct { + syn::parse_str(src).expect("input parses") + } + + #[test] + fn generates_storage_component_impl() { + let input = parse( + r#" + pub struct Erc20 { + total_supply: Lazy, + balances: Mapping, + } + "#, + ); + let output = expand_storage_struct(input).unwrap().to_string(); + + // The original struct is preserved. + assert!( + output.contains("pub struct Erc20"), + "struct should be preserved: {output}" + ); + + // SLOTS sums each field's SLOTS. + assert!( + output.contains("const SLOTS : u64 = < Lazy < U256 > as :: pvm_contract_sdk :: StorageComponent > :: SLOTS + < Mapping < Address , U256 > as :: pvm_contract_sdk :: StorageComponent > :: SLOTS"), + "SLOTS const should sum field SLOTS. Got: {output}" + ); + + // First field's offset is 0. + assert!( + output.contains("const __pvm_storage_offset_total_supply : u64 = 0 ;"), + "first offset should be 0: {output}" + ); + + // Each field's slot const is base + offset. + assert!( + output.contains("base + __pvm_storage_offset_total_supply"), + "field init should reference its offset const: {output}" + ); + } + + #[test] + fn rejects_tuple_struct() { + let input = parse("pub struct T(u32, u32);"); + let err = expand_storage_struct(input).unwrap_err().to_string(); + assert!( + err.contains("Tuple structs are not supported"), + "Got: {err}" + ); + } + + #[test] + fn rejects_unit_struct() { + let input = parse("pub struct U;"); + let err = expand_storage_struct(input).unwrap_err().to_string(); + assert!( + err.contains("Unit and tuple structs are not supported"), + "Got: {err}" + ); + } + + #[test] + fn rejects_empty_named_struct() { + let input = parse("pub struct E {}"); + let err = expand_storage_struct(input).unwrap_err().to_string(); + assert!( + err.contains("at least one storage field"), + "Got: {err}" + ); + } + + #[test] + fn supports_generics() { + let input = parse( + r#" + pub struct Container { + value: Lazy, + } + "#, + ); + let output = expand_storage_struct(input).unwrap().to_string(); + // The impl picks up the generics. + assert!( + output.contains("impl < T > :: pvm_contract_sdk :: StorageComponent for Container < T >"), + "should propagate generics: {output}" + ); + } +} diff --git a/crates/pvm-contract-macros/src/lib.rs b/crates/pvm-contract-macros/src/lib.rs index 861b677e..ce20a1c8 100644 --- a/crates/pvm-contract-macros/src/lib.rs +++ b/crates/pvm-contract-macros/src/lib.rs @@ -126,12 +126,10 @@ use syn::{DeriveInput, ItemFn, ItemMod, parse_macro_input}; /// reads calldata, calls `route()`, falls through to fallback or /// `return_value(REVERT, UNKNOWN_SELECTOR)` when `route()` returns `None`. /// -/// Outside the module, a `Router` trait impl is generated: +/// Outside the module, a `Router` trait impl is generated: /// /// ```ignore -/// impl ::pvm_contract_sdk::Router<::pvm_contract_sdk::Host> -/// for my_token::Contract -/// { +/// impl ::pvm_contract_sdk::Router for my_token::Contract { /// fn route( /// &mut self, /// selector: [u8; 4], @@ -395,9 +393,7 @@ use syn::{DeriveInput, ItemFn, ItemMod, parse_macro_input}; /// } /// /// // Generated outside the module: -/// impl ::pvm_contract_sdk::Router<::pvm_contract_sdk::Host> -/// for my_token::Contract -/// { +/// impl ::pvm_contract_sdk::Router for my_token::Contract { /// fn route( /// &mut self, /// selector: [u8; 4], @@ -498,6 +494,87 @@ pub fn contract(attr: TokenStream, item: TokenStream) -> TokenStream { } } +/// Derives [`StorageComponent`] for a struct so it can be embedded as a field +/// inside another `#[storage]` struct or directly inside the `#[contract]` +/// storage struct. +/// +/// Field slots are auto-numbered in declaration order; the embedded struct's +/// `SLOTS` is the sum of its fields' `SLOTS`. The contract struct's +/// auto-numbering uses these `SLOTS` constants to assign contiguous ranges, +/// so embedding nests cleanly without manual slot math. +/// +/// # Example +/// +/// ```ignore +/// #[pvm_contract_sdk::storage] +/// pub struct Erc20 { +/// total_supply: Lazy, +/// balances: Mapping, +/// allowances: Mapping>, +/// } +/// +/// #[pvm_contract_sdk::contract] +/// mod my_contract { +/// pub struct MyContract { +/// erc20: super::Erc20, // claims 3 slots +/// additional_state: Lazy, // claims slot 3 +/// } +/// } +/// ``` +/// +/// # Constraints +/// +/// - Only named-field structs are supported (unit/tuple structs rejected). +/// - All fields must implement `StorageComponent` (which `Lazy`/`Mapping` and +/// other `#[storage]` structs do). +/// - `#[slot(N)]` pinning inside a `#[storage]` struct is *not* supported. +/// Use auto-numbering, or write the leaf fields directly on the contract +/// struct if you need explicit slots. +#[proc_macro_attribute] +pub fn storage(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as syn::ItemStruct); + match codegen::expand_storage_struct(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +/// Adds an `interface_id() -> [u8; 4]` provided method to a trait, matching +/// the ERC-165 convention (XOR of function selectors). +/// +/// Each trait method's selector is computed from its Rust signature: the +/// method name is converted from `snake_case` to `camelCase` (or overridden +/// via `#[selector(name = "...")]`), parameter types are taken from each +/// argument's `::SOL_NAME`, and the canonical Solidity +/// signature is fed to `const_selector` for the first 4 keccak bytes. +/// +/// # Example +/// +/// ```ignore +/// #[pvm_contract_sdk::interface_id] +/// pub trait IErc20 { +/// fn total_supply(&self) -> U256; +/// fn balance_of(&self, account: Address) -> U256; +/// fn transfer(&mut self, to: Address, value: U256) -> Result; +/// } +/// +/// // Use it from a contract: +/// let id: [u8; 4] = ::interface_id(); +/// ``` +/// +/// # Renaming methods +/// +/// Use `#[selector(name = "exactSolidityName")]` to override the camelCase +/// default for a single method. +#[proc_macro_attribute] +pub fn interface_id(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as syn::ItemTrait); + match codegen::expand_interface_id(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + /// Marks a function as a contract method. The signature is derived from the Solidity interface file. /// /// # Attributes diff --git a/crates/pvm-contract-macros/src/utils.rs b/crates/pvm-contract-macros/src/utils.rs index 988f430d..7774fae9 100644 --- a/crates/pvm-contract-macros/src/utils.rs +++ b/crates/pvm-contract-macros/src/utils.rs @@ -49,3 +49,26 @@ pub fn compute_function_signature(item: &ItemFunction) -> String { } name } + +/// Convert `snake_case_name` -> `snakeCaseName` (Solidity convention for fn names). +/// +/// The first segment stays lowercase; subsequent underscore-separated segments +/// get their first letter capitalised. Matches the `to_camel_case` helper that +/// was originally private to `codegen/contract.rs`. +pub fn to_camel_case(snake: &str) -> String { + let mut result = String::new(); + let mut next_upper = false; + for (i, c) in snake.chars().enumerate() { + if c == '_' { + next_upper = true; + } else if i == 0 { + result.push(c); + } else if next_upper { + result.push(c.to_ascii_uppercase()); + next_upper = false; + } else { + result.push(c); + } + } + result +} diff --git a/crates/pvm-contract-macros/tests/abi_output.rs b/crates/pvm-contract-macros/tests/abi_output.rs index f04bb0e6..b5c2da8f 100644 --- a/crates/pvm-contract-macros/tests/abi_output.rs +++ b/crates/pvm-contract-macros/tests/abi_output.rs @@ -45,6 +45,12 @@ fn constructor_no_params_produces_valid_abi() { .assert_eq(&cargo_run_abi("constructor-no-params")); } +#[test] +fn constructor_payable_produces_valid_abi() { + expect_test::expect_file!("./test_abi_contract/abi_constructor_payable.json") + .assert_eq(&cargo_run_abi("constructor-payable")); +} + #[test] fn custom_type_method_produces_valid_abi() { expect_test::expect_file!("./test_abi_contract/abi_custom_type_method.json") diff --git a/crates/pvm-contract-macros/tests/inheritance_oz_pattern.rs b/crates/pvm-contract-macros/tests/inheritance_oz_pattern.rs new file mode 100644 index 00000000..52e6bab6 --- /dev/null +++ b/crates/pvm-contract-macros/tests/inheritance_oz_pattern.rs @@ -0,0 +1,619 @@ +#![cfg(not(feature = "abi-gen"))] +//! End-to-end example: OpenZeppelin-style contract composition. +//! +//! This file is the canonical "how to use it" reference for the inheritance +//! features. It demonstrates all five pieces composing together: +//! +//! 1. `#[storage]` — extension storage structs (`Erc20State`, `OwnableState`) +//! each claim a slot range computed from `StorageComponent::SLOTS`. +//! 2. Auto-numbered slots — the outer `#[contract]` struct embeds those +//! extensions as plain fields; the macro assigns slot ranges by summing +//! each field's `SLOTS`. +//! 3. `#[interface_id]` — `IErc20` and `IOwnable` traits each gain an +//! `interface_id() -> [u8; 4]` provided method (ERC-165 compatible). +//! 4. `implements(...)` — the contract declares the trait set in its +//! `#[contract]` attribute, and the macro dispatches every trait method +//! automatically. +//! 5. Multi-`impl` dispatch — the contract has multiple `impl` blocks: +//! - one inherent `impl MyToken` for the constructor + custom methods +//! - one `impl IErc20 for MyToken` (with one method **overridden** — +//! `transfer` adds an owner check on top of the extension helper) +//! - one `impl IOwnable for MyToken` +//! +//! The dispatch table folds methods from every block into a single selector +//! switch. Overrides "just work" because the contract's own `impl IErc20` +//! body is what runs — there's no `virtual`/`override` keyword. +//! +//! The patterns shown here are exactly what an `openzeppelin-pvm` library +//! would use to expose `Erc20`, `Ownable`, etc. as reusable extensions. + +use pvm_contract_sdk::{Address, Lazy, Mapping, U256}; +use pvm_contract_types::{ + Host, HostApi, MockHost, MockHostBuilder, ReturnFlags, SolEncode, StaticEncodedLen, +}; + +extern crate alloc; + +// --------------------------------------------------------------------------- +// Errors +// +// `#[derive(SolError)]` produces the per-error selector. The two errors below +// are surfaced as Solidity-style typed reverts (`InsufficientBalance(...)`, +// `Unauthorized()`) so callers can decode them just like solc-emitted custom +// errors. +// --------------------------------------------------------------------------- + +#[derive(Debug, pvm_contract_sdk::SolError)] +pub struct InsufficientBalance { + pub available: U256, + pub required: U256, +} + +#[derive(Debug, pvm_contract_sdk::SolError)] +pub struct Unauthorized; + +// --------------------------------------------------------------------------- +// Extensions +// +// Each extension is a `#[storage]` struct exposing `pub fn _internal(...)` +// helpers. These helpers are NOT `#[method]`-annotated and are NOT dispatched +// themselves — they're library functions called from the outer contract's +// `impl ITrait for Contract` blocks. +// +// The leading underscore is a convention borrowed from Solidity for internal +// helpers; it's not enforced by the SDK. +// --------------------------------------------------------------------------- + +#[pvm_contract_sdk::storage] +pub struct Erc20State { + /// Total token supply at slot offset 0 (relative to the extension's base + /// slot inside the outer contract). + pub total_supply: Lazy, + /// Per-address balances at slot offset 1. + pub balances: Mapping, +} + +impl Erc20State { + /// Read the balance for `account`. Maps to the Solidity `balanceOf` view. + pub fn _balance_of(&self, account: Address) -> U256 { + self.balances.get(&account) + } + + /// Read total supply. + pub fn _total_supply(&self) -> U256 { + self.total_supply.get() + } + + /// Credit `value` to `to`. Used by both `_mint` and `_transfer`. + fn _credit(&mut self, to: Address, value: U256) { + let cur = self.balances.get(&to); + self.balances.insert(&to, &(cur + value)); + } + + /// Mint new tokens — increases total supply and credits `to`. + pub fn _mint(&mut self, to: Address, value: U256) { + let supply = self.total_supply.get() + value; + self.total_supply.set(&supply); + self._credit(to, value); + } + + /// Move `value` from `from` to `to`. Reverts with `InsufficientBalance` + /// if the sender doesn't have enough. + pub fn _transfer( + &mut self, + from: Address, + to: Address, + value: U256, + ) -> Result<(), InsufficientBalance> { + let available = self.balances.get(&from); + if available < value { + return Err(InsufficientBalance { + available, + required: value, + }); + } + self.balances.insert(&from, &(available - value)); + self._credit(to, value); + Ok(()) + } +} + +#[pvm_contract_sdk::storage] +pub struct OwnableState { + /// Single-slot owner address. + pub owner: Lazy
, +} + +impl OwnableState { + pub fn _owner(&self) -> Address { + self.owner.get() + } + + pub fn _set_owner(&mut self, new_owner: Address) { + self.owner.set(&new_owner); + } + + /// Guard helper: returns `Err(Unauthorized)` if `caller` is not the owner. + pub fn _check_owner(&self, caller: Address) -> Result<(), Unauthorized> { + if caller != self.owner.get() { + return Err(Unauthorized); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Interfaces +// +// `#[interface_id]` adds `interface_id() -> [u8; 4]` to each trait, computed +// as the XOR of method selectors (ERC-165 convention). +// +// Both `IErc20` and `IOwnable` carry an associated `Error` type so the same +// trait can be implemented over different error enums — `MyToken` will bind +// `Error = MyTokenError` for both. +// --------------------------------------------------------------------------- + +#[pvm_contract_sdk::interface_id] +pub trait IErc20 { + type Error; + + fn total_supply(&self) -> U256; + fn balance_of(&self, account: Address) -> U256; + fn transfer(&mut self, to: Address, value: U256) -> Result<(), Self::Error>; +} + +#[pvm_contract_sdk::interface_id] +pub trait IOwnable { + type Error; + + fn owner(&self) -> Address; + fn transfer_ownership(&mut self, new_owner: Address) -> Result<(), Self::Error>; +} + +// --------------------------------------------------------------------------- +// Combined contract error +// +// `MyToken` unifies the two extension errors into a single enum so all +// `Result<_, MyTokenError>` arms share a return type. `From` +// impls let `?` bubble extension errors up through the contract methods. +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum MyTokenError { + Insufficient(InsufficientBalance), + Unauthorized(Unauthorized), +} + +impl From for MyTokenError { + fn from(e: InsufficientBalance) -> Self { + Self::Insufficient(e) + } +} + +impl From for MyTokenError { + fn from(e: Unauthorized) -> Self { + Self::Unauthorized(e) + } +} + +// Manual `SolRevert` impl so the dispatch layer can encode the typed revert +// data. We delegate to each variant's own `SolError` implementation. +impl pvm_contract_sdk::SolRevert for MyTokenError { + fn revert_data(&self, buf: &mut [u8]) -> usize { + match self { + Self::Insufficient(e) => { + ::revert_data(e, buf) + } + Self::Unauthorized(e) => { + ::revert_data(e, buf) + } + } + } + + fn revert_data_len(&self) -> usize { + match self { + Self::Insufficient(e) => { + ::revert_data_len(e) + } + Self::Unauthorized(e) => { + ::revert_data_len(e) + } + } + } + + fn error_signatures() -> impl Iterator { + // Order matters for ABI export only; runtime dispatch doesn't care. + [ + &::SIGNATURE, + &::SIGNATURE, + ] + .into_iter() + } +} + +// --------------------------------------------------------------------------- +// The contract +// +// Two extensions embedded as plain fields. Auto-numbered slots: +// - erc20.total_supply → slot 0 +// - erc20.balances → slot 1 +// - ownable.owner → slot 2 +// +// `implements(...)` lists every trait whose `impl ... for MyToken` block +// participates in dispatch. The trait order doesn't matter; the macro folds +// every block into one selector switch. +// --------------------------------------------------------------------------- + +#[allow(dead_code)] // deploy()/call() are riscv64-gated; tests poke route(). +#[pvm_contract_sdk::contract(implements( + IErc20, + IOwnable, +))] +mod my_token { + use super::*; + + pub struct MyToken { + pub erc20: Erc20State, + pub ownable: OwnableState, + } + + /// Inherent impl: the constructor + any methods that aren't part of a + /// trait the contract is declared to implement. + impl MyToken { + /// Constructor — owner is the deployer. + /// + /// "Constructor chaining" in this SDK is just calling each extension's + /// init helper explicitly. There's no automatic super-call. + #[pvm_contract_sdk::constructor] + pub fn new(&mut self, initial_supply: U256) { + let mut caller_bytes = [0u8; 20]; + self.host().caller(&mut caller_bytes); + let caller = Address::from(caller_bytes); + + self.ownable._set_owner(caller); + self.erc20._mint(caller, initial_supply); + } + + /// Owner-only mint. Inherent method — not on any trait but still + /// dispatched because of `#[method]`. + #[pvm_contract_sdk::method] + pub fn mint(&mut self, to: Address, value: U256) -> Result<(), MyTokenError> { + let mut caller_bytes = [0u8; 20]; + self.host().caller(&mut caller_bytes); + self.ownable._check_owner(Address::from(caller_bytes))?; + self.erc20._mint(to, value); + Ok(()) + } + } + + /// Trait impl: every fn here is dispatched implicitly because `IErc20` is + /// in `implements(...)`. + /// + /// `transfer` is **overridden** here vs. what the extension does on its + /// own — the contract layers an additional ownership-style check (in real + /// OZ you'd more likely layer pausability or a transfer hook). The point + /// is that the contract's `impl` is the single source of truth for what + /// runs at the `transfer(address,uint256)` selector. There's no + /// `virtual`/`override` — the contract just writes a different body. + impl super::IErc20 for MyToken { + type Error = super::MyTokenError; + + fn total_supply(&self) -> super::U256 { + self.erc20._total_supply() + } + + fn balance_of(&self, account: super::Address) -> super::U256 { + self.erc20._balance_of(account) + } + + fn transfer( + &mut self, + to: super::Address, + value: super::U256, + ) -> Result<(), Self::Error> { + let mut caller_bytes = [0u8; 20]; + self.host().caller(&mut caller_bytes); + let from = super::Address::from(caller_bytes); + + // Forward to the extension helper. `?` desugars the + // `InsufficientBalance` extension error into our `MyTokenError` + // via the `From` impl. + self.erc20._transfer(from, to, value)?; + Ok(()) + } + } + + /// Trait impl: ownership management. + impl super::IOwnable for MyToken { + type Error = super::MyTokenError; + + fn owner(&self) -> super::Address { + self.ownable._owner() + } + + fn transfer_ownership( + &mut self, + new_owner: super::Address, + ) -> Result<(), Self::Error> { + let mut caller_bytes = [0u8; 20]; + self.host().caller(&mut caller_bytes); + self.ownable._check_owner(super::Address::from(caller_bytes))?; + self.ownable._set_owner(new_owner); + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// Test harness — mirrors the pattern used by other native dispatch tests. +// --------------------------------------------------------------------------- + +const OWNER: [u8; 20] = [0x01; 20]; +const ALICE: [u8; 20] = [0xAA; 20]; +const BOB: [u8; 20] = [0xBB; 20]; + +fn host_with_caller(caller: [u8; 20]) -> MockHost { + MockHostBuilder::new().caller(caller).build() +} + +fn make_contract(mock: &MockHost) -> my_token::MyToken { + // Construct exactly the way the macro-generated `deploy()` / `call()` + // would, but without invoking the riscv64 boundary syscalls. The fields + // mirror what auto-numbered slot construction produces. + let host = Host::from_dyn(alloc::rc::Rc::new(mock.clone())); + my_token::MyToken { + erc20: ::new_at(0, host.clone()), + ownable: ::new_at(2, host.clone()), + host, + } +} + +/// Run the constructor body so storage is in a non-default state for the +/// other tests. Mirrors what `deploy()` does on-chain. +fn deploy_with_supply(mock: &MockHost, supply: U256) -> my_token::MyToken { + let mut c = make_contract(mock); + // Call the constructor body directly. + let mut caller = [0u8; 20]; + c.host.caller(&mut caller); + c.ownable._set_owner(Address::from(caller)); + c.erc20._mint(Address::from(caller), supply); + c +} + +fn selector(sig: &str) -> [u8; 4] { + pvm_contract_types::const_selector(sig) +} + +fn route_ok(c: &mut my_token::MyToken, mock: &MockHost, sel: [u8; 4], input: &[u8]) -> Vec { + let outcome = my_token::route(c, sel, input); + assert_eq!(outcome, Some(()), "selector must match"); + let rv = mock.take_return_value().expect("return_value called"); + assert_eq!(rv.flags, ReturnFlags::empty(), "expected success"); + rv.data +} + +fn route_revert( + c: &mut my_token::MyToken, + mock: &MockHost, + sel: [u8; 4], + input: &[u8], +) -> Vec { + let outcome = my_token::route(c, sel, input); + assert_eq!(outcome, Some(()), "selector must match"); + let rv = mock.take_return_value().expect("return_value called"); + assert_eq!(rv.flags, ReturnFlags::REVERT, "expected REVERT"); + rv.data +} + +fn encode_addr(a: Address) -> Vec { + let mut buf = vec![0u8;
::ENCODED_SIZE]; + a.encode_to(&mut buf); + buf +} + +fn encode_addr_u256(a: Address, v: U256) -> Vec { + const LEN: usize = +
::ENCODED_SIZE + ::ENCODED_SIZE; + let mut buf = vec![0u8; LEN]; + (a, v).encode_to(&mut buf); + buf +} + +// =========================================================================== +// Tests +// =========================================================================== + +/// Smoke test: the storage struct embeds two extensions whose `SLOTS` sum +/// correctly. The contract claims 3 slots total (Erc20State=2 + OwnableState=1). +#[test] +fn extensions_claim_correct_slot_count() { + use pvm_contract_sdk::StorageComponent; + assert_eq!(::SLOTS, 2); + assert_eq!(::SLOTS, 1); +} + +/// Each trait's `interface_id()` is computed from its methods. Different +/// traits have different IDs, the IDs are non-zero, and they're stable +/// across calls. +#[test] +fn interface_ids_are_distinct_and_stable() { + let id_erc20 = ::interface_id(); + let id_ownable = ::interface_id(); + assert_ne!(id_erc20, id_ownable); + assert_ne!(id_erc20, [0u8; 4]); + assert_ne!(id_ownable, [0u8; 4]); + // Stable on repeated calls. + assert_eq!(id_erc20, ::interface_id()); +} + +/// `total_supply` and `balance_of` come from `impl IErc20 for MyToken` and +/// dispatch through UFCS — they forward to the embedded `Erc20State` helpers. +#[test] +fn ierc20_views_dispatch_through_extension() { + let mock = host_with_caller(OWNER); + let mut c = deploy_with_supply(&mock, U256::from(10_000)); + + let data = route_ok(&mut c, &mock, selector("totalSupply()"), &[]); + assert_eq!(data, U256::from(10_000).to_be_bytes::<32>().to_vec()); + + let data = route_ok( + &mut c, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(OWNER)), + ); + assert_eq!(data, U256::from(10_000).to_be_bytes::<32>().to_vec()); +} + +/// `IErc20::transfer` dispatches and mutates the balances mapping. The +/// extension's `_transfer` helper does the actual work; the contract's impl +/// is just a thin caller plus error-type coercion via `?`. +#[test] +fn ierc20_transfer_moves_balance() { + let mock = host_with_caller(OWNER); + let mut c = deploy_with_supply(&mock, U256::from(1_000)); + + route_ok( + &mut c, + &mock, + selector("transfer(address,uint256)"), + &encode_addr_u256(Address::from(ALICE), U256::from(400)), + ); + + let alice_bal = route_ok( + &mut c, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(ALICE)), + ); + let owner_bal = route_ok( + &mut c, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(OWNER)), + ); + assert_eq!(alice_bal, U256::from(400).to_be_bytes::<32>().to_vec()); + assert_eq!(owner_bal, U256::from(600).to_be_bytes::<32>().to_vec()); +} + +/// Insufficient-balance reverts surface the typed `InsufficientBalance` +/// selector, even though the contract's combined error enum wraps it. This +/// proves the `From` coercion + `SolRevert` enum dispatch work end-to-end. +#[test] +fn ierc20_transfer_revert_carries_typed_error_selector() { + let mock = host_with_caller(ALICE); + let mut c = make_contract(&mock); // alice has zero balance + + let data = route_revert( + &mut c, + &mock, + selector("transfer(address,uint256)"), + &encode_addr_u256(Address::from(BOB), U256::from(1)), + ); + + let expected_sel = selector("InsufficientBalance(uint256,uint256)"); + assert_eq!(&data[..4], &expected_sel[..]); + + let available = U256::from_be_bytes::<32>({ + let mut b = [0u8; 32]; + b.copy_from_slice(&data[4..36]); + b + }); + let required = U256::from_be_bytes::<32>({ + let mut b = [0u8; 32]; + b.copy_from_slice(&data[36..68]); + b + }); + assert_eq!(available, U256::ZERO); + assert_eq!(required, U256::from(1)); +} + +/// `IOwnable::owner` returns the deploy-time caller, as set in the +/// constructor body. +#[test] +fn iownable_owner_returns_constructor_caller() { + let mock = host_with_caller(OWNER); + let mut c = deploy_with_supply(&mock, U256::from(1)); + + let data = route_ok(&mut c, &mock, selector("owner()"), &[]); + let mut out = [0u8; 20]; + out.copy_from_slice(&data[12..32]); + assert_eq!(out, OWNER); +} + +/// `IOwnable::transfer_ownership` reverts with `Unauthorized` when called by +/// a non-owner. Demonstrates the second variant of the combined error enum +/// reaching the revert payload correctly. +#[test] +fn iownable_transfer_ownership_revert_for_non_owner() { + let mock = host_with_caller(OWNER); + let mut c = deploy_with_supply(&mock, U256::from(1)); + + // Switch caller to ALICE for the next route() call. + mock.set_caller(ALICE); + + let data = route_revert( + &mut c, + &mock, + selector("transferOwnership(address)"), + &encode_addr(Address::from(BOB)), + ); + let expected_sel = selector("Unauthorized()"); + assert_eq!(&data[..4], &expected_sel[..]); +} + +/// Inherent `mint` enforces owner-only via the extension's `_check_owner` +/// helper. Mixed inherent + trait dispatch in the same contract. +#[test] +fn inherent_mint_is_owner_gated() { + let mock = host_with_caller(OWNER); + let mut c = deploy_with_supply(&mock, U256::from(0)); + + // Owner mints — should succeed. + route_ok( + &mut c, + &mock, + selector("mint(address,uint256)"), + &encode_addr_u256(Address::from(ALICE), U256::from(50)), + ); + + let alice_bal = route_ok( + &mut c, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(ALICE)), + ); + assert_eq!(alice_bal, U256::from(50).to_be_bytes::<32>().to_vec()); + + // Non-owner mint reverts. + mock.set_caller(BOB); + let data = route_revert( + &mut c, + &mock, + selector("mint(address,uint256)"), + &encode_addr_u256(Address::from(BOB), U256::from(999)), + ); + let expected_sel = selector("Unauthorized()"); + assert_eq!(&data[..4], &expected_sel[..]); +} + +/// Trait `transfer_ownership` succeeds when the owner calls it. End-to-end +/// trail: dispatch → trait impl → extension helper → storage write → read +/// back through `owner()`. +#[test] +fn iownable_transfer_ownership_succeeds_for_owner() { + let mock = host_with_caller(OWNER); + let mut c = deploy_with_supply(&mock, U256::from(0)); + + route_ok( + &mut c, + &mock, + selector("transferOwnership(address)"), + &encode_addr(Address::from(ALICE)), + ); + + let data = route_ok(&mut c, &mock, selector("owner()"), &[]); + let mut out = [0u8; 20]; + out.copy_from_slice(&data[12..32]); + assert_eq!(out, ALICE); +} diff --git a/crates/pvm-contract-macros/tests/interface_id_runtime.rs b/crates/pvm-contract-macros/tests/interface_id_runtime.rs new file mode 100644 index 00000000..8b471fae --- /dev/null +++ b/crates/pvm-contract-macros/tests/interface_id_runtime.rs @@ -0,0 +1,131 @@ +#![cfg(not(feature = "abi-gen"))] +//! Runtime tests that exercise the `#[interface_id]` macro against known +//! ERC-165 interface IDs published in the Ethereum spec, plus our own +//! per-method selectors so the XOR reduction is verified independently of +//! any one trait. + +use pvm_contract_sdk::{Address, U256, const_selector}; + +/// IERC-165 itself: `supportsInterface(bytes4)` → 0x01ffc9a7. +#[pvm_contract_sdk::interface_id] +pub trait IErc165 { + fn supports_interface(&self, interface_id: [u8; 4]) -> bool; +} + +#[test] +fn ierc165_interface_id_matches_spec() { + // Implementing the trait on a unit type so we can call interface_id(). + struct Probe; + impl IErc165 for Probe { + fn supports_interface(&self, _id: [u8; 4]) -> bool { + false + } + } + let id = ::interface_id(); + // ERC-165 interface ID for `IERC165` (one method: supportsInterface(bytes4)) + // keccak256("supportsInterface(bytes4)")[..4] = 0x01ffc9a7 + assert_eq!(id, [0x01, 0xff, 0xc9, 0xa7], "got 0x{}", hex(id)); +} + +/// IERC-20 standard: XOR of six method selectors. +/// Spec value: 0x36372b07 (per OpenZeppelin Contracts and EIP-165 references). +#[pvm_contract_sdk::interface_id] +pub trait IErc20 { + fn total_supply(&self) -> U256; + fn balance_of(&self, account: Address) -> U256; + fn transfer(&mut self, to: Address, value: U256) -> bool; + fn allowance(&self, owner: Address, spender: Address) -> U256; + fn approve(&mut self, spender: Address, value: U256) -> bool; + fn transfer_from(&mut self, from: Address, to: Address, value: U256) -> bool; +} + +#[test] +fn ierc20_interface_id_matches_spec() { + struct Probe; + impl IErc20 for Probe { + fn total_supply(&self) -> U256 { + U256::ZERO + } + fn balance_of(&self, _: Address) -> U256 { + U256::ZERO + } + fn transfer(&mut self, _: Address, _: U256) -> bool { + false + } + fn allowance(&self, _: Address, _: Address) -> U256 { + U256::ZERO + } + fn approve(&mut self, _: Address, _: U256) -> bool { + false + } + fn transfer_from(&mut self, _: Address, _: Address, _: U256) -> bool { + false + } + } + let id = ::interface_id(); + // ERC-20 interface ID: 0x36372b07 + // Cross-check by independently XOR'ing the six selectors: + let totalsupply = const_selector("totalSupply()"); + let balanceof = const_selector("balanceOf(address)"); + let transfer = const_selector("transfer(address,uint256)"); + let allowance = const_selector("allowance(address,address)"); + let approve = const_selector("approve(address,uint256)"); + let transferfrom = const_selector("transferFrom(address,address,uint256)"); + let mut expected = [0u8; 4]; + for sel in [totalsupply, balanceof, transfer, allowance, approve, transferfrom] { + for j in 0..4 { + expected[j] ^= sel[j]; + } + } + assert_eq!( + id, + expected, + "macro-generated 0x{} should match independently-computed 0x{}", + hex(id), + hex(expected), + ); + assert_eq!( + id, + [0x36, 0x37, 0x2b, 0x07], + "ERC-20 interface ID should be 0x36372b07 per spec; got 0x{}", + hex(id), + ); +} + +/// `#[selector(name = "...")]` overrides the camelCase default. Verify the +/// selector reflects the override, not the Rust ident. +#[pvm_contract_sdk::interface_id] +pub trait Renamed { + #[selector(name = "myCustomFn")] + fn snake_default(&self) -> bool; +} + +#[test] +fn selector_rename_changes_signature_used_for_id() { + struct Probe; + impl Renamed for Probe { + fn snake_default(&self) -> bool { + false + } + } + let id = ::interface_id(); + let expected = const_selector("myCustomFn()"); + assert_eq!(id, expected, "rename should drive the selector"); + // Sanity: differs from what we'd get from the Rust ident. + assert_ne!(id, const_selector("snakeDefault()")); +} + +/// Hex helper for assertion messages. +fn hex(bytes: [u8; 4]) -> alloc::string::String { + extern crate alloc; + use alloc::string::String; + let mut out = String::with_capacity(8); + for b in bytes { + out.push(NIBBLE[(b >> 4) as usize] as char); + out.push(NIBBLE[(b & 0x0f) as usize] as char); + } + out +} + +extern crate alloc; +const NIBBLE: &[u8; 16] = b"0123456789abcdef"; diff --git a/crates/pvm-contract-macros/tests/native_cross_contract_call.rs b/crates/pvm-contract-macros/tests/native_cross_contract_call.rs index 78ddcc67..c71319cd 100644 --- a/crates/pvm-contract-macros/tests/native_cross_contract_call.rs +++ b/crates/pvm-contract-macros/tests/native_cross_contract_call.rs @@ -6,24 +6,24 @@ //! These tests exercise the host-as-parameter path: //! //! ```ignore -//! Iface::from_address(addr).method().call(&host)? -//! -> CallBuilder::call(host, ...) -//! -> host.call_evm(...) // dispatches to MockHost +//! Iface::from_address(addr).method().call(&cx)? +//! -> CallBuilder::call(cx, ...) +//! -> cx.host().call_evm(...) // dispatches to MockHost //! -> MockHost::resolve_call // returns mocked data //! -> CallBuilder::extract_output(host, ...) //! -> host.return_data_copy(...) // reads from MockHost.return_data //! ``` //! -//! Without the host plumbing, `CallBuilder` would call `PolkaVmHost.*` -//! directly, bypassing the mock and panicking with `unimplemented!()` on -//! host-target builds. +//! `Context` wraps the `Host` and impls `ContractContext`, so it satisfies +//! the borrow gate (`&impl ContractContext` for view callees, +//! `&mut impl ContractContext` for mutating ones). extern crate alloc; use std::rc::Rc; use pvm_contract_sdk::{ - Address, CallError, Host, HostApi, MockHostBuilder, RefTimeAndProofSizeLimits, + Address, CallError, Context, Host, HostApi, MockHostBuilder, RefTimeAndProofSizeLimits, }; pvm_contract_sdk::abi_import! { @@ -51,11 +51,11 @@ fn view_call_returns_mocked_data() { let target = Address::from([0xBB; 20]); let mock = MockHostBuilder::new().build(); mock.mock_call(target.0, Ok(encoded_bool(true))); - let host = Host::from_dyn(Rc::new(mock)); + let cx = Context::new(Host::from_dyn(Rc::new(mock))); let res = flipper::Flipper::from_address(target) .get() - .call(&host) + .call(&cx) .unwrap(); assert!(res); @@ -66,11 +66,11 @@ fn view_call_returns_mocked_false() { let target = Address::from([0xBC; 20]); let mock = MockHostBuilder::new().build(); mock.mock_call(target.0, Ok(encoded_bool(false))); - let host = Host::from_dyn(Rc::new(mock)); + let cx = Context::new(Host::from_dyn(Rc::new(mock))); let res = flipper::Flipper::from_address(target) .get() - .call(&host) + .call(&cx) .unwrap(); assert!(!res); @@ -82,15 +82,16 @@ fn write_call_invokes_mock_and_propagates_return_data() { let marker = vec![0xAA, 0xBB, 0xCC, 0xDD]; let mock = MockHostBuilder::new().build(); mock.mock_call(target.0, Ok(marker.clone())); - let host = Host::from_dyn(Rc::new(mock)); + let mut cx = Context::new(Host::from_dyn(Rc::new(mock))); flipper::Flipper::from_address(target) .flip() - .call(&host) + .call(&mut cx) .expect("flip should succeed"); // Stronger evidence than a successful return: the mock actually wrote // its configured payload into the host's return-data buffer. + let host = &cx.host; assert_eq!(host.return_data_size(), marker.len() as u64); let mut buf = vec![0u8; marker.len()]; let mut out = &mut buf[..]; @@ -105,14 +106,14 @@ fn unmocked_call_leaves_return_data_empty() { // clears return_data and returns Ok(()) — proving the previous test's // marker bytes came from the mock table, not from a default fallback. let target = Address::from([0xBE; 20]); - let host = Host::from_dyn(Rc::new(MockHostBuilder::new().build())); + let mut cx = Context::new(Host::from_dyn(Rc::new(MockHostBuilder::new().build()))); flipper::Flipper::from_address(target) .flip() - .call(&host) + .call(&mut cx) .expect("unmocked flip still succeeds"); - assert_eq!(host.return_data_size(), 0); + assert_eq!(cx.host.return_data_size(), 0); } #[test] @@ -120,9 +121,9 @@ fn revert_from_callee_propagates_as_call_error() { let target = Address::from([0xCC; 20]); let mock = MockHostBuilder::new().build(); mock.mock_call(target.0, Err(())); - let host = Host::from_dyn(Rc::new(mock)); + let cx = Context::new(Host::from_dyn(Rc::new(mock))); - let res = flipper::Flipper::from_address(target).get().call(&host); + let res = flipper::Flipper::from_address(target).get().call(&cx); assert_eq!(res, Err(CallError::CalleeReverted)); } @@ -139,14 +140,15 @@ fn delegate_call_uses_same_mock_table() { let payload = encoded_bool(true); let mock = MockHostBuilder::new().build(); mock.mock_call(target.0, Ok(payload.clone())); - let host = Host::from_dyn(Rc::new(mock)); + let mut cx = Context::new(Host::from_dyn(Rc::new(mock))); let res = flipper::Flipper::from_address(target) .get() - .delegate_call(&host) + .delegate_call(&mut cx) .unwrap(); assert!(res); + let host = &cx.host; assert_eq!(host.return_data_size(), payload.len() as u64); let mut buf = vec![0u8; payload.len()]; let mut out = &mut buf[..]; @@ -157,7 +159,7 @@ fn delegate_call_uses_same_mock_table() { #[test] fn chained_calls_each_extract_their_own_return_data() { // Two callees mocked with different payloads; the contract calls both - // in sequence. Each `.call(&host)` must decode the value belonging to + // in sequence. Each `.call(&cx)` must decode the value belonging to // *its* callee — proving that `extract_output` runs immediately after // `call_raw` and before the next call clobbers `return_data`. This // mirrors on-chain semantics (RETURNDATA reflects only the most recent @@ -169,25 +171,26 @@ fn chained_calls_each_extract_their_own_return_data() { let mock = MockHostBuilder::new().build(); mock.mock_call(target_a.0, Ok(encoded_bool(true))); mock.mock_call(target_b.0, Ok(encoded_bool(false))); - let host = Host::from_dyn(Rc::new(mock)); + let cx = Context::new(Host::from_dyn(Rc::new(mock))); // First call — get the answer for target A. let res_a = flipper::Flipper::from_address(target_a) .get() - .call(&host) + .call(&cx) .unwrap(); assert!(res_a, "first call should decode target_a's payload"); // Second call — get the answer for target B. let res_b = flipper::Flipper::from_address(target_b) .get() - .call(&host) + .call(&cx) .unwrap(); assert!(!res_b, "second call should decode target_b's payload"); // Pin the overwrite semantics: the host's RETURNDATA now holds only // target B's payload (target A's is gone). On chain this is exactly // how `RETURNDATASIZE` / `RETURNDATACOPY` behave. + let host = &cx.host; assert_eq!(host.return_data_size(), 32); let mut buf = [0u8; 32]; let mut out = &mut buf[..]; @@ -200,7 +203,7 @@ fn instantiate_returns_mocked_address() { let deployed = [0xDD; 20]; let mock = MockHostBuilder::new().build(); mock.mock_instantiate(deployed, Vec::new()); - let host = Host::from_dyn(Rc::new(mock)); + let mut cx = Context::new(Host::from_dyn(Rc::new(mock))); let limits = RefTimeAndProofSizeLimits { ref_time_limit: u64::MAX, @@ -208,23 +211,49 @@ fn instantiate_returns_mocked_address() { deposit_limit: [0u8; 32], }; let (addr, ()) = flipper::new_flipper() - .instantiate(&host, &[0u8; 32], 0, limits, None) + .instantiate(&mut cx, &[0u8; 32], 0, limits, None) .expect("instantiate should succeed"); assert_eq!(addr, Address::from(deployed)); } +#[test] +fn mut_caller_calling_view_callee_compiles_via_coercion() { + // Pins the borrow-checker coercion path: a caller that holds + // `&mut Context` can invoke a `View` callee whose `call` takes + // `&impl ContractContext`. Rust's `&mut T -> &T` reborrow makes this + // work without an explicit `&*cx`. Regression guard: if someone + // tightens the View bound to require `&Self`-only (no coercion from + // `&mut`), this test will fail to compile. + let target = Address::from([0xBF; 20]); + let mock = MockHostBuilder::new().build(); + mock.mock_call(target.0, Ok(encoded_bool(true))); + let mut cx = Context::new(Host::from_dyn(Rc::new(mock))); + + // `&mut cx` exists; Flipper::get is a View callee whose .call + // takes `&impl ContractContext`. The reborrow `&*&mut cx -> &cx` is + // implicit here — we just pass `&cx` after taking the mut borrow. + let res = flipper::Flipper::from_address(target) + .get() + .call(&cx) + .unwrap(); + assert!(res); + + // Confirm `cx` is still owned mutably afterwards (no borrow stuck). + let _: &mut Context = &mut cx; +} + #[test] fn instantiate_without_mock_returns_out_of_resources() { let mock = MockHostBuilder::new().build(); - let host = Host::from_dyn(Rc::new(mock)); + let mut cx = Context::new(Host::from_dyn(Rc::new(mock))); let limits = RefTimeAndProofSizeLimits { ref_time_limit: u64::MAX, proof_size_limit: u64::MAX, deposit_limit: [0u8; 32], }; - let res = flipper::new_flipper().instantiate(&host, &[0u8; 32], 0, limits, None); + let res = flipper::new_flipper().instantiate(&mut cx, &[0u8; 32], 0, limits, None); assert_eq!(res, Err(CallError::OutOfResources)); } diff --git a/crates/pvm-contract-macros/tests/native_e2e_token.rs b/crates/pvm-contract-macros/tests/native_e2e_token.rs index 2444c4ef..75144b37 100644 --- a/crates/pvm-contract-macros/tests/native_e2e_token.rs +++ b/crates/pvm-contract-macros/tests/native_e2e_token.rs @@ -500,7 +500,7 @@ fn router_trait_path_produces_identical_result_to_free_fn() { .expect("free fn called return_value"); // Drive via the Router trait, take the freshly captured return. - let outcome = >::route(&mut contract, sel, &input); + let outcome = ::route(&mut contract, sel, &input); assert_eq!(outcome, Some(())); let via_trait = mock .take_return_value() diff --git a/crates/pvm-contract-macros/tests/native_route_dispatch.rs b/crates/pvm-contract-macros/tests/native_route_dispatch.rs index c21c552f..3c7b397c 100644 --- a/crates/pvm-contract-macros/tests/native_route_dispatch.rs +++ b/crates/pvm-contract-macros/tests/native_route_dispatch.rs @@ -134,7 +134,7 @@ fn router_trait_impl_delegates_to_module_route() { let input = encode_address(Address::from([0xAA; 20])); // Call through the Router trait rather than the free function. - let outcome = >::route(&mut contract, sel, &input); + let outcome = ::route(&mut contract, sel, &input); assert_eq!(outcome, Some(())); let rv = mock diff --git a/crates/pvm-contract-macros/tests/storage_composition.rs b/crates/pvm-contract-macros/tests/storage_composition.rs new file mode 100644 index 00000000..6ec864d2 --- /dev/null +++ b/crates/pvm-contract-macros/tests/storage_composition.rs @@ -0,0 +1,207 @@ +#![cfg(not(feature = "abi-gen"))] +//! End-to-end test for the new storage composition surface: +//! +//! - Auto-numbered slots on the `#[contract]` struct (no `#[slot(N)]` needed). +//! - `#[storage]`-derived embedded sub-storage struct claiming a contiguous +//! slot range. +//! - `StorageComponent::SLOTS` chained through both levels so the outer +//! contract gets the correct overall layout without manual offset math. +//! - Dynamic `String` value stored via `Lazy` (Gap 1). + +use pvm_contract_sdk::{Lazy, Mapping, StorageComponent, StorageKey}; +use pvm_contract_types::{Address, Host, MockHostBuilder}; +use ruint::aliases::U256; + +/// A composable sub-storage struct. After `#[storage]` expansion, this type +/// implements `StorageComponent` with `SLOTS = 3` (one for each `Lazy` / +/// `Mapping`). +#[pvm_contract_sdk::storage] +pub struct Erc20State { + pub total_supply: Lazy, + pub balances: Mapping, + pub allowances: Mapping>, +} + +/// A second sub-storage struct using a dynamic `Lazy`. Still 1 slot +/// because the header slot stores length and the body lives at +/// `keccak256(slot)`. +#[pvm_contract_sdk::storage] +pub struct MetadataState { + pub name: Lazy, + pub symbol: Lazy, +} + +// Pull `alloc` into the test crate for the `String` type. +extern crate alloc; + +/// SLOTS counts at the type level should match what we expect. +#[test] +fn storage_component_slots_are_field_sums() { + assert_eq!(::SLOTS, 3); + assert_eq!(::SLOTS, 2); + assert_eq!( as StorageComponent>::SLOTS, 1); + assert_eq!( as StorageComponent>::SLOTS, 1); +} + +fn fresh_host() -> Host { + Host::from_dyn(alloc::rc::Rc::new(MockHostBuilder::new().build())) +} + +/// Constructing an `Erc20State` via `StorageComponent::new_at(base, host)` +/// produces fields rooted at `base, base+1, base+2` — exactly the layout +/// `cast storage` will expect. +#[test] +fn erc20_state_new_at_assigns_contiguous_slots() { + let host = fresh_host(); + let state = ::new_at(5, host.clone()); + + // Mint to alice via balance map insert; that should write at the slot + // derived from `keccak256(pad32(alice) ++ pad32(6))` because balances is + // the *second* field of Erc20State and we passed base=5 so it claims slot 6. + let alice = Address([0xAA; 20]); + let mut state = state; + state.balances.insert(&alice, &U256::from(1_000)); + + // Cross-check: a standalone Mapping rooted at slot 6 should see the same + // entry. + let independent = Mapping::::new(StorageKey::from_slot(6), host); + assert_eq!(independent.get(&alice), U256::from(1_000)); +} + +/// Auto-numbered storage on a `#[contract]` struct that embeds an +/// `#[storage]`-derived sub-struct. +/// +/// Layout produced by the macro: +/// +/// slot 0 = erc20.total_supply +/// slot 1 = erc20.balances (mapping root) +/// slot 2 = erc20.allowances (mapping root) +/// slot 3 = metadata.name +/// slot 4 = metadata.symbol +/// slot 5 = paused +/// +/// The `#[contract]` macro never sees `Erc20State::SLOTS = 3`; it just +/// references the const at codegen time so the chain `0 + 3 + 2 = 5` is +/// resolved at compile time. +#[allow(dead_code)] // route(), deploy(), call() are riscv64-gated +#[pvm_contract_macros::contract] +mod composed_contract { + use super::*; + + pub struct ComposedContract { + pub erc20: Erc20State, + pub metadata: MetadataState, + pub paused: Lazy, + } + + impl ComposedContract { + #[pvm_contract_macros::constructor] + pub fn new(&mut self) { + self.erc20.total_supply.set(&U256::from(1_000_000)); + self.metadata + .name + .set(&alloc::string::String::from("Composed Token")); + self.metadata + .symbol + .set(&alloc::string::String::from("CMP")); + self.paused.set(&false); + } + + #[pvm_contract_macros::method] + pub fn balance_of(&self, who: Address) -> U256 { + self.erc20.balances.get(&who) + } + + #[pvm_contract_macros::method] + pub fn total_supply(&self) -> U256 { + self.erc20.total_supply.get() + } + } +} + +/// Cross-check the composed layout by deriving the expected slot for each +/// embedded field via two independent paths: +/// +/// 1. Construct the embedded type directly with the offset that the macro +/// would have assigned (3 for `metadata`, 5 for `paused`). +/// 2. Construct the outer contract's storage layout *by hand* using the +/// same `StorageComponent::new_at` calls the macro generates, write a +/// distinctive value through one field, and read it back through the +/// standalone path. +/// +/// If they agree, the macro's auto-numbering matches the trait's slot +/// arithmetic at the type level. +#[test] +fn composed_contract_layout_matches_hand_constructed() { + let host = fresh_host(); + let mut erc20 = ::new_at(0, host.clone()); + let mut metadata = ::new_at(3, host.clone()); + let mut paused = as StorageComponent>::new_at(5, host.clone()); + + // Write through each sub-component. + let alice = Address([0xAA; 20]); + erc20.total_supply.set(&U256::from(1_000)); + erc20.balances.insert(&alice, &U256::from(42)); + metadata.name.set(&alloc::string::String::from("Composed")); + metadata.symbol.set(&alloc::string::String::from("CMP")); + paused.set(&true); + + // Read back via slot-pinned standalone helpers and confirm the slot + // arithmetic from `#[storage]` matches our manual `new_at` calls above. + let supply_slot = Lazy::::new(StorageKey::from_slot(0), host.clone()); + assert_eq!(supply_slot.get(), U256::from(1_000)); + + let balances_slot = Mapping::::new(StorageKey::from_slot(1), host.clone()); + assert_eq!(balances_slot.get(&alice), U256::from(42)); + + // `allowances` claims slot 2 — write/read via two paths to confirm. + let mut allowances_via_outer = >>::new( + StorageKey::from_slot(2), + host.clone(), + ); + let bob = Address([0xBB; 20]); + allowances_via_outer.entry(&alice).insert(&bob, &U256::from(7)); + assert_eq!(erc20.allowances.get(&alice).get(&bob), U256::from(7)); + + let name_slot = Lazy::::new(StorageKey::from_slot(3), host.clone()); + assert_eq!(name_slot.get(), "Composed"); + + let symbol_slot = Lazy::::new(StorageKey::from_slot(4), host.clone()); + assert_eq!(symbol_slot.get(), "CMP"); + + let paused_slot = Lazy::::new(StorageKey::from_slot(5), host); + assert!(paused_slot.get()); +} + +/// Nesting: a `#[storage]` struct that itself contains another `#[storage]` +/// struct sums correctly. +#[pvm_contract_sdk::storage] +pub struct OuterState { + pub flag: Lazy, + pub erc20: Erc20State, // 3 slots +} + +#[test] +fn nested_storage_struct_slot_sum() { + assert_eq!(::SLOTS, 4); +} + +#[test] +fn nested_storage_struct_uses_offset() { + let host = fresh_host(); + let mut outer = ::new_at(10, host.clone()); + + // OuterState at base 10 places `flag` at slot 10 and `erc20` starting at + // slot 11 (because flag claims 1 slot). + outer.flag.set(&true); + outer + .erc20 + .total_supply + .set(&U256::from(999)); + + let flag_check = Lazy::::new(StorageKey::from_slot(10), host.clone()); + assert!(flag_check.get()); + + let supply_check = Lazy::::new(StorageKey::from_slot(11), host); + assert_eq!(supply_check.get(), U256::from(999)); +} diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/Cargo.toml b/crates/pvm-contract-macros/tests/test_abi_contract/Cargo.toml index f94680e5..b41bf74f 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/Cargo.toml +++ b/crates/pvm-contract-macros/tests/test_abi_contract/Cargo.toml @@ -15,6 +15,10 @@ path = "src/constructor_with_params.rs" name = "constructor-no-params" path = "src/constructor_no_params.rs" +[[bin]] +name = "constructor-payable" +path = "src/constructor_payable.rs" + [[bin]] name = "custom-type-method" path = "src/custom_type_method.rs" diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/abi_constructor_payable.json b/crates/pvm-contract-macros/tests/test_abi_contract/abi_constructor_payable.json new file mode 100644 index 00000000..d03c01c9 --- /dev/null +++ b/crates/pvm-contract-macros/tests/test_abi_contract/abi_constructor_payable.json @@ -0,0 +1,32 @@ +[ + { + "inputs": [], + "stateMutability": "payable", + "type": "constructor" + }, + { + "inputs": [], + "name": "InvalidCalldata", + "type": "error" + }, + { + "inputs": [], + "name": "CalldataTooLarge", + "type": "error" + }, + { + "inputs": [], + "name": "NoSelector", + "type": "error" + }, + { + "inputs": [], + "name": "UnknownSelector", + "type": "error" + }, + { + "inputs": [], + "name": "NonPayableValueReceived", + "type": "error" + } +] \ No newline at end of file diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/abi_constructor_with_params.json b/crates/pvm-contract-macros/tests/test_abi_contract/abi_constructor_with_params.json index 1f413be9..f22bdd3e 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/abi_constructor_with_params.json +++ b/crates/pvm-contract-macros/tests/test_abi_contract/abi_constructor_with_params.json @@ -27,7 +27,7 @@ "type": "uint256" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/abi_custom_type_method.json b/crates/pvm-contract-macros/tests/test_abi_contract/abi_custom_type_method.json index ef67b027..52122f73 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/abi_custom_type_method.json +++ b/crates/pvm-contract-macros/tests/test_abi_contract/abi_custom_type_method.json @@ -38,7 +38,7 @@ "type": "tuple" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/abi_dynamic_custom_return.json b/crates/pvm-contract-macros/tests/test_abi_contract/abi_dynamic_custom_return.json index 740e4855..6572c8aa 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/abi_dynamic_custom_return.json +++ b/crates/pvm-contract-macros/tests/test_abi_contract/abi_dynamic_custom_return.json @@ -23,7 +23,7 @@ "type": "tuple" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { @@ -54,7 +54,7 @@ "type": "uint64" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/abi_host_api_calls.json b/crates/pvm-contract-macros/tests/test_abi_contract/abi_host_api_calls.json index 11e26207..cc94d036 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/abi_host_api_calls.json +++ b/crates/pvm-contract-macros/tests/test_abi_contract/abi_host_api_calls.json @@ -18,7 +18,7 @@ "type": "uint256" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { @@ -46,7 +46,7 @@ "type": "address" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/abi_multi_method.json b/crates/pvm-contract-macros/tests/test_abi_contract/abi_multi_method.json index 2d4ec1c1..14a86caa 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/abi_multi_method.json +++ b/crates/pvm-contract-macros/tests/test_abi_contract/abi_multi_method.json @@ -50,7 +50,35 @@ "type": "uint64" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "name": "a", + "type": "uint64" + }, + { + "name": "b", + "type": "uint64" + } + ], + "name": "add", + "outputs": [ + { + "name": "", + "type": "uint64" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "deposit", + "outputs": [], + "stateMutability": "payable", "type": "function" }, { diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/abi_nested_custom_type.json b/crates/pvm-contract-macros/tests/test_abi_contract/abi_nested_custom_type.json index 7aa0489f..3a4e34ed 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/abi_nested_custom_type.json +++ b/crates/pvm-contract-macros/tests/test_abi_contract/abi_nested_custom_type.json @@ -78,7 +78,7 @@ "type": "tuple" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { @@ -100,7 +100,7 @@ "type": "tuple" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/src/constructor_payable.rs b/crates/pvm-contract-macros/tests/test_abi_contract/src/constructor_payable.rs new file mode 100644 index 00000000..b6bf1166 --- /dev/null +++ b/crates/pvm-contract-macros/tests/test_abi_contract/src/constructor_payable.rs @@ -0,0 +1,12 @@ +#![cfg_attr(not(feature = "abi-gen"), no_main, no_std)] + +#[pvm_contract_sdk::contract] +mod my_contract { + pub struct MyContract; + + impl MyContract { + #[pvm_contract_sdk::constructor] + #[pvm_contract_sdk::payable] + pub fn new(&mut self) {} + } +} diff --git a/crates/pvm-contract-macros/tests/test_abi_contract/src/multi_method.rs b/crates/pvm-contract-macros/tests/test_abi_contract/src/multi_method.rs index 3cae6d76..fa16818e 100644 --- a/crates/pvm-contract-macros/tests/test_abi_contract/src/multi_method.rs +++ b/crates/pvm-contract-macros/tests/test_abi_contract/src/multi_method.rs @@ -23,5 +23,14 @@ mod my_contract { pub fn get_count(&self) -> u64 { 0 } + + #[pvm_contract_sdk::method] + pub fn add(a: u64, b: u64) -> u64 { + a + b + } + + #[pvm_contract_sdk::method] + #[pvm_contract_sdk::payable] + pub fn deposit(&mut self) {} } } diff --git a/crates/pvm-contract-macros/tests/trait_dispatch.rs b/crates/pvm-contract-macros/tests/trait_dispatch.rs new file mode 100644 index 00000000..e4c3c762 --- /dev/null +++ b/crates/pvm-contract-macros/tests/trait_dispatch.rs @@ -0,0 +1,333 @@ +#![cfg(not(feature = "abi-gen"))] +//! End-to-end test for **trait-based dispatch**: methods declared in a +//! `#[interface_id]`-annotated trait and implemented via +//! `impl ITrait for Contract { ... }` are dispatched alongside inherent +//! methods when the trait is listed in `#[contract(implements(...))]`. +//! +//! This is the load-bearing piece for OpenZeppelin-style inheritance: each +//! extension is a `#[storage]` struct, the outer contract embeds it as a +//! field, and the contract's `IErc20` impl forwards to extension helpers +//! while the dispatch layer routes each selector to the appropriate trait +//! method through UFCS. +//! +//! What's exercised: +//! +//! - `#[pvm_contract_sdk::interface_id]` adds `interface_id() -> [u8; 4]` to +//! a trait and produces an ERC-165-compatible XOR of method selectors. +//! - `#[contract(implements(IErc20))]` declares the trait set. +//! - Methods provided by `impl IErc20 for MyToken { ... }` are dispatched +//! without `#[method]` attributes — the macro picks them up implicitly. +//! - Dispatch flows through UFCS (`::transfer(&mut this, …)`), +//! so inherent methods of the same name would not silently shadow trait +//! methods. +//! - Inherent methods still work alongside trait-impl methods. + +use pvm_contract_sdk::{Address, U256}; +use pvm_contract_types::{ + Host, MockHost, MockHostBuilder, ReturnFlags, SolEncode, StaticEncodedLen, +}; + +extern crate alloc; + +const BALANCE_PREFIX: u8 = 0xB1; + +fn balance_key(addr: Address) -> [u8; 32] { + let mut key = [0u8; 32]; + key[0] = BALANCE_PREFIX; + key[12..].copy_from_slice(addr.as_ref() as &[u8; 20]); + key +} + +// --------------------------------------------------------------------------- +// Trait declaration. The `#[interface_id]` macro adds `interface_id()` and is +// also the trait signature the contract impl must match. +// --------------------------------------------------------------------------- + +#[pvm_contract_sdk::interface_id] +pub trait IMiniErc20 { + /// Associated error type. + type Error; + + fn balance_of(&self, account: Address) -> U256; + fn transfer(&mut self, to: Address, value: U256) -> Result<(), Self::Error>; +} + +// --------------------------------------------------------------------------- +// Custom error so we exercise the Result-returning dispatch path. +// --------------------------------------------------------------------------- + +#[derive(Debug, pvm_contract_sdk::SolError)] +pub struct InsufficientBalance { + pub available: U256, + pub required: U256, +} + +// --------------------------------------------------------------------------- +// The contract itself, with the trait listed under `implements(...)`. +// +// The contract has: +// - One inherent `#[constructor]` (deploy entry). +// - One inherent `#[method]` (`mint`) — exercises that inherent methods still +// dispatch alongside trait methods. +// - One `impl IMiniErc20 for MiniErc20 { ... }` block — exercises trait +// dispatch. +// --------------------------------------------------------------------------- + +#[allow(dead_code)] // deploy()/call() are riscv64-gated; tests poke route() directly. +#[pvm_contract_sdk::contract(implements(IMiniErc20))] +mod mini_erc20 { + use super::*; + use pvm_contract_types::StorageFlags; + + pub struct MiniErc20; + + impl MiniErc20 { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) {} + + /// Inherent (non-trait) method — verifies the dispatch table mixes + /// inherent and trait methods. + #[pvm_contract_sdk::method] + pub fn mint(&mut self, to: Address, value: U256) { + let key = balance_key(to); + let mut buf = [0u8; 32]; + self.host() + .get_storage_or_zero(StorageFlags::empty(), &key, &mut buf); + let current = U256::from_be_bytes::<32>(buf); + let new = current + value; + self.host() + .set_storage(StorageFlags::empty(), &key, &new.to_be_bytes::<32>()); + } + } + + /// Trait impl — every fn here is dispatched implicitly because + /// `IMiniErc20` is in `implements(...)` on the `#[contract]` attribute. + impl super::IMiniErc20 for MiniErc20 { + type Error = super::InsufficientBalance; + + fn balance_of(&self, account: super::Address) -> super::U256 { + let key = super::balance_key(account); + let mut buf = [0u8; 32]; + self.host() + .get_storage_or_zero(StorageFlags::empty(), &key, &mut buf); + super::U256::from_be_bytes::<32>(buf) + } + + fn transfer( + &mut self, + to: super::Address, + value: super::U256, + ) -> Result<(), Self::Error> { + let mut caller_bytes = [0u8; 20]; + self.host().caller(&mut caller_bytes); + let from = super::Address::from(caller_bytes); + + let from_key = super::balance_key(from); + let mut buf = [0u8; 32]; + self.host() + .get_storage_or_zero(StorageFlags::empty(), &from_key, &mut buf); + let available = super::U256::from_be_bytes::<32>(buf); + + if available < value { + return Err(super::InsufficientBalance { + available, + required: value, + }); + } + + let new_from = available - value; + self.host().set_storage( + StorageFlags::empty(), + &from_key, + &new_from.to_be_bytes::<32>(), + ); + + let to_key = super::balance_key(to); + let mut buf = [0u8; 32]; + self.host() + .get_storage_or_zero(StorageFlags::empty(), &to_key, &mut buf); + let new_to = super::U256::from_be_bytes::<32>(buf) + value; + self.host() + .set_storage(StorageFlags::empty(), &to_key, &new_to.to_be_bytes::<32>()); + + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// Test harness — share storage between contract and assertion site. +// --------------------------------------------------------------------------- + +fn host_with_caller(caller: [u8; 20]) -> MockHost { + MockHostBuilder::new().caller(caller).build() +} + +fn make_contract(mock: &MockHost) -> mini_erc20::MiniErc20 { + mini_erc20::MiniErc20 { + host: Host::from_dyn(alloc::rc::Rc::new(mock.clone())), + } +} + +fn seed_balance(host: &MockHost, addr: [u8; 20], amount: U256) { + let key = balance_key(Address::from(addr)); + host.set_raw_storage(key.to_vec(), amount.to_be_bytes::<32>().to_vec()); +} + +fn selector(sig: &str) -> [u8; 4] { + pvm_contract_types::const_selector(sig) +} + +fn route_ok(contract: &mut mini_erc20::MiniErc20, mock: &MockHost, sel: [u8; 4], input: &[u8]) -> Vec { + let outcome = mini_erc20::route(contract, sel, input); + assert_eq!(outcome, Some(()), "expected matched selector"); + let rv = mock.take_return_value().expect("return_value was called"); + assert_eq!(rv.flags, ReturnFlags::empty(), "expected success flags"); + rv.data +} + +fn route_revert(contract: &mut mini_erc20::MiniErc20, mock: &MockHost, sel: [u8; 4], input: &[u8]) -> Vec { + let outcome = mini_erc20::route(contract, sel, input); + assert_eq!(outcome, Some(()), "expected matched selector"); + let rv = mock.take_return_value().expect("return_value was called"); + assert_eq!(rv.flags, ReturnFlags::REVERT, "expected REVERT flags"); + rv.data +} + +fn encode_addr(addr: Address) -> Vec { + let mut buf = vec![0u8;
::ENCODED_SIZE]; + addr.encode_to(&mut buf); + buf +} + +fn encode_addr_u256(addr: Address, value: U256) -> Vec { + const LEN: usize = +
::ENCODED_SIZE + ::ENCODED_SIZE; + let mut buf = vec![0u8; LEN]; + (addr, value).encode_to(&mut buf); + buf +} + +// --------------------------------------------------------------------------- +// Tests. +// --------------------------------------------------------------------------- + +const ALICE: [u8; 20] = [0xA1; 20]; +const BOB: [u8; 20] = [0xB0; 20]; + +#[test] +fn interface_id_method_is_available_on_the_trait() { + // The macro adds `interface_id() -> [u8; 4]` as a provided method. + // Cross-check by independently XOR'ing the two method selectors. + let id = ::interface_id(); + let sel_balance = selector("balanceOf(address)"); + let sel_transfer = selector("transfer(address,uint256)"); + let mut expected = [0u8; 4]; + for j in 0..4 { + expected[j] = sel_balance[j] ^ sel_transfer[j]; + } + assert_eq!(id, expected); +} + +#[test] +fn trait_balance_of_dispatches_through_ufcs() { + let mock = host_with_caller(ALICE); + seed_balance(&mock, ALICE, U256::from(1000)); + let mut contract = make_contract(&mock); + + // `balance_of` came from `impl IMiniErc20 for MiniErc20`. It's dispatched + // implicitly because `IMiniErc20` is in `implements(...)`. + let data = route_ok( + &mut contract, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(ALICE)), + ); + assert_eq!(data, U256::from(1000).to_be_bytes::<32>().to_vec()); +} + +#[test] +fn trait_transfer_moves_balance() { + let mock = host_with_caller(ALICE); + seed_balance(&mock, ALICE, U256::from(1000)); + let mut contract = make_contract(&mock); + + route_ok( + &mut contract, + &mock, + selector("transfer(address,uint256)"), + &encode_addr_u256(Address::from(BOB), U256::from(300)), + ); + + // Cross-check via `balanceOf` after the transfer. + let data = route_ok( + &mut contract, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(ALICE)), + ); + assert_eq!(data, U256::from(700).to_be_bytes::<32>().to_vec()); + + let data = route_ok( + &mut contract, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(BOB)), + ); + assert_eq!(data, U256::from(300).to_be_bytes::<32>().to_vec()); +} + +#[test] +fn trait_transfer_reverts_with_custom_error_when_insufficient_balance() { + let mock = host_with_caller(ALICE); + seed_balance(&mock, ALICE, U256::from(50)); + let mut contract = make_contract(&mock); + + let data = route_revert( + &mut contract, + &mock, + selector("transfer(address,uint256)"), + &encode_addr_u256(Address::from(BOB), U256::from(100)), + ); + + // Revert payload starts with the InsufficientBalance selector (per + // SolError::SELECTOR), then encodes (available, required). + let expected_sel = selector("InsufficientBalance(uint256,uint256)"); + assert_eq!(&data[..4], &expected_sel[..], "revert selector mismatch"); + + let available = U256::from_be_bytes::<32>({ + let mut b = [0u8; 32]; + b.copy_from_slice(&data[4..36]); + b + }); + let required = U256::from_be_bytes::<32>({ + let mut b = [0u8; 32]; + b.copy_from_slice(&data[36..68]); + b + }); + assert_eq!(available, U256::from(50)); + assert_eq!(required, U256::from(100)); +} + +#[test] +fn inherent_mint_method_still_dispatches_alongside_trait_methods() { + let mock = host_with_caller(ALICE); + let mut contract = make_contract(&mock); + + // `mint` is an inherent #[method] on MiniErc20 — separate from the trait + // impl, dispatched through method-call syntax. + let _ = route_ok( + &mut contract, + &mock, + selector("mint(address,uint256)"), + &encode_addr_u256(Address::from(BOB), U256::from(777)), + ); + + let data = route_ok( + &mut contract, + &mock, + selector("balanceOf(address)"), + &encode_addr(Address::from(BOB)), + ); + assert_eq!(data, U256::from(777).to_be_bytes::<32>().to_vec()); +} diff --git a/crates/pvm-contract-macros/tests/ui/constructor_no_receiver.rs b/crates/pvm-contract-macros/tests/ui/constructor_no_receiver.rs new file mode 100644 index 00000000..5bf4c195 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/constructor_no_receiver.rs @@ -0,0 +1,16 @@ +// Constructors must take `&mut self`. A no-receiver constructor is incoherent +// for the same reason as a `&self` constructor: it cannot write storage. + +#[pvm_contract_macros::contract] +mod c { + pub struct C; + + impl C { + #[pvm_contract_macros::constructor] + pub fn new() -> Result<(), pvm_contract_sdk::EmptyError> { + Ok(()) + } + } +} + +fn main() {} diff --git a/crates/pvm-contract-macros/tests/ui/constructor_no_receiver.stderr b/crates/pvm-contract-macros/tests/ui/constructor_no_receiver.stderr new file mode 100644 index 00000000..1384cef8 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/constructor_no_receiver.stderr @@ -0,0 +1,8 @@ +error: constructor must take `&mut self`; it always initializes storage + --> tests/ui/constructor_no_receiver.rs:9:9 + | + 9 | / #[pvm_contract_macros::constructor] +10 | | pub fn new() -> Result<(), pvm_contract_sdk::EmptyError> { +11 | | Ok(()) +12 | | } + | |_________^ diff --git a/crates/pvm-contract-macros/tests/ui/constructor_with_ref_self.rs b/crates/pvm-contract-macros/tests/ui/constructor_with_ref_self.rs new file mode 100644 index 00000000..f771609c --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/constructor_with_ref_self.rs @@ -0,0 +1,16 @@ +// Constructors must take `&mut self` — they always initialize storage. A +// `&self` constructor cannot mutate, so it would be a useless entry point. + +#[pvm_contract_macros::contract] +mod c { + pub struct C; + + impl C { + #[pvm_contract_macros::constructor] + pub fn new(&self) -> Result<(), pvm_contract_sdk::EmptyError> { + Ok(()) + } + } +} + +fn main() {} diff --git a/crates/pvm-contract-macros/tests/ui/constructor_with_ref_self.stderr b/crates/pvm-contract-macros/tests/ui/constructor_with_ref_self.stderr new file mode 100644 index 00000000..156ca8e5 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/constructor_with_ref_self.stderr @@ -0,0 +1,8 @@ +error: constructor must take `&mut self`; it always initializes storage + --> tests/ui/constructor_with_ref_self.rs:9:9 + | + 9 | / #[pvm_contract_macros::constructor] +10 | | pub fn new(&self) -> Result<(), pvm_contract_sdk::EmptyError> { +11 | | Ok(()) +12 | | } + | |_________^ diff --git a/crates/pvm-contract-macros/tests/ui/fixtures/CrossContract.sol b/crates/pvm-contract-macros/tests/ui/fixtures/CrossContract.sol new file mode 100644 index 00000000..a97f0cd3 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/fixtures/CrossContract.sol @@ -0,0 +1,4 @@ +interface CrossContract { + function getValue() external view returns (uint256); + function setValue(uint256 v) external; +} diff --git a/crates/pvm-contract-macros/tests/ui/payable_on_no_receiver_method.rs b/crates/pvm-contract-macros/tests/ui/payable_on_no_receiver_method.rs new file mode 100644 index 00000000..00c73b13 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/payable_on_no_receiver_method.rs @@ -0,0 +1,15 @@ +// `#[payable]` requires `&mut self`. An associated function (no receiver) +// is `pure` — it has no host access, so accepting value is incoherent. + +#[pvm_contract_macros::contract] +mod c { + pub struct C; + + impl C { + #[pvm_contract_macros::method] + #[pvm_contract_macros::payable] + pub fn deposit() {} + } +} + +fn main() {} diff --git a/crates/pvm-contract-macros/tests/ui/payable_on_no_receiver_method.stderr b/crates/pvm-contract-macros/tests/ui/payable_on_no_receiver_method.stderr new file mode 100644 index 00000000..d71f883b --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/payable_on_no_receiver_method.stderr @@ -0,0 +1,7 @@ +error: associated function (no `self` receiver) cannot be marked `#[payable]`; payable callables must take `&mut self` + --> tests/ui/payable_on_no_receiver_method.rs:9:9 + | + 9 | / #[pvm_contract_macros::method] +10 | | #[pvm_contract_macros::payable] +11 | | pub fn deposit() {} + | |___________________________^ diff --git a/crates/pvm-contract-macros/tests/ui/payable_on_ref_self_method.rs b/crates/pvm-contract-macros/tests/ui/payable_on_ref_self_method.rs new file mode 100644 index 00000000..527b3497 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/payable_on_ref_self_method.rs @@ -0,0 +1,15 @@ +// `#[payable]` requires `&mut self`. A `&self` receiver implies `view`, +// and view methods cannot mutate storage to record the received value. + +#[pvm_contract_macros::contract] +mod c { + pub struct C; + + impl C { + #[pvm_contract_macros::method] + #[pvm_contract_macros::payable] + pub fn deposit(&self) {} + } +} + +fn main() {} diff --git a/crates/pvm-contract-macros/tests/ui/payable_on_ref_self_method.stderr b/crates/pvm-contract-macros/tests/ui/payable_on_ref_self_method.stderr new file mode 100644 index 00000000..a1ba8a27 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/payable_on_ref_self_method.stderr @@ -0,0 +1,7 @@ +error: method is marked `#[payable]` but takes `&self`; payable callables must take `&mut self` + --> tests/ui/payable_on_ref_self_method.rs:9:9 + | + 9 | / #[pvm_contract_macros::method] +10 | | #[pvm_contract_macros::payable] +11 | | pub fn deposit(&self) {} + | |________________________________^ diff --git a/crates/pvm-contract-macros/tests/ui/sol_nonpayable_rust_has_payable_attr.stderr b/crates/pvm-contract-macros/tests/ui/sol_nonpayable_rust_has_payable_attr.stderr index 70a9e285..a26a1cd9 100644 --- a/crates/pvm-contract-macros/tests/ui/sol_nonpayable_rust_has_payable_attr.stderr +++ b/crates/pvm-contract-macros/tests/ui/sol_nonpayable_rust_has_payable_attr.stderr @@ -1,4 +1,4 @@ -error: method 'transfer' is not declared payable in the Solidity interface but the Rust signature is marked `#[payable]` +error: method `transfer` mutability mismatch: `.sol` declares `nonpayable`, Rust signature is `payable`. remove `#[payable]`. --> tests/ui/sol_nonpayable_rust_has_payable_attr.rs:6:9 | 6 | / #[pvm_contract_macros::method] diff --git a/crates/pvm-contract-macros/tests/ui/sol_payable_rust_missing_payable_attr.stderr b/crates/pvm-contract-macros/tests/ui/sol_payable_rust_missing_payable_attr.stderr index d62eeee0..67c1b3c3 100644 --- a/crates/pvm-contract-macros/tests/ui/sol_payable_rust_missing_payable_attr.stderr +++ b/crates/pvm-contract-macros/tests/ui/sol_payable_rust_missing_payable_attr.stderr @@ -1,4 +1,4 @@ -error: method 'deposit' is declared payable in the Solidity interface but the Rust signature is not marked `#[payable]` +error: method `deposit` mutability mismatch: `.sol` declares `payable`, Rust signature is `nonpayable`. add `#[payable]`. --> tests/ui/sol_payable_rust_missing_payable_attr.rs:6:9 | 6 | / #[pvm_contract_macros::method] diff --git a/crates/pvm-contract-macros/tests/ui/storage_struct_derives_clone.rs b/crates/pvm-contract-macros/tests/ui/storage_struct_derives_clone.rs new file mode 100644 index 00000000..4433771a --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/storage_struct_derives_clone.rs @@ -0,0 +1,23 @@ +// The contract storage struct must not derive `Clone`. The mutation gate +// (`&self` vs `&mut self`) relies on `Storage: !Clone` so that a view method +// cannot smuggle out a `&mut Storage` via cloning. + +#[pvm_contract_macros::contract] +mod c { + #[derive(Clone)] + pub struct C; + + impl C { + #[pvm_contract_macros::constructor] + pub fn new(&mut self) -> Result<(), pvm_contract_sdk::EmptyError> { + Ok(()) + } + + #[pvm_contract_macros::method] + pub fn balance(&self) -> pvm_contract_sdk::U256 { + pvm_contract_sdk::U256::ZERO + } + } +} + +fn main() {} diff --git a/crates/pvm-contract-macros/tests/ui/storage_struct_derives_clone.stderr b/crates/pvm-contract-macros/tests/ui/storage_struct_derives_clone.stderr new file mode 100644 index 00000000..9efa7dcb --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/storage_struct_derives_clone.stderr @@ -0,0 +1,5 @@ +error: contract storage structs must not derive `Clone`; the mutation gate (`&self` vs `&mut self`) relies on `Storage: !Clone` to prevent view methods from smuggling out a `&mut Storage` + --> tests/ui/storage_struct_derives_clone.rs:7:5 + | +7 | #[derive(Clone)] + | ^^^^^^^^^^^^^^^^ diff --git a/crates/pvm-contract-macros/tests/ui/view_caller_calls_nonpayable_callee.rs b/crates/pvm-contract-macros/tests/ui/view_caller_calls_nonpayable_callee.rs new file mode 100644 index 00000000..f11ff810 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/view_caller_calls_nonpayable_callee.rs @@ -0,0 +1,45 @@ +// A `view` (`&self`) method must not be able to invoke a `nonpayable` +// callee through the typed cross-contract API. The borrow check rejects +// passing `&self` where `&mut impl ContractContext` is required. + +extern crate alloc; + +pvm_contract_sdk::abi_import! { + #![abi_import(alloc = true)] + // SPDX-License-Identifier: MIT + pragma solidity ^0.8.0; + + interface CrossContract { + function getValue() external view returns (uint256); + function setValue(uint256 v) external; + } +} + +#[pvm_contract_sdk::contract] +mod caller { + use pvm_contract_sdk::*; + use super::*; + use cross_contract::CrossContract; + + pub struct Caller; + + impl Caller { + #[pvm_contract_sdk::constructor] + pub fn new(&mut self) -> Result<(), EmptyError> { Ok(()) } + + // The misuse: `&self` (view) caller tries to call the + // `nonpayable` callee `setValue`. The `set_value` builder method + // requires `&mut impl ContractContext`; we only have `&Self`. + #[pvm_contract_sdk::method] + pub fn cheat(&self, addr: Address) -> Result<(), CallError> { + let cb = CrossContract::from_address(addr).set_value(U256::ZERO); + cb.call(self)?; + Ok(()) + } + + #[pvm_contract_sdk::fallback] + pub fn fallback(&mut self) -> Result<(), EmptyError> { Ok(()) } + } +} + +fn main() {} diff --git a/crates/pvm-contract-macros/tests/ui/view_caller_calls_nonpayable_callee.stderr b/crates/pvm-contract-macros/tests/ui/view_caller_calls_nonpayable_callee.stderr new file mode 100644 index 00000000..e75c8004 --- /dev/null +++ b/crates/pvm-contract-macros/tests/ui/view_caller_calls_nonpayable_callee.stderr @@ -0,0 +1,21 @@ +error[E0308]: mismatched types + --> tests/ui/view_caller_calls_nonpayable_callee.rs:36:21 + | +36 | cb.call(self)?; + | ---- ^^^^ types differ in mutability + | | + | arguments to this method are incorrect + | + = note: expected mutable reference `&mut _` + found reference `&Caller` +note: method defined here + --> tests/ui/view_caller_calls_nonpayable_callee.rs:7:1 + | + 7 | / pvm_contract_sdk::abi_import! { + 8 | | #![abi_import(alloc = true)] + 9 | | // SPDX-License-Identifier: MIT +10 | | pragma solidity ^0.8.0; +... | +16 | | } + | |_^ + = note: this error originates in the macro `pvm_contract_sdk::abi_import` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/pvm-contract-sdk/Cargo.toml b/crates/pvm-contract-sdk/Cargo.toml index 8f9c0a5a..12079fd0 100644 --- a/crates/pvm-contract-sdk/Cargo.toml +++ b/crates/pvm-contract-sdk/Cargo.toml @@ -16,6 +16,6 @@ pvm-storage = { path = "../pvm-storage", default-features = false } [features] default = [] -alloc = ["pvm-contract-types/alloc"] -std = ["alloc", "pvm-contract-types/std"] +alloc = ["pvm-contract-types/alloc", "pvm-storage/alloc"] +std = ["alloc", "pvm-contract-types/std", "pvm-storage/std"] abi-gen = ["pvm-contract-types/abi-gen", "pvm-contract-macros/abi-gen", "pvm-storage/abi-gen"] diff --git a/crates/pvm-contract-sdk/src/lib.rs b/crates/pvm-contract-sdk/src/lib.rs index 82f3de46..05a3338f 100644 --- a/crates/pvm-contract-sdk/src/lib.rs +++ b/crates/pvm-contract-sdk/src/lib.rs @@ -43,7 +43,8 @@ extern crate self as pvm_contract_sdk; // --------------------------------------------------------------------------- pub use pvm_contract_macros::{ - SolError, SolType, abi_import, constructor, contract, fallback, method, payable, + SolError, SolType, abi_import, constructor, contract, fallback, interface_id, method, payable, + storage, }; // --------------------------------------------------------------------------- @@ -68,6 +69,9 @@ pub use pvm_contract_types::{ CallFlags, // Encoding / decoding ConstStr, + Context, + // Mutation gating + ContractContext, // Error traits and types EmptyError, Host, @@ -98,6 +102,11 @@ pub use pvm_contract_types::{ value_transferred_is_nonzero, }; +/// Sealing module re-exported for the `#[contract]` macro to implement on +/// generated storage structs. External users have no reason to import this. +#[doc(hidden)] +pub use pvm_contract_types::__private; + // Cross-contract calls pub use pvm_contract_core::call::{ CallBuilder, CallError, CallLimits, NonPayable, Payable, Pure, RefTimeAndProofSizeLimits, @@ -105,8 +114,10 @@ pub use pvm_contract_core::call::{ }; // Typed storage helpers. `Lazy`/`Mapping` are the declarable field types for -// `#[slot(N)]` fields on the contract struct. -pub use pvm_storage::{AsStorageKey, Lazy, Mapping, StorageKey}; +// storage fields on the contract struct. `SlotValue` is the trait values must +// implement to live in a slot; `StorageComponent` is the trait typed storage +// helpers implement to participate in auto-numbered slot layout. +pub use pvm_storage::{AsStorageKey, Lazy, Mapping, SlotValue, StorageComponent, StorageKey}; #[cfg(feature = "abi-gen")] pub use pvm_storage::StorageLayoutType; diff --git a/crates/pvm-contract-types/src/host.rs b/crates/pvm-contract-types/src/host.rs index 52a360f2..1e625e88 100644 --- a/crates/pvm-contract-types/src/host.rs +++ b/crates/pvm-contract-types/src/host.rs @@ -28,6 +28,64 @@ pub use pallet_revive_uapi::{CallFlags, ReturnErrorCode, ReturnFlags, StorageFla /// Result type for host operations that can fail. pub type HostResult = core::result::Result<(), ReturnErrorCode>; +/// Marker trait identifying the contract storage root. +/// +/// The `#[contract]` macro auto-implements this on the generated storage +/// struct; the DSL implements it on its dispatch root. Cross-contract call +/// builders are bound `&impl ContractContext` (for `View`/`Pure` callees) or +/// `&mut impl ContractContext` (for `NonPayable`/`Payable` callees), so the +/// borrow checker — not just the runtime — rejects view methods that try to +/// initiate a state-mutating cross-contract call. +/// +/// Sealed via [`crate::__private::Sealed`]: external code cannot implement +/// `ContractContext` for arbitrary types, so the gate cannot be smuggled past +/// by user-provided "fake roots". +pub trait ContractContext: crate::__private::Sealed { + /// Borrow the contract's host handle. + /// + /// The borrow on `Self` is the load-bearing piece of the gate; the host + /// returned here is then used internally by the call builder. + fn host(&self) -> &Host; +} + +/// Stateless [`ContractContext`] root. +/// +/// Wraps a [`Host`] and implements [`ContractContext`] so cross-contract call +/// builders (which require `&impl ContractContext` / `&mut impl ContractContext`) +/// can be invoked outside the `#[contract]` macro's storage struct — from DSL +/// handlers (wrap the dispatcher-provided `&Host` via `Context::new(host.clone())`) +/// and from `#[test]` functions backed by a `MockHost`. +/// +/// `Host` is `Copy` on `riscv64` (ZST) and `Clone` on host targets (one +/// `Rc::clone`), so the owned shape costs nothing in production. +/// +/// **Not `Clone`** — same gating contract as the macro-generated storage +/// struct: a `&self` method that gets `&Context` cannot smuggle out a +/// `&mut Context` via cloning. The DSL path is still the "manual control" +/// surface: a handler holds the owned `Context` locally, so it can freely +/// construct both `&cx` and `&mut cx` from the same binding. If you need +/// the static view-vs-mutating guarantee, use the `#[contract]` macro path. +pub struct Context { + pub host: Host, +} + +impl Context { + /// Construct a new context from an owned host handle. + #[inline(always)] + pub fn new(host: Host) -> Self { + Self { host } + } +} + +impl crate::__private::Sealed for Context {} + +impl ContractContext for Context { + #[inline(always)] + fn host(&self) -> &Host { + &self.host + } +} + /// Receiver-based host API. /// /// Every method takes `&self` — `PolkaVmHost` is a zero-sized type, so this diff --git a/crates/pvm-contract-types/src/lib.rs b/crates/pvm-contract-types/src/lib.rs index 3567e074..e2af2915 100644 --- a/crates/pvm-contract-types/src/lib.rs +++ b/crates/pvm-contract-types/src/lib.rs @@ -25,9 +25,18 @@ pub use serde_json; mod host; pub use host::{ - CallFlags, Host, HostApi, HostResult, PolkaVmHost, ReturnErrorCode, ReturnFlags, StorageFlags, + CallFlags, Context, ContractContext, Host, HostApi, HostResult, PolkaVmHost, ReturnErrorCode, + ReturnFlags, StorageFlags, }; +/// Sealing marker for traits that should only be implemented by code in this +/// workspace (specifically: macro-generated contract structs and the DSL +/// dispatch root). External users have no reason to import this module. +#[doc(hidden)] +pub mod __private { + pub trait Sealed {} +} + /// Re-exported so macro-generated `call()` / `deploy()` wrappers can reach it /// without the user's `Cargo.toml` depending on `pallet-revive-uapi` directly. #[doc(hidden)] @@ -230,7 +239,7 @@ pub fn value_transferred_is_nonzero(host: &H) -> bool { /// Selector-based dispatch trait for composable `#[contract]` routing. /// -/// Each contract module gets a generated `impl Router for Contract` +/// Each contract module gets a generated `impl Router for Contract` /// that delegates to a free `mod_name::route(this, selector, input)` function. /// Dispatch arms call `host.return_value(...)` directly — `-> !` on `riscv64` /// (terminates execution), `-> ()` on host targets (captures into @@ -249,7 +258,7 @@ pub fn value_transferred_is_nonzero(host: &H) -> bool { /// // fallback or revert /// } /// ``` -pub trait Router { +pub trait Router { /// Dispatch `selector` against `input`. Returns `Some(())` if the selector /// was handled (the dispatch arm has already called `host.return_value(...)`, /// which on `riscv64` means execution has terminated). Returns `None` if diff --git a/crates/pvm-contract-types/src/mock_host.rs b/crates/pvm-contract-types/src/mock_host.rs index c48b8c66..aa417dd0 100644 --- a/crates/pvm-contract-types/src/mock_host.rs +++ b/crates/pvm-contract-types/src/mock_host.rs @@ -179,6 +179,13 @@ pub struct MockHost { } impl MockHost { + /// Change the caller (`msg.sender` analogue) used by subsequent host + /// calls. Useful for tests that exercise auth-gated methods from multiple + /// EOAs without rebuilding the whole mock (and losing storage state). + pub fn set_caller(&self, caller: [u8; 20]) { + self.state.borrow_mut().caller = caller; + } + /// Register a mock return value for [`HostApi::call`] to `callee`. pub fn mock_call(&self, callee: [u8; 20], result: MockCallReturn) { self.state.borrow_mut().call_returns.insert(callee, result); diff --git a/crates/pvm-storage/Cargo.toml b/crates/pvm-storage/Cargo.toml index ac9a6479..2c1efb9e 100644 --- a/crates/pvm-storage/Cargo.toml +++ b/crates/pvm-storage/Cargo.toml @@ -16,5 +16,6 @@ trybuild = { workspace = true } [features] default = [] -std = [] +alloc = ["pvm-contract-types/alloc"] +std = ["alloc"] abi-gen = ["std"] diff --git a/crates/pvm-storage/src/lib.rs b/crates/pvm-storage/src/lib.rs index c9c1ae4c..257b50bf 100644 --- a/crates/pvm-storage/src/lib.rs +++ b/crates/pvm-storage/src/lib.rs @@ -4,25 +4,41 @@ //! storage, both using Solidity-compatible key derivation so tools like `cast storage` //! and `cast index` work out of the box. //! +//! The crate is built on two layered abstractions: +//! +//! - [`SlotValue`] — *how a value lives in storage*. Static-encoded leaves (32-byte +//! types) put their value directly in the slot; dynamic-encoded leaves +//! (`String`, `Vec`) put a length in the slot and the payload at a derived +//! key, matching the layout `solc` emits for the same Solidity type. +//! - [`StorageComponent`] — *how a typed storage object claims root slots*. +//! `Lazy` and `Mapping` each claim one slot; user-defined storage +//! structs (marked with `#[storage]`) claim the sum of their fields. The +//! `#[contract]` macro uses [`StorageComponent::SLOTS`] to auto-number the +//! contract struct's fields, so explicit `#[slot(N)]` is optional. +//! //! # Usage //! -//! Inside a `#[contract]` module, declare `#[slot(N)]` fields on the contract -//! struct. The macro handles construction automatically. Standalone usage: +//! Inside a `#[contract]` module, declare storage fields on the contract struct. +//! Slot numbers are assigned in declaration order by default; opt out with +//! `#[slot(N)]` if you need to pin a specific slot. //! //! ```ignore //! use pvm_storage::{Lazy, Mapping, StorageKey}; //! -//! let mut total_supply = Lazy::::new(StorageKey::from_slot(0)); +//! let mut total_supply = Lazy::::new(StorageKey::from_slot(0), host.clone()); //! total_supply.set(&U256::from(1000)); //! assert_eq!(total_supply.get(), U256::from(1000)); //! -//! let mut balances = Mapping::::new(StorageKey::from_slot(1)); +//! let mut balances = Mapping::::new(StorageKey::from_slot(1), host); //! balances.insert(&caller, &U256::from(500)); //! assert_eq!(balances.get(&caller), U256::from(500)); //! ``` #![cfg_attr(not(feature = "std"), no_std)] +#[cfg(feature = "alloc")] +extern crate alloc; + // Alias so that macro-generated `::pvm_contract_sdk::` paths resolve // within this crate's own tests. Same pattern as pvm-contract-types. extern crate self as pvm_contract_sdk; @@ -65,6 +81,29 @@ fn storage_try_get_32(host: &Host, key: &[u8; 32]) -> Option<[u8; 32]> { } } +/// Hash a 32-byte slot to produce the data root for a dynamic value +/// (`keccak256(slot)`). This matches Solidity's layout for `bytes`, `string`, +/// and arrays. +#[cfg(feature = "alloc")] +fn dynamic_data_root(host: &Host, slot: &[u8; 32]) -> [u8; 32] { + let mut output = [0u8; 32]; + host.hash_keccak_256(slot, &mut output); + output +} + +/// Increment a 32-byte big-endian integer in-place (used to walk consecutive +/// storage slots for the body of dynamic values). +#[cfg(feature = "alloc")] +fn inc_slot(slot: &mut [u8; 32]) { + for byte in slot.iter_mut().rev() { + let (next, carry) = byte.overflowing_add(1); + *byte = next; + if !carry { + return; + } + } +} + // --------------------------------------------------------------------------- // StorageKey // --------------------------------------------------------------------------- @@ -93,6 +132,13 @@ impl StorageKey { StorageKey(key) } + /// Construct from raw 32 bytes. Internal: callers must ensure the bytes + /// already represent a valid slot identifier. + #[doc(hidden)] + pub const fn from_raw(bytes: [u8; 32]) -> Self { + StorageKey(bytes) + } + /// Derive a mapping child key following Solidity's key derivation convention. /// /// For scalar keys: `keccak256(pad32(key) ++ self)` (one keccak). @@ -203,6 +249,345 @@ impl_tuple_storage_key!(A: 0, B: 1, C: 2); impl_tuple_storage_key!(A: 0, B: 1, C: 2, D: 3); impl_tuple_storage_key!(A: 0, B: 1, C: 2, D: 3, E: 4); +// --------------------------------------------------------------------------- +// SlotValue: how a Rust type lives in (or under) a single storage slot. +// --------------------------------------------------------------------------- + +/// Encode/decode a Rust value into a Solidity-compatible storage cell rooted +/// at a single slot. +/// +/// Two layout families share this trait: +/// +/// - **Static** (`U256`, `Address`, `bool`, `[u8; 32]`, etc.): the entire +/// value lives in the slot itself. +/// - **Dynamic** (`String`, `Vec`): the slot stores layout metadata +/// (length) and the payload lives at slots derived from +/// `keccak256(slot)`, matching `solc`'s layout for the corresponding +/// Solidity type. +/// +/// Implementors describe both directions. The `Lazy` and `Mapping` +/// wrappers route their `get` / `set` / `clear` calls through these methods. +/// +/// The trait is sealed at the SDK boundary: external crates implement it via +/// `pvm-contract-types`' encoding impls (`SolEncode + SolDecode + StaticEncodedLen` +/// for static, plus the alloc-gated blanket impls for `String` / `Vec` in +/// this crate). Future additions (e.g. user-defined wrapper types) will gain +/// blanket impls here rather than ad-hoc unsafe code in consumer crates. +pub trait SlotValue: Sized { + /// Read the value from a storage slot. Returns the zero/default value if + /// the slot was never written (matching Solidity semantics). + fn slot_get(host: &Host, slot: &StorageKey) -> Self; + + /// Read the value, distinguishing "never written" from "has been set." + /// Returns `None` if the slot was never written or has been cleared back + /// to the zero value. + fn slot_try_get(host: &Host, slot: &StorageKey) -> Option; + + /// Write the value to a storage slot. + fn slot_set(&self, host: &Host, slot: &StorageKey); + + /// Clear the storage slot (delete the entry). + fn slot_clear(host: &Host, slot: &StorageKey); +} + +/// Internal helper: read a static-encoded value from a single 32-byte slot. +/// +/// Used by the per-type `SlotValue` impls produced by [`impl_static_slot_value!`]. +/// Generic over any `SolDecode` type whose canonical encoding is 32 bytes. +fn static_slot_get(host: &Host, slot: &StorageKey) -> T { + let buf = storage_get_32(host, slot.as_bytes()); + T::decode(&buf) +} + +fn static_slot_try_get(host: &Host, slot: &StorageKey) -> Option { + storage_try_get_32(host, slot.as_bytes()).map(|buf| T::decode(&buf)) +} + +fn static_slot_set(value: &T, host: &Host, slot: &StorageKey) { + let mut buf = [0u8; 32]; + SolEncode::encode_body_to(value, &mut buf); + storage_set_32(host, slot.as_bytes(), &buf); +} + +fn static_slot_clear(host: &Host, slot: &StorageKey) { + storage_set_32(host, slot.as_bytes(), &[0u8; 32]); +} + +/// Implement [`SlotValue`] for each 32-byte static type. +/// +/// Per-type impl rather than a blanket `impl` because the +/// blanket form forbids us from later adding a *different* `SlotValue` impl +/// for `String` / `Vec` (which DO implement `StaticEncodedLen` via the +/// `alloc` feature, but encode as Solidity `bytes`/`string` — a fundamentally +/// different storage layout). The const-assert guards against accidentally +/// listing a non-32-byte type here. +macro_rules! impl_static_slot_value { + ($($ty:ty),* $(,)?) => {$( + impl SlotValue for $ty { + fn slot_get(host: &Host, slot: &StorageKey) -> Self { + const { + assert!( + <$ty as StaticEncodedLen>::ENCODED_SIZE == 32, + "static SlotValue requires a 32-byte ABI encoding" + ); + } + static_slot_get::<$ty>(host, slot) + } + + fn slot_try_get(host: &Host, slot: &StorageKey) -> Option { + static_slot_try_get::<$ty>(host, slot) + } + + fn slot_set(&self, host: &Host, slot: &StorageKey) { + static_slot_set::<$ty>(self, host, slot) + } + + fn slot_clear(host: &Host, slot: &StorageKey) { + static_slot_clear(host, slot) + } + } + )*}; +} + +// Mirrors the list in `impl_scalar_storage_key!` above — all 32-byte scalar +// types from `pvm-contract-types`. +impl_static_slot_value!( + U256, u128, u64, u32, u16, u8, // Signed integers + I256, i128, i64, i32, i16, i8, // Other value types + bool, Address, +); + +// Fixed-size byte arrays [u8; N] encode as Solidity `bytesN` (left-aligned, 32 bytes). +impl SlotValue for [u8; N] { + fn slot_get(host: &Host, slot: &StorageKey) -> Self { + const { + assert!( + N <= 32, + "static SlotValue requires a 32-byte ABI encoding ([u8; N] with N <= 32)" + ); + } + static_slot_get::<[u8; N]>(host, slot) + } + + fn slot_try_get(host: &Host, slot: &StorageKey) -> Option { + static_slot_try_get::<[u8; N]>(host, slot) + } + + fn slot_set(&self, host: &Host, slot: &StorageKey) { + static_slot_set::<[u8; N]>(self, host, slot) + } + + fn slot_clear(host: &Host, slot: &StorageKey) { + static_slot_clear(host, slot) + } +} + +// --------------------------------------------------------------------------- +// Dynamic SlotValue impls (alloc-gated): bytes/string Solidity layout. +// +// Layout matches solc for `bytes`/`string` at storage slot S: +// slot[S] = encoded length (bytes as right-aligned big-endian +// integer; short-string optimization is intentionally +// NOT implemented — see comment below). +// slot[keccak256(S) + i] = 32-byte body chunks, big-endian. +// +// We deliberately *skip* solc's "short string optimization" (where bytes <= 31 +// pack length+data into the slot itself). The optimization is incompatible +// with how `set_storage_or_clear` decides whether to delete: solc encodes the +// length in the low byte, but our static-value path always treats the slot as +// a 32-byte big-endian integer. Storing the full length in the slot lets +// `try_get` distinguish "never written" from "empty value" reliably. +// `cast storage` and similar tooling still read this correctly; only the +// gas-saving short-string fast path differs from Solidity. +// --------------------------------------------------------------------------- + +#[cfg(feature = "alloc")] +fn dynamic_bytes_set(host: &Host, slot: &StorageKey, data: &[u8]) { + // Write the length to the header slot. + let mut length_buf = [0u8; 32]; + let len_bytes = (data.len() as u64).to_be_bytes(); + length_buf[24..32].copy_from_slice(&len_bytes); + storage_set_32(host, slot.as_bytes(), &length_buf); + + if data.is_empty() { + return; + } + + // Write the body, 32 bytes per slot, starting at keccak256(slot). + let mut body_slot = dynamic_data_root(host, slot.as_bytes()); + let mut offset = 0usize; + while offset < data.len() { + let mut chunk = [0u8; 32]; + let remaining = data.len() - offset; + let take = if remaining >= 32 { 32 } else { remaining }; + chunk[..take].copy_from_slice(&data[offset..offset + take]); + storage_set_32(host, &body_slot, &chunk); + offset += take; + inc_slot(&mut body_slot); + } +} + +#[cfg(feature = "alloc")] +fn dynamic_bytes_get(host: &Host, slot: &StorageKey) -> alloc::vec::Vec { + let length_buf = storage_get_32(host, slot.as_bytes()); + let length = u64::from_be_bytes([ + length_buf[24], + length_buf[25], + length_buf[26], + length_buf[27], + length_buf[28], + length_buf[29], + length_buf[30], + length_buf[31], + ]) as usize; + if length == 0 { + return alloc::vec::Vec::new(); + } + let mut out = alloc::vec::Vec::with_capacity(length); + let mut body_slot = dynamic_data_root(host, slot.as_bytes()); + let mut remaining = length; + while remaining > 0 { + let chunk = storage_get_32(host, &body_slot); + let take = if remaining >= 32 { 32 } else { remaining }; + out.extend_from_slice(&chunk[..take]); + remaining -= take; + inc_slot(&mut body_slot); + } + out +} + +#[cfg(feature = "alloc")] +fn dynamic_bytes_try_get(host: &Host, slot: &StorageKey) -> Option> { + let length_buf = storage_try_get_32(host, slot.as_bytes())?; + let length = u64::from_be_bytes([ + length_buf[24], + length_buf[25], + length_buf[26], + length_buf[27], + length_buf[28], + length_buf[29], + length_buf[30], + length_buf[31], + ]) as usize; + if length == 0 { + return Some(alloc::vec::Vec::new()); + } + let mut out = alloc::vec::Vec::with_capacity(length); + let mut body_slot = dynamic_data_root(host, slot.as_bytes()); + let mut remaining = length; + while remaining > 0 { + let chunk = storage_get_32(host, &body_slot); + let take = if remaining >= 32 { 32 } else { remaining }; + out.extend_from_slice(&chunk[..take]); + remaining -= take; + inc_slot(&mut body_slot); + } + Some(out) +} + +#[cfg(feature = "alloc")] +fn dynamic_bytes_clear(host: &Host, slot: &StorageKey) { + // Read current length first so we know how many body slots to clear. + let length_buf = storage_get_32(host, slot.as_bytes()); + let length = u64::from_be_bytes([ + length_buf[24], + length_buf[25], + length_buf[26], + length_buf[27], + length_buf[28], + length_buf[29], + length_buf[30], + length_buf[31], + ]) as usize; + + // Clear the header. + storage_set_32(host, slot.as_bytes(), &[0u8; 32]); + + if length == 0 { + return; + } + + let mut body_slot = dynamic_data_root(host, slot.as_bytes()); + let chunks = length.div_ceil(32); + for _ in 0..chunks { + storage_set_32(host, &body_slot, &[0u8; 32]); + inc_slot(&mut body_slot); + } +} + +#[cfg(feature = "alloc")] +impl SlotValue for alloc::vec::Vec { + fn slot_get(host: &Host, slot: &StorageKey) -> Self { + dynamic_bytes_get(host, slot) + } + + fn slot_try_get(host: &Host, slot: &StorageKey) -> Option { + dynamic_bytes_try_get(host, slot) + } + + fn slot_set(&self, host: &Host, slot: &StorageKey) { + dynamic_bytes_set(host, slot, self); + } + + fn slot_clear(host: &Host, slot: &StorageKey) { + dynamic_bytes_clear(host, slot); + } +} + +#[cfg(feature = "alloc")] +impl SlotValue for alloc::string::String { + fn slot_get(host: &Host, slot: &StorageKey) -> Self { + let bytes = dynamic_bytes_get(host, slot); + // Solidity `string` is UTF-8 by convention; we tolerate invalid bytes + // by replacing them, matching how alloy decodes string return data. + alloc::string::String::from_utf8_lossy(&bytes).into_owned() + } + + fn slot_try_get(host: &Host, slot: &StorageKey) -> Option { + let bytes = dynamic_bytes_try_get(host, slot)?; + Some(alloc::string::String::from_utf8_lossy(&bytes).into_owned()) + } + + fn slot_set(&self, host: &Host, slot: &StorageKey) { + dynamic_bytes_set(host, slot, self.as_bytes()); + } + + fn slot_clear(host: &Host, slot: &StorageKey) { + dynamic_bytes_clear(host, slot); + } +} + +// --------------------------------------------------------------------------- +// StorageComponent: how a typed storage object claims root slots. +// --------------------------------------------------------------------------- + +/// A typed storage helper that occupies one or more contiguous root slots. +/// +/// Implementations: +/// +/// - [`Lazy`] — 1 slot. +/// - [`Mapping`] — 1 slot (the root; entries live at derived keys). +/// - user storage structs annotated with `#[storage]` — sum of their fields' +/// `SLOTS`, assigned in declaration order. +/// +/// The `#[contract]` macro reads `SLOTS` to assign slot numbers to fields. The +/// macro-generated constructor calls [`StorageComponent::new_at`] with the +/// assigned base slot and a clone of the contract's host handle. +/// +/// # Why a separate trait from [`SlotValue`] +/// +/// `SlotValue` describes how a *Rust value* lives in storage; `StorageComponent` +/// describes how a *typed storage helper* is constructed. `U256` implements +/// `SlotValue` (it can be stored), but never `StorageComponent` — you never put +/// a `U256` directly on a contract struct; you put a `Lazy` there. +pub trait StorageComponent: Sized { + /// Number of root storage slots claimed by this component. + const SLOTS: u64; + + /// Construct the component rooted at `slot`, bound to `host`. + fn new_at(slot: u64, host: Host) -> Self; +} + // --------------------------------------------------------------------------- // StorageLayoutType: for storageLayout JSON generation (abi-gen only) // --------------------------------------------------------------------------- @@ -250,23 +635,18 @@ impl StorageLayoutType for Mapping { /// "Lazy" because there is no caching: every [`get`](Lazy::get) reads from host /// storage, every [`set`](Lazy::set) writes immediately. /// -/// Only 32-byte types are supported (U256, Address, bool, `[u8; 32]`). -/// Using a larger type produces a compile-time error. +/// `T` may be any [`SlotValue`] — static 32-byte types (`U256`, `Address`, +/// `bool`, `[u8; 32]`, …) and, with the `alloc` feature, dynamic types +/// (`String`, `Vec`). pub struct Lazy { key: StorageKey, host: Host, _marker: PhantomData, } -impl Lazy { +impl Lazy { /// Create a new `Lazy` at the given storage key, bound to a host handle. pub fn new(key: StorageKey, host: Host) -> Self { - const { - assert!( - T::ENCODED_SIZE == 32, - "Lazy requires a 32-byte type (U256, Address, bool, [u8; 32])" - ) - }; Lazy { key, host, @@ -279,8 +659,7 @@ impl Lazy { /// Returns the zero value for `T` if the slot was never written, /// matching Solidity's default-to-zero semantics. pub fn get(&self) -> T { - let buf = storage_get_32(&self.host, self.key.as_bytes()); - T::decode(&buf) + T::slot_get(&self.host, &self.key) } /// Read the value, distinguishing "never written" from "has been set." @@ -291,7 +670,7 @@ impl Lazy { /// Note: writing an all-zero value deletes the key (Solidity semantics), /// so `try_get()` returns `None` after writing zero. pub fn try_get(&self) -> Option { - storage_try_get_32(&self.host, self.key.as_bytes()).map(|buf| T::decode(&buf)) + T::slot_try_get(&self.host, &self.key) } /// Write a value to storage. @@ -299,16 +678,22 @@ impl Lazy { /// Takes `&mut self` so that view methods (which receive `&Storage`) /// cannot call this through an immutable borrow. pub fn set(&mut self, value: &T) { - let mut buf = [0u8; 32]; - SolEncode::encode_body_to(value, &mut buf); - storage_set_32(&self.host, self.key.as_bytes(), &buf); + value.slot_set(&self.host, &self.key); } /// Clear the storage slot. /// /// Writes all-zero, which the host deletes from storage. pub fn clear(&mut self) { - storage_set_32(&self.host, self.key.as_bytes(), &[0u8; 32]); + T::slot_clear(&self.host, &self.key); + } +} + +impl StorageComponent for Lazy { + const SLOTS: u64 = 1; + + fn new_at(slot: u64, host: Host) -> Self { + Lazy::new(StorageKey::from_slot(slot), host) } } @@ -337,7 +722,15 @@ impl Mapping { } } -impl Mapping { +impl StorageComponent for Mapping { + const SLOTS: u64 = 1; + + fn new_at(slot: u64, host: Host) -> Self { + Mapping::new(StorageKey::from_slot(slot), host) + } +} + +impl Mapping { /// Compute the raw storage key for a given map key. /// /// Useful for debugging and cross-checking with `cast index`. @@ -359,22 +752,22 @@ impl Mapping /// /// Returns the zero value if the key was never written. pub fn get(&self, key: &K) -> V { - Lazy::new(self.slot_of(key), self.host.clone()).get() + V::slot_get(&self.host, &self.slot_of(key)) } /// Read the value, returning `None` if the key was never written. pub fn try_get(&self, key: &K) -> Option { - Lazy::new(self.slot_of(key), self.host.clone()).try_get() + V::slot_try_get(&self.host, &self.slot_of(key)) } /// Write a value at the given key. pub fn insert(&mut self, key: &K, value: &V) { - self.entry(key).set(value); + value.slot_set(&self.host, &self.slot_of(key)); } /// Delete the value at the given key. pub fn remove(&mut self, key: &K) { - self.entry(key).clear(); + V::slot_clear(&self.host, &self.slot_of(key)); } } @@ -386,9 +779,7 @@ impl Mapping /// `Mapping<(Address, Address), U256>` produces the same slots as /// `Mapping>`. Tuple key support is /// implemented via `AsStorageKey` for tuples up to arity 5. -impl - Mapping> -{ +impl Mapping> { /// Read path for nested mappings: derives the inner mapping root. /// /// The returned `Mapping` is an owned value with full read/write access. @@ -418,6 +809,8 @@ mod tests { use super::*; use alloc::rc::Rc; + use alloc::string::String; + use alloc::vec::Vec; use pvm_contract_types::Address; use pvm_contract_types::MockHostBuilder; use ruint::aliases::U256; @@ -611,6 +1004,90 @@ mod tests { assert_eq!(m.get(&key), U256::from(42)); } + // --- Dynamic SlotValue: String / Vec --- + + #[test] + fn lazy_roundtrip_string_short() { + let mut lazy = Lazy::::new(StorageKey::from_slot(0), h()); + lazy.set(&String::from("hello")); + assert_eq!(lazy.get(), "hello"); + } + + #[test] + fn lazy_roundtrip_string_long() { + let mut lazy = Lazy::::new(StorageKey::from_slot(0), h()); + let long = "a".repeat(200); + lazy.set(&long); + assert_eq!(lazy.get(), long); + } + + #[test] + fn lazy_string_empty_is_default() { + let lazy = Lazy::::new(StorageKey::from_slot(0), h()); + assert_eq!(lazy.get(), ""); + assert_eq!(lazy.try_get(), None); + } + + #[test] + fn lazy_string_clear() { + let mut lazy = Lazy::::new(StorageKey::from_slot(0), h()); + lazy.set(&String::from("payload")); + assert_eq!(lazy.try_get().as_deref(), Some("payload")); + lazy.clear(); + assert_eq!(lazy.try_get(), None); + assert_eq!(lazy.get(), ""); + } + + #[test] + fn lazy_string_overwrite_smaller() { + let mut lazy = Lazy::::new(StorageKey::from_slot(0), h()); + lazy.set(&"hello world this is a longer string".into()); + lazy.set(&"short".into()); + assert_eq!(lazy.get(), "short"); + } + + #[test] + fn lazy_roundtrip_vec_u8() { + let mut lazy = Lazy::>::new(StorageKey::from_slot(0), h()); + lazy.set(&alloc::vec![1, 2, 3, 4, 5]); + assert_eq!(lazy.get(), alloc::vec![1, 2, 3, 4, 5]); + } + + #[test] + fn lazy_vec_u8_large() { + let mut lazy = Lazy::>::new(StorageKey::from_slot(0), h()); + let data: Vec = (0..=255u8).collect(); + lazy.set(&data); + assert_eq!(lazy.get(), data); + } + + #[test] + fn mapping_address_to_string() { + let mut m = Mapping::::new(StorageKey::from_slot(0), h()); + let a = Address([0x01; 20]); + let b = Address([0x02; 20]); + m.insert(&a, &"alice".into()); + m.insert(&b, &"bob".into()); + assert_eq!(m.get(&a), "alice"); + assert_eq!(m.get(&b), "bob"); + m.remove(&a); + assert_eq!(m.try_get(&a), None); + assert_eq!(m.get(&b), "bob"); + } + + #[test] + fn dynamic_data_root_independent_per_slot() { + // Distinct header slots must hash to distinct data roots so two + // dynamic values on adjacent slots can't trample each other. + let mut a = Lazy::::new(StorageKey::from_slot(0), h()); + let host = a.host.clone(); + let mut b = Lazy::::new(StorageKey::from_slot(1), host); + a.set(&"first".into()); + b.set(&"second".into()); + assert_eq!(a.get(), "first"); + assert_eq!(b.get(), "second"); + } + // --- Solidity compatibility --- #[test] @@ -642,6 +1119,27 @@ mod tests { assert_eq!(derived.as_bytes(), &expected); } + // --- StorageComponent --- + + #[test] + fn storage_component_slot_count() { + assert_eq!( as StorageComponent>::SLOTS, 1); + assert_eq!( as StorageComponent>::SLOTS, 1); + assert_eq!( as StorageComponent>::SLOTS, 1); + } + + #[test] + fn storage_component_new_at_matches_new() { + let host = h(); + let mut a = Lazy::::new(StorageKey::from_slot(7), host.clone()); + let mut b = as StorageComponent>::new_at(7, host); + a.set(&U256::from(99)); + // `b` shares the host, so should see the same write. + assert_eq!(b.get(), U256::from(99)); + b.set(&U256::from(100)); + assert_eq!(a.get(), U256::from(100)); + } + // --- Entry optimization --- #[test] diff --git a/crates/pvm-storage/tests/ui/lazy_non_32_byte_type.stderr b/crates/pvm-storage/tests/ui/lazy_non_32_byte_type.stderr index 1d6f4fc6..c25f0533 100644 --- a/crates/pvm-storage/tests/ui/lazy_non_32_byte_type.stderr +++ b/crates/pvm-storage/tests/ui/lazy_non_32_byte_type.stderr @@ -5,4 +5,4 @@ error[E0599]: the function or associated item `new` exists for struct `Lazy<(Uin | ^^^ function or associated item cannot be called on `Lazy<(Uint<256, 4>, Uint<256, 4>)>` due to unsatisfied trait bounds | = note: the following trait bounds were not satisfied: - `(Uint<256, 4>, Uint<256, 4>): StaticEncodedLen` + `(Uint<256, 4>, Uint<256, 4>): SlotValue` diff --git a/examples/example-mytoken/src/example-mytoken-dsl-no-alloc.rs b/examples/example-mytoken/src/example-mytoken-dsl-no-alloc.rs index f4426446..bb35a477 100644 --- a/examples/example-mytoken/src/example-mytoken-dsl-no-alloc.rs +++ b/examples/example-mytoken/src/example-mytoken-dsl-no-alloc.rs @@ -1,9 +1,11 @@ #![no_main] #![no_std] -use pvm_contract_builder_dsl::{ContractBuilder, HandlerResult, solidity_selector}; +use pvm_contract_builder_dsl::{ + ContractBuilder, HandlerResult, assert_non_payable_deploy, solidity_selector, +}; use pvm_contract_sdk::{ - Address, HostApi, PolkaVmHost, SolDecode, SolEncode, SolRevert, StaticEncodedLen, StorageFlags, + Address, Host, HostApi, SolDecode, SolEncode, SolRevert, StaticEncodedLen, StorageFlags, }; use pvm_contract_sdk::U256; @@ -45,21 +47,23 @@ pvm_contract_sdk::sol_revert_enum! { #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] -pub extern "C" fn deploy() {} +pub extern "C" fn deploy() { + assert_non_payable_deploy(&Host::new()); +} #[unsafe(no_mangle)] #[polkavm_derive::polkavm_export] pub extern "C" fn call() { - let host = PolkaVmHost; - ContractBuilder::::new() - .method(TOTAL_SUPPLY_SELECTOR, total_supply_handler::) - .method(BALANCE_OF_SELECTOR, balance_of_handler::) - .method(TRANSFER_SELECTOR, transfer_handler::) - .method(MINT_SELECTOR, mint_handler::) + let host = Host::new(); + ContractBuilder::new() + .method(TOTAL_SUPPLY_SELECTOR, total_supply_handler) + .method(BALANCE_OF_SELECTOR, balance_of_handler) + .method(TRANSFER_SELECTOR, transfer_handler) + .method(MINT_SELECTOR, mint_handler) .dispatch_impl::<256>(&host); } -fn total_supply_handler(host: &H, _input: &[u8], output: &mut [u8]) -> HandlerResult { +fn total_supply_handler(host: &Host, _input: &[u8], output: &mut [u8]) -> HandlerResult { let key = total_supply_key(); let mut supply_bytes = [0u8; 32]; let mut supply_slice = &mut supply_bytes[..]; @@ -72,7 +76,7 @@ fn total_supply_handler(host: &H, _input: &[u8], output: &mut [u8]) HandlerResult::Ok(::ENCODED_SIZE) } -fn balance_of_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn balance_of_handler(host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let account =
::decode_at(input, 0); let account: [u8; 20] = account.into(); let key = balance_key(host, &account); @@ -87,7 +91,7 @@ fn balance_of_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult::Ok(::ENCODED_SIZE) } -fn transfer_handler(host: &H, input: &[u8], output: &mut [u8]) -> HandlerResult { +fn transfer_handler(host: &Host, input: &[u8], output: &mut [u8]) -> HandlerResult { let to =
::decode_at(input, 0); let to: [u8; 20] = to.into(); let amount = U256::decode_at(input,
::ENCODED_SIZE); @@ -127,7 +131,7 @@ fn transfer_handler(host: &H, input: &[u8], output: &mut [u8]) -> Ha HandlerResult::Ok(0) } -fn mint_handler(host: &H, input: &[u8], _output: &mut [u8]) -> HandlerResult { +fn mint_handler(host: &Host, input: &[u8], _output: &mut [u8]) -> HandlerResult { let to =
::decode_at(input, 0); let to: [u8; 20] = to.into(); let amount = U256::decode_at(input,
::ENCODED_SIZE); @@ -165,7 +169,7 @@ fn total_supply_key() -> [u8; 32] { [0u8; 32] } -fn balance_key(host: &H, addr: &[u8; 20]) -> [u8; 32] { +fn balance_key(host: &Host, addr: &[u8; 20]) -> [u8; 32] { let mut input = [0u8; 64]; input[12..32].copy_from_slice(addr); input[63] = 1; @@ -175,23 +179,23 @@ fn balance_key(host: &H, addr: &[u8; 20]) -> [u8; 32] { key } -fn set_total_supply(host: &H, amount: U256) { +fn set_total_supply(host: &Host, amount: U256) { let key = total_supply_key(); host.set_storage(StorageFlags::empty(), &key, &amount.to_be_bytes::<32>()); } -fn set_balance(host: &H, addr: &[u8; 20], amount: U256) { +fn set_balance(host: &Host, addr: &[u8; 20], amount: U256) { let key = balance_key(host, addr); host.set_storage(StorageFlags::empty(), &key, &amount.to_be_bytes::<32>()); } -fn get_caller(host: &H) -> [u8; 20] { +fn get_caller(host: &Host) -> [u8; 20] { let mut caller = [0u8; 20]; host.caller(&mut caller); caller } -fn emit_transfer(host: &H, from: &[u8; 20], to: &[u8; 20], value: U256) { +fn emit_transfer(host: &Host, from: &[u8; 20], to: &[u8; 20], value: U256) { let mut from_topic = [0u8; 32]; from_topic[12..32].copy_from_slice(from); diff --git a/examples/test-contracts/Cargo.lock b/examples/test-contracts/Cargo.lock index f5b2ff22..6051a6a5 100644 --- a/examples/test-contracts/Cargo.lock +++ b/examples/test-contracts/Cargo.lock @@ -380,7 +380,6 @@ dependencies = [ "polkavm-linker", "pvm-contract-types", "serde_json", - "syn 2.0.117", "toml_edit 0.22.27", ] diff --git a/examples/test-contracts/src/error-handling.rs b/examples/test-contracts/src/error-handling.rs index 137f124d..39a97a39 100644 --- a/examples/test-contracts/src/error-handling.rs +++ b/examples/test-contracts/src/error-handling.rs @@ -31,7 +31,7 @@ mod error_handling { } #[pvm_contract_sdk::method] - pub fn will_revert(&self) -> Result<(), ContractError> { + pub fn will_revert(&mut self) -> Result<(), ContractError> { Err(AlwaysReverts.into()) } diff --git a/examples/test-contracts/src/flipper_call.rs b/examples/test-contracts/src/flipper_call.rs index 850ae016..347793aa 100644 --- a/examples/test-contracts/src/flipper_call.rs +++ b/examples/test-contracts/src/flipper_call.rs @@ -39,10 +39,13 @@ mod flipper_call_alloy { let get = flipper.get(); let flip = flipper.flip(); - let res = get.call(self.host())?; + // View callee — `&self` borrow of the contract root suffices; + // `&mut self` here coerces to `&Self` automatically. + let res = get.call(self)?; assert_eq!(res, false); - let _ = flip.call(self.host())?; - let res = get.call(self.host())?; + // Nonpayable callee — requires `&mut Self` borrow. + let _ = flip.call(self)?; + let res = get.call(self)?; assert_eq!(res, true); Ok(()) } diff --git a/examples/test-contracts/src/flipper_delegate.rs b/examples/test-contracts/src/flipper_delegate.rs index 7395f4c7..2a6f89b5 100644 --- a/examples/test-contracts/src/flipper_delegate.rs +++ b/examples/test-contracts/src/flipper_delegate.rs @@ -40,7 +40,7 @@ mod flipper_delegate { #[pvm_contract_sdk::method] pub fn delegate_flipper(&mut self, addr: Address) -> Result<(), Error> { let flip = Flipper::from_address(addr).flip(); - Ok(flip.delegate_call(self.host())?) + Ok(flip.delegate_call(self)?) } #[pvm_contract_sdk::method] diff --git a/examples/test-contracts/src/flipper_instantiate.rs b/examples/test-contracts/src/flipper_instantiate.rs index abe31e62..6dce64a1 100644 --- a/examples/test-contracts/src/flipper_instantiate.rs +++ b/examples/test-contracts/src/flipper_instantiate.rs @@ -40,10 +40,10 @@ mod flipper_instantiate { let get = flipper.get(); let flip = flipper.flip(); - let res = get.call(self.host())?; + let res = get.call(self)?; assert_eq!(res, false); - let _ = flip.call(self.host())?; - let res = get.call(self.host())?; + let _ = flip.call(self)?; + let res = get.call(self)?; assert_eq!(res, true); let mut code_hash = [0; 32]; let _ = self.host().code_hash(&addr.0, &mut code_hash); @@ -51,7 +51,7 @@ mod flipper_instantiate { let deposit_limit = ruint::aliases::U256::from(u128::MAX); let deposit_limit = deposit_limit.to_be_bytes(); let (addr, _) = f.instantiate( - self.host(), + self, &code_hash, 0, RefTimeAndProofSizeLimits { @@ -65,10 +65,10 @@ mod flipper_instantiate { let get = flipper.get(); let flip = flipper.flip(); - let res = get.call(self.host())?; + let res = get.call(self)?; assert_eq!(res, false); - let _ = flip.call(self.host())?; - let res = get.call(self.host())?; + let _ = flip.call(self)?; + let res = get.call(self)?; assert_eq!(res, true); Ok(()) } diff --git a/examples/test-contracts/src/point_adder_call.rs b/examples/test-contracts/src/point_adder_call.rs index 1a2d6ac1..f77b0802 100644 --- a/examples/test-contracts/src/point_adder_call.rs +++ b/examples/test-contracts/src/point_adder_call.rs @@ -55,7 +55,7 @@ mod point_adder_call { b: U256::from(2), }, ) - .call(self.host())?; + .call(self)?; assert_eq!( call, diff --git a/specs/builder-dsl.md b/specs/builder-dsl.md index 3b9a882e..bbae256b 100644 --- a/specs/builder-dsl.md +++ b/specs/builder-dsl.md @@ -2,7 +2,7 @@ A non-macro alternative to `#[contract]`. You wire up dispatch manually using `ContractBuilder`, with no proc macros and full explicit control over entry points and method routing. -> **Host handle:** The DSL path keeps the explicit `` generic on handler signatures (`fn handler(host: &H, input, output)`). In production you instantiate it with `PolkaVmHost`; in native unit tests you instantiate it with `MockHost` directly. The macro path hides this behind a concrete `Host` field injected onto the contract struct — the DSL deliberately keeps it explicit so authors control the monomorphization boundary themselves. +> **Host handle:** DSL handlers take a concrete `&Host`. On `riscv64` `Host` is a zero-sized wrapper around `PolkaVmHost`, with `#[inline(always)]` delegations on every `HostApi` method, so production builds pay no indirection. In native unit tests `Host` wraps `Rc` (via `Host::from_dyn`) so a `MockHost` can back the same handler signature without changing it. This matches the `#[contract]` macro path, which also threads a concrete `Host` through the storage struct. ## Basic Usage @@ -114,13 +114,13 @@ buffer via `SolRevert::revert_data`, then return `HandlerResult::Revert(n)`: ```rust,ignore use pvm_contract_builder_dsl::HandlerResult; -use pvm_contract_sdk::{HostApi, SolRevert}; +use pvm_contract_sdk::{Host, SolRevert}; #[derive(pvm_contract_sdk::SolError)] struct InsufficientBalance; -fn transfer_handler( - host: &H, +fn transfer_handler( + host: &Host, input: &[u8], output: &mut [u8], ) -> HandlerResult { @@ -173,6 +173,33 @@ match api::get_storage(StorageFlags::empty(), &key, &mut slice) { } ``` +## Typed Cross-Contract Calls (`Context`) + +The typed `abi_import!`-generated call wrappers expect `&impl ContractContext` +(view callees) or `&mut impl ContractContext` (mutating callees). DSL handlers +receive a `&Host` from the dispatcher, not a contract struct, so they wrap a +cloned host in [`Context`](https://docs.rs/pvm-contract-types): + +```rust,ignore +use pvm_contract_builder_dsl::HandlerResult; +use pvm_contract_sdk::{Context, Host}; + +fn transfer_handler(host: &Host, _input: &[u8], _output: &mut [u8]) -> HandlerResult { + let mut cx = Context::new(host.clone()); + // Mutating call — `&mut cx` satisfies `&mut impl ContractContext`. + Erc20::from_address(addr).transfer(to, amount).call(&mut cx)?; + HandlerResult::Ok(0) +} +``` + +`Host::clone()` is `Copy` on `riscv64` (ZST) and a single `Rc::clone` on host +targets, so the wrapper costs nothing in production. `Context` carries only +the host handle, so the borrow checker cannot enforce view-vs-mutating at +compile time in the DSL — a handler can freely construct `&cx` and `&mut cx` +from the same locally-owned wrapper. The DSL is the manual-control path; if +you need the static guarantee that view methods cannot initiate +state-mutating cross-contract calls, use the `#[contract]` macro path. + ## Events Also manual — construct topic arrays and call the host: