diff --git a/CHANGELOG.md b/CHANGELOG.md index 70cc5509..2e6dca0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,18 @@ Changelog for `odra`. - `#[odra::external_contract]` keeps the annotated trait and implements it for the generated `XxxContractRef` and `XxxHostRef`, so the trait can be used as a bound or implemented by a module. A trait declared a second time by hand as a workaround must be removed. +- Livenet: `ODRA_CASPER_LIVENET_STATE_ROOT_HASH` pins every read to a past state root hash (transactions are + refused while it is set). In `odra-cli` the global `--state-root-hash ` flag does the same for one + invocation or REPL session, so any getter, `inspect` or `storage` command can look at the chain as it was (#572). +- `odra_cli::ContractLoaderExt`: `MyContract::load_from_file(&env, path)`, `load_from_file_named` and + `load_from_default_file(&env)` load an already deployed contract from the contracts file written by the + `deploy` command, instead of pasting its package hash into a script (#566). +- `ContractEnv::debug(message)` prints from a contract: always on OdraVM and for livenet getters, and on + the Casper test VM when the contract is built with the new `test-support` feature of `odra` (`cargo odra + test -b casper -- --nocapture`). Without the feature the wasm carries no trace of it (#616). +- `WalletProxy` example: a contract attaches CSPR to a cross-contract call with + `XxxContractRef::with_tokens(..)`, both from the value attached to its own call and from its balance; + tested on OdraVM and CasperVM (#529). - Project templates ship `AGENTS.md` (and `CLAUDE.md` importing it): a short pointer that tells an AI agent to install the Odra Claude Code plugin or read `llms.txt`, and lists the `cargo odra` commands. - `odra_test::odra_env()` and `odra_test::casper_env()` are public, so a test can be pinned to one backend @@ -31,6 +43,12 @@ Changelog for `odra`. on OdraVM under `cargo odra test -b casper` this way. ### Changed +- `#[odra::module(name = "..")]` now also names the package: the named key the package hash is stored + under at install is `_package_hash` instead of `_package_hash` (`HasIdent::contract_name()`, + used by `InstallConfig` and `UpgradeConfig`). Modules without `name` are unaffected (#385). +- `HostEnv::advance_block_time` and `advance_with_auctions` take a `core::time::Duration`; `auction_delay()` + and `unbonding_delay()` return one. Block time itself (`block_time()`, `get_block_time()`) stays in + milliseconds. Wrap old values with `Duration::from_millis(..)` or write `Duration::from_secs(..)` (#589). - Livenet: `CasperClient` caches global state and dictionary query responses for the state root hash they were read at (a query at a fixed state root is deterministic). Resolving a contract's entity, the events counter `HostEnv` reads after every call and repeated getters cost no RPC calls within diff --git a/core/src/contract_context.rs b/core/src/contract_context.rs index aac0510e..c7ab95d4 100644 --- a/core/src/contract_context.rs +++ b/core/src/contract_context.rs @@ -125,6 +125,10 @@ pub trait ContractContext { /// * `event` - The event data to emit. fn emit_native_event(&self, event: &Bytes); + /// Prints a debug message on the host: always on OdraVM and livenet, inside wasm only when the + /// contract is built with the `test-support` feature of `odra`. + fn debug(&self, message: &str); + /// Transfers tokens to the specified address. /// /// # Arguments diff --git a/core/src/contract_def.rs b/core/src/contract_def.rs index 8a891cac..3448e8ed 100644 --- a/core/src/contract_def.rs +++ b/core/src/contract_def.rs @@ -76,8 +76,17 @@ pub enum EntrypointAttribute { /// A trait that should be implemented by each smart contract to allow the backend. pub trait HasIdent { - /// Returns the contract's ident. + /// Returns the contract's ident - the name of the module struct. fn ident() -> String; + + /// Returns the contract's name: the `name` given in `#[odra::module(name = "..")]`, or the + /// ident when none was given. It names the contract in the schema and is the base of the + /// named key the package hash is stored under (`_package_hash`). + /// + /// Not called `name` on purpose: that is a common entry point name (token contracts). + fn contract_name() -> String { + Self::ident() + } } /// A trait that should be implemented by each smart contract to allow the backend diff --git a/core/src/contract_env.rs b/core/src/contract_env.rs index 7e1ec5fb..1408029d 100644 --- a/core/src/contract_env.rs +++ b/core/src/contract_env.rs @@ -289,6 +289,24 @@ impl ContractEnv { backend.emit_event(&bytes.into()) } + /// Prints a debug message on the host running the contract. + /// + /// Always printed on OdraVM and for getters on livenet (they run locally). Inside wasm the + /// call is a no-op unless the contract is built with the `test-support` feature of `odra`, + /// in which case the Casper VM used by `cargo odra test -b casper` prints it (run the tests + /// with `-- --nocapture` to see it). A contract built without the feature carries no trace of + /// the message - but the arguments are still evaluated, so keep `format!` out of hot paths. + /// Do not build production wasm with the feature: on a real network the message only lands in + /// the node's log and costs gas. + /// + /// ```ignore + /// self.env().debug(format!("transfer of {amount} from {from:?}")); + /// ``` + pub fn debug(&self, message: impl AsRef) { + let backend = self.backend.borrow(); + backend.debug(message.as_ref()) + } + /// Emits an event with the specified data using the native mechanism. pub fn emit_native_event(&self, event: T) { let backend = self.backend.borrow(); diff --git a/core/src/host.rs b/core/src/host.rs index 12b76295..2ff7c408 100644 --- a/core/src/host.rs +++ b/core/src/host.rs @@ -17,6 +17,7 @@ use casper_types::{ bytesrepr::{Bytes, FromBytes, ToBytes}, CLTyped, PublicKey, RuntimeArgs, U512 }; +use core::time::Duration; /// A host side reference to a contract. pub trait HostRef { @@ -176,7 +177,7 @@ impl InstallConfig { /// Returns new InstallConfig pub fn new(is_upgradable: bool, allow_key_override: bool) -> Self { InstallConfig { - package_named_key: T::ident(), + package_named_key: T::contract_name(), is_upgradable, allow_key_override } @@ -194,7 +195,7 @@ impl UpgradeConfig { /// It is by default upgradable and allows key override. pub fn new() -> Self { UpgradeConfig { - package_named_key: T::ident(), + package_named_key: T::contract_name(), force_create_upgrade_group: false, allow_key_override: true } @@ -501,29 +502,39 @@ impl HostEnv { backend.set_caller(address) } - /// Advances the block time by the specified time difference in milliseconds. - pub fn advance_block_time(&self, time_diff: u64) { + /// Advances the block time by `time_diff`. + /// + /// Block time has millisecond resolution; anything finer is truncated. + /// + /// ``` + /// # use core::time::Duration; + /// # fn shift(env: &odra_core::host::HostEnv) { + /// env.advance_block_time(Duration::from_secs(60 * 60 * 24)); + /// # } + /// ``` + pub fn advance_block_time(&self, time_diff: Duration) { let backend = self.backend.as_ref(); - backend.advance_block_time(time_diff) + backend.advance_block_time(millis(time_diff)) } - /// Advances the block time by the specified time difference in milliseconds - /// and processes auctions. - pub fn advance_with_auctions(&self, time_diff: u64) { + /// Advances the block time by `time_diff` and processes auctions. + /// + /// Block time has millisecond resolution; anything finer is truncated. + pub fn advance_with_auctions(&self, time_diff: Duration) { let backend = self.backend.as_ref(); - backend.advance_with_auctions(time_diff); + backend.advance_with_auctions(millis(time_diff)); } - /// Returns the era length in milliseconds. - pub fn auction_delay(&self) -> u64 { + /// Returns the era length. + pub fn auction_delay(&self) -> Duration { let backend = self.backend.as_ref(); - backend.auction_delay() + Duration::from_millis(backend.auction_delay()) } - /// Returns the delay between unstaking and the transfer of funds back to the delegator in milliseconds. - pub fn unbonding_delay(&self) -> u64 { + /// Returns the delay between unstaking and the transfer of funds back to the delegator. + pub fn unbonding_delay(&self) -> Duration { let backend = self.backend.as_ref(); - backend.unbonding_delay() + Duration::from_millis(backend.unbonding_delay()) } /// Returns the amount of CSPR delegated to the specified validator by the specified delegator. @@ -1042,6 +1053,11 @@ impl HostEnv { } } +/// Block time is kept in milliseconds by every backend. +fn millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + #[cfg(test)] mod test { use core::fmt::Debug; diff --git a/examples/Cargo.toml b/examples/Cargo.toml index d0987cb3..0202d223 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -12,7 +12,7 @@ repository = { workspace = true } version = { workspace = true } [dependencies] -odra = { path = "../odra", features = [], default-features = false } +odra = { path = "../odra", features = ["test-support"], default-features = false } odra-casper-livenet-env = { path = "../odra-casper/livenet-env", optional = true } odra-modules = { path = "../modules", features = ["eip712"], default-features = false } casper-eip-712 = { version = "1.2.0", optional = true } diff --git a/examples/Odra.toml b/examples/Odra.toml index dec56635..e4f69fa9 100644 --- a/examples/Odra.toml +++ b/examples/Odra.toml @@ -58,6 +58,9 @@ fqn = "features::modules::ModulesContract" [[contracts]] fqn = "features::native_token::PublicWallet" +[[contracts]] +fqn = "features::native_token::WalletProxy" + [[contracts]] fqn = "features::livenet::LivenetContract" diff --git a/examples/bin/validators_on_livenet.rs b/examples/bin/validators_on_livenet.rs index 14cf224b..9d84bdea 100644 --- a/examples/bin/validators_on_livenet.rs +++ b/examples/bin/validators_on_livenet.rs @@ -1,4 +1,5 @@ //! Deploys an example Validators contract and tests its functionality. +use core::time::Duration; use odra::casper_types::{PublicKey, U512}; use odra::host::{Deployer, HostEnv, HostRef}; use odra::prelude::*; @@ -30,7 +31,7 @@ fn main() { println!("Auction delay: {:?}", env.auction_delay()); println!("Unbonding delay: {:?}", env.unbonding_delay()); - env.advance_with_auctions(1000); + env.advance_with_auctions(Duration::from_secs(1)); } /// Deploys an ERC20 contract. diff --git a/examples/ourcoin/bin/our_token_livenet.rs b/examples/ourcoin/bin/our_token_livenet.rs index bc3c81ba..f6e9218d 100644 --- a/examples/ourcoin/bin/our_token_livenet.rs +++ b/examples/ourcoin/bin/our_token_livenet.rs @@ -1,5 +1,6 @@ //! Deploys a new OurToken contract on the Casper livenet and mints some tokens for the tutorial //! creator. +use core::time::Duration; use std::str::FromStr; use odra::casper_types::U256; @@ -32,7 +33,7 @@ fn main() { // we set the voting time to 10 minutes. // OH NO! It is the Livenet, so we need to wait real time... // Hopefully you are not in a hurry. - env.advance_block_time(11 * 60 * 1000); + env.advance_block_time(Duration::from_secs(11 * 60)); // Tally the votes. token.tally(); diff --git a/examples/ourcoin/src/token.rs b/examples/ourcoin/src/token.rs index 9424a582..50069559 100644 --- a/examples/ourcoin/src/token.rs +++ b/examples/ourcoin/src/token.rs @@ -193,6 +193,7 @@ impl OurToken { #[cfg(test)] mod tests { use super::*; + use core::time::Duration; use odra::host::Deployer; #[test] @@ -217,7 +218,7 @@ mod tests { assert_eq!(token.balance_of(&env.get_account(0)), U256::zero()); // Wait for the vote to end. - env.advance_block_time(60 * 11 * 1000); + env.advance_block_time(Duration::from_secs(60 * 11)); // Finish the vote. token.tally(); @@ -238,7 +239,7 @@ mod tests { env.set_caller(env.get_account(0)); token.vote(false, U256::from(1000)); - env.advance_block_time(60 * 11 * 1000); + env.advance_block_time(Duration::from_secs(60 * 11)); token.tally(); diff --git a/examples/src/contracts/owned_token.rs b/examples/src/contracts/owned_token.rs index 321a45a6..b7515b7b 100644 --- a/examples/src/contracts/owned_token.rs +++ b/examples/src/contracts/owned_token.rs @@ -52,6 +52,8 @@ impl OwnedToken { #[cfg(test)] pub mod tests { use super::*; + use odra::contract_def::HasIdent; + use odra::host::InstallConfig; use odra::{ host::{Deployer, HostRef}, VmError @@ -217,4 +219,18 @@ pub mod tests { CallerNotTheOwner.into() ); } + #[test] + fn module_name_names_the_package() { + // `#[odra::module(name = "MyTokenContact")]` above. + assert_eq!(OwnedToken::ident(), "OwnedToken"); + assert_eq!(OwnedToken::contract_name(), "MyTokenContact"); + assert_eq!( + InstallConfig::upgradable::().package_named_key, + "MyTokenContact" + ); + assert_eq!( + InstallConfig::upgradable::().package_named_key, + "MyTokenContact" + ); + } } diff --git a/examples/src/contracts/tlw.rs b/examples/src/contracts/tlw.rs index 2b8380f0..4074361f 100644 --- a/examples/src/contracts/tlw.rs +++ b/examples/src/contracts/tlw.rs @@ -111,9 +111,10 @@ pub struct Withdrawal { mod test { use super::*; use crate::contracts::tlw::{Deposit, Withdrawal}; + use core::time::Duration; use odra::host::{Deployer, HostRef}; - const ONE_DAY_IN_SECONDS: u64 = 60 * 60 * 24; + const ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); fn setup() -> (TimeLockWalletHostRef, Address, Address) { let test_env = odra_test::env(); @@ -121,7 +122,7 @@ mod test { TimeLockWallet::deploy( &test_env, TimeLockWalletInitArgs { - lock_duration: ONE_DAY_IN_SECONDS + lock_duration: ONE_DAY.as_millis() as u64 } ), test_env.get_account(0), @@ -197,7 +198,7 @@ mod test { contract.with_tokens(deposit_amount).deposit(); // When the user makes two token withdrawals after the lock is expired. - test_env.advance_block_time(ONE_DAY_IN_SECONDS + 1); + test_env.advance_block_time(ONE_DAY + Duration::from_millis(1)); let balance_before_withdrawals = test_env.balance_of(&user); let first_withdrawal_amount: U512 = 50.into(); let second_withdrawal_amount: U512 = 40.into(); @@ -254,7 +255,9 @@ mod test { contract.with_tokens(deposit.into()).deposit(); // When the user withdraws more tokens than has in the deposit, an error occurs. - contract.env().advance_block_time(ONE_DAY_IN_SECONDS + 1); + contract + .env() + .advance_block_time(ONE_DAY + Duration::from_millis(1)); let withdrawal = deposit + 1; assert_eq!( contract.try_withdraw(&withdrawal.into()).unwrap_err(), diff --git a/examples/src/features/events.rs b/examples/src/features/events.rs index b0c99574..b6dd84a6 100644 --- a/examples/src/features/events.rs +++ b/examples/src/features/events.rs @@ -54,6 +54,7 @@ impl PartyContract { #[cfg(test)] mod tests { use super::{NativePartyStarted, PartyContract, PartyStarted}; + use core::time::Duration; use odra::host::{Deployer, NoArgs}; #[test] @@ -79,7 +80,7 @@ mod tests { assert!(test_env.emitted_native(&party_contract, "NativePartyStarted")); assert_eq!(test_env.events_count(&party_contract), 1); assert_eq!(test_env.native_events_count(&party_contract), 1); - test_env.advance_block_time(42); + test_env.advance_block_time(Duration::from_millis(42)); test_env.set_caller(test_env.get_account(1)); party_contract.emit(); diff --git a/examples/src/features/native_token.rs b/examples/src/features/native_token.rs index 8a9703e0..a8e829df 100644 --- a/examples/src/features/native_token.rs +++ b/examples/src/features/native_token.rs @@ -1,6 +1,7 @@ //! This example demonstrates how to handle native CSPR transfers in a contract. use odra::casper_types::U512; use odra::prelude::*; +use odra::ContractRef; /// Public wallet contract - used to show how Odra handles native CSPR transfers. #[odra::module] @@ -23,12 +24,42 @@ impl PublicWallet { } } +/// Forwards CSPR to a [PublicWallet] from inside a contract: `ContractRef::with_tokens` attaches +/// the amount to the cross-contract call the same way `HostRef::with_tokens` does from a test. +#[odra::module] +pub struct WalletProxy; + +#[odra::module] +impl WalletProxy { + /// Deposits the CSPR attached to this call into `wallet`. + #[odra(payable)] + pub fn forward(&mut self, wallet: &Address) { + let amount = self.env().attached_value(); + PublicWalletContractRef::new(self.env(), *wallet) + .with_tokens(amount) + .deposit(); + } + + /// Deposits everything this contract holds into `wallet`. + pub fn forward_balance(&mut self, wallet: &Address) { + let amount = self.env().self_balance(); + PublicWalletContractRef::new(self.env(), *wallet) + .with_tokens(amount) + .deposit(); + } + + /// Accepts CSPR to be forwarded later with [Self::forward_balance]. + #[odra(payable)] + pub fn fund(&mut self) {} +} + #[cfg(test)] mod tests { - use super::PublicWallet; + use super::{PublicWallet, WalletProxy}; use odra::{ casper_types::U512, - host::{Deployer, HostRef, NoArgs} + host::{Deployer, HostRef, NoArgs}, + prelude::Addressable }; #[test] @@ -67,4 +98,39 @@ mod tests { original_caller_balance - U512::from(100) ); } + + #[test] + fn contract_forwards_attached_tokens() { + let test_env = odra_test::env(); + let wallet = PublicWallet::deploy(&test_env, NoArgs); + let proxy = WalletProxy::deploy(&test_env, NoArgs); + let caller = test_env.get_account(0); + let caller_balance = test_env.balance_of(&caller); + + proxy + .with_tokens(U512::from(100)) + .forward(&wallet.address()); + + assert_eq!(test_env.balance_of(&wallet), U512::from(100)); + assert_eq!(test_env.balance_of(&proxy), U512::zero()); + assert_eq!( + test_env.balance_of(&caller), + caller_balance - U512::from(100) + ); + } + + #[test] + fn contract_forwards_its_balance() { + let test_env = odra_test::env(); + let wallet = PublicWallet::deploy(&test_env, NoArgs); + let mut proxy = WalletProxy::deploy(&test_env, NoArgs); + + proxy.with_tokens(U512::from(100)).fund(); + assert_eq!(test_env.balance_of(&proxy), U512::from(100)); + + proxy.forward_balance(&wallet.address()); + + assert_eq!(test_env.balance_of(&wallet), U512::from(100)); + assert_eq!(test_env.balance_of(&proxy), U512::zero()); + } } diff --git a/examples/src/features/testing.rs b/examples/src/features/testing.rs index 0a2b303b..49edd7a2 100644 --- a/examples/src/features/testing.rs +++ b/examples/src/features/testing.rs @@ -14,6 +14,8 @@ pub struct TestingContract { impl TestingContract { /// Initializes the contract with the name pub fn init(&mut self, name: String) { + self.env() + .debug(format!("TestingContract::init called with name {name:?}")); self.name.set(name); self.created_at.set(self.env().get_block_time()); self.created_by.set(self.env().caller()) diff --git a/examples/src/features/upgrade.rs b/examples/src/features/upgrade.rs index 6edc4364..f7a2f3f1 100644 --- a/examples/src/features/upgrade.rs +++ b/examples/src/features/upgrade.rs @@ -93,8 +93,8 @@ mod test { CounterV1, CounterV2, CounterV2UpgradeArgs, IncrementEvent, IncrementEventV2 }; use odra::casper_types::U256; - use odra::host::{Deployer, HostRef, InstallConfig, NoArgs}; - use odra::prelude::Addressable; + use odra::host::{Deployer, HostRef, InstallConfig, NoArgs, UpgradeConfig}; + use odra::prelude::*; #[test] fn it_works() { @@ -151,4 +151,39 @@ mod test { ) .unwrap(); } + /// A contract installed under one package-hash key (e.g. `Struct_package_hash` by Odra 2.x) + /// can be upgraded with a config that names the key differently (what a 3.0 `name = ".."` + /// produces): the upgrade finds the package by address and is authorized by the account's + /// access URef, not by the key name. Afterwards the package hash sits under both keys. + #[test] + fn upgrade_survives_a_package_key_rename() { + let test_env = odra_test::env(); + let mut counter = CounterV1::deploy_with_cfg( + &test_env, + NoArgs, + InstallConfig { + package_named_key: String::from("OldName"), + is_upgradable: true, + allow_key_override: true + } + ); + counter.increment(); + assert_eq!(counter.get(), 1); + + let counter2 = CounterV2::try_upgrade_with_cfg( + &test_env, + counter.address(), + CounterV2UpgradeArgs { new_start: None }, + UpgradeConfig { + package_named_key: String::from("NewName"), + force_create_upgrade_group: false, + allow_key_override: true + } + ) + .unwrap(); + + // Same package, state kept. + assert_eq!(counter2.address(), counter.address()); + assert_eq!(counter2.get(), U256::one()); + } } diff --git a/modules/src/cep2612.rs b/modules/src/cep2612.rs index 6c2593fd..d8bac1f4 100644 --- a/modules/src/cep2612.rs +++ b/modules/src/cep2612.rs @@ -240,6 +240,7 @@ impl CEP2612Wrapper { mod tests { use super::*; use crate::cep18::events::SetAllowance; + use core::time::Duration; use odra::host::{Deployer, HostEnv}; const TOKEN_NAME: &str = "Test Token"; @@ -448,7 +449,7 @@ mod tests { let nonce: U256 = U256::zero(); // Advance block time (milliseconds) to just past the deadline. - env.advance_block_time((deadline + 1) * 1_000); + env.advance_block_time(Duration::from_secs(deadline + 1)); let signature = sign_permit( &env, @@ -480,7 +481,7 @@ mod tests { } = setup(); // Even after a long time, a permit with deadline = u64::MAX is valid. - env.advance_block_time(1_000_000); + env.advance_block_time(Duration::from_secs(1_000)); let value: U256 = 500u64.into(); let deadline: u64 = u64::MAX; diff --git a/modules/src/cep3009.rs b/modules/src/cep3009.rs index b727128e..f70d9bac 100644 --- a/modules/src/cep3009.rs +++ b/modules/src/cep3009.rs @@ -504,6 +504,7 @@ impl CEP3009Wrapper { #[cfg(test)] mod tests { use super::*; + use core::time::Duration; use odra::host::{Deployer, HostEnv}; const TOKEN_NAME: &str = "Test Token"; @@ -541,7 +542,7 @@ mod tests { } ); - env.advance_block_time(1_000); + env.advance_block_time(Duration::from_secs(1)); Setup { env, @@ -732,7 +733,7 @@ mod tests { } = setup(); // Move further into the future so that valid_before is in the past. - env.advance_block_time(60_000); + env.advance_block_time(Duration::from_secs(60)); let valid_after: u64 = 0; let valid_before: u64 = 10; diff --git a/odra-casper/livenet-env/src/livenet_contract_env.rs b/odra-casper/livenet-env/src/livenet_contract_env.rs index b45ce7b6..2714774a 100644 --- a/odra-casper/livenet-env/src/livenet_contract_env.rs +++ b/odra-casper/livenet-env/src/livenet_contract_env.rs @@ -19,6 +19,10 @@ pub struct LivenetContractEnv { } impl ContractContext for LivenetContractEnv { + fn debug(&self, message: &str) { + println!("{message}"); + } + fn get_value(&self, key: &[u8]) -> Option { let callstack = self.callstack.borrow(); let client = self.casper_client.borrow(); diff --git a/odra-casper/rpc-client/src/casper_client.rs b/odra-casper/rpc-client/src/casper_client.rs index 5d87b1cb..da4ffe66 100644 --- a/odra-casper/rpc-client/src/casper_client.rs +++ b/odra-casper/rpc-client/src/casper_client.rs @@ -4,6 +4,7 @@ use crate::casper_client::{ configuration::CasperClientConfiguration, transaction_watcher::TransactionWatcher }; use crate::error::LivenetError; +use crate::log; use casper_types::bytesrepr::Bytes; use casper_types::{Digest, Key, StoredValue, U512}; use std::cell::RefCell; @@ -38,6 +39,9 @@ pub const ENV_LIVENET_ENV_FILE: &str = "ODRA_CASPER_LIVENET_ENV"; pub const ENV_TTL: &str = "ODRA_CASPER_LIVENET_TTL"; /// Environment variable holding gas price tolerance for transactions. pub const ENV_GAS_PRICE_TOLERANCE: &str = "ODRA_CASPER_LIVENET_GAS_PRICE_TOLERANCE"; +/// Environment variable pinning every read to a past state root hash (hex). Transactions are +/// refused while it is set. +pub const ENV_STATE_ROOT_HASH: &str = "ODRA_CASPER_LIVENET_STATE_ROOT_HASH"; pub type Result = core::result::Result; @@ -61,6 +65,8 @@ pub struct CasperClient { active_account: usize, gas: U512, runtime: Rc, + /// A state root hash every read is pinned to (`ODRA_CASPER_LIVENET_STATE_ROOT_HASH`). + pinned_state_root_hash: Option, /// Cached state root hash and the time it was fetched, see [STATE_ROOT_HASH_TTL]. state_root_hash: RefCell>, /// Query responses, valid for one state root hash. @@ -99,7 +105,14 @@ impl CasperClient { let timeout = Duration::from_secs(TRANSACTION_WAIT_TIME * TRANSACTION_MAX_RETRIES); let watcher = TransactionWatcher::new(&configuration, timeout); + if let Some(digest) = configuration.state_root_hash { + log::info(format!( + "Reads pinned to state root hash {}; transactions are disabled.", + base16::encode_lower(&digest) + )); + } CasperClient { + pinned_state_root_hash: configuration.state_root_hash, configuration, watcher, active_account: 0, @@ -156,8 +169,30 @@ impl CasperClient { cache.dictionary.insert(item, value); } - /// Returns the cached state root hash if it is younger than [STATE_ROOT_HASH_TTL]. + /// The state root hash all reads are pinned to, if any. + pub fn pinned_state_root_hash(&self) -> Option { + self.pinned_state_root_hash + } + + /// Fails when reads are pinned to a past state root hash: a transaction would execute at the + /// chain tip and its effects would never show up in the pinned view. + fn ensure_not_pinned(&self) -> Result<()> { + match self.pinned_state_root_hash { + None => Ok(()), + Some(digest) => Err(LivenetError::ClientError(format!( + "Transactions are disabled while reads are pinned to state root hash {} \ + ({ENV_STATE_ROOT_HASH})", + base16::encode_lower(&digest) + ))) + } + } + + /// Returns the pinned state root hash, or the cached one if it is younger than + /// [STATE_ROOT_HASH_TTL]. fn cached_state_root_hash(&self) -> Option { + if self.pinned_state_root_hash.is_some() { + return self.pinned_state_root_hash; + } self.state_root_hash .borrow() .filter(|(_, fetched_at)| fetched_at.elapsed() < STATE_ROOT_HASH_TTL) @@ -169,6 +204,7 @@ impl CasperClient { } /// Forgets the cached state root hash; called after every transaction this client sends. + /// A pinned state root hash stays. pub fn invalidate_state_root_hash(&self) { *self.state_root_hash.borrow_mut() = None; } @@ -181,3 +217,44 @@ impl CasperClient { self.runtime.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::casper_client::configuration::CasperClientConfiguration; + + fn configuration(state_root_hash: Option) -> CasperClientConfiguration { + CasperClientConfiguration { + node_address: "http://localhost:11101".to_string(), + events_url: "http://localhost:18101/events".to_string(), + chain_name: "casper-net-1".to_string(), + secret_keys: vec![], + secret_key_paths: vec![], + cspr_cloud_auth_token: None, + gas_price_tolerance: 1, + ttl: 300, + state_root_hash + } + } + + #[test] + fn pinned_state_root_hash_is_used_and_survives_invalidation() { + let digest = Digest::hash(b"past"); + let client = CasperClient::new(configuration(Some(digest))); + assert_eq!(client.cached_state_root_hash(), Some(digest)); + client.invalidate_state_root_hash(); + assert_eq!(client.cached_state_root_hash(), Some(digest)); + assert!(client.ensure_not_pinned().is_err()); + } + + #[test] + fn unpinned_client_starts_without_a_cached_hash() { + let client = CasperClient::new(configuration(None)); + assert_eq!(client.cached_state_root_hash(), None); + assert!(client.ensure_not_pinned().is_ok()); + client.cache_state_root_hash(Digest::hash(b"now")); + assert!(client.cached_state_root_hash().is_some()); + client.invalidate_state_root_hash(); + assert_eq!(client.cached_state_root_hash(), None); + } +} diff --git a/odra-casper/rpc-client/src/casper_client/configuration.rs b/odra-casper/rpc-client/src/casper_client/configuration.rs index 110cfc41..2e8ee46f 100644 --- a/odra-casper/rpc-client/src/casper_client/configuration.rs +++ b/odra-casper/rpc-client/src/casper_client/configuration.rs @@ -1,11 +1,13 @@ use crate::casper_client::{ ENV_ACCOUNT_PREFIX, ENV_CHAIN_NAME, ENV_CSPR_CLOUD_AUTH_TOKEN, ENV_EVENTS_ADDRESS, - ENV_GAS_PRICE_TOLERANCE, ENV_LIVENET_ENV_FILE, ENV_NODE_ADDRESS, ENV_SECRET_KEY, ENV_TTL + ENV_GAS_PRICE_TOLERANCE, ENV_LIVENET_ENV_FILE, ENV_NODE_ADDRESS, ENV_SECRET_KEY, + ENV_STATE_ROOT_HASH, ENV_TTL }; use crate::error::LivenetError; use crate::log; use crate::utils::{get_env_variable, get_optional_env_variable}; use casper_client::Verbosity; +use casper_types::Digest; use casper_types::TimeDiff; use odra_core::casper_types::SecretKey; use std::path::PathBuf; @@ -22,7 +24,9 @@ pub struct CasperClientConfiguration { pub secret_key_paths: Vec, pub cspr_cloud_auth_token: Option, pub gas_price_tolerance: u8, - pub ttl: u32 + pub ttl: u32, + /// Pins every read to this state root hash and disables transactions. + pub state_root_hash: Option } impl CasperClientConfiguration { @@ -51,6 +55,15 @@ impl CasperClientConfiguration { .and_then(|ttl| ttl.parse::().ok()) .unwrap_or(DEFAULT_GAS_TOLERANCE); + let state_root_hash = match get_optional_env_variable(ENV_STATE_ROOT_HASH) { + None => None, + Some(hex) => Some(Digest::from_hex(hex.trim()).map_err(|e| { + LivenetError::ClientError(format!( + "{ENV_STATE_ROOT_HASH} is not a valid state root hash: {e}" + )) + })?) + }; + let (secret_keys, secret_key_paths) = Self::secret_keys_from_env()?; Ok(CasperClientConfiguration { node_address, @@ -60,7 +73,8 @@ impl CasperClientConfiguration { cspr_cloud_auth_token: get_optional_env_variable(ENV_CSPR_CLOUD_AUTH_TOKEN), events_url, gas_price_tolerance, - ttl + ttl, + state_root_hash }) } diff --git a/odra-casper/rpc-client/src/casper_client/transactions.rs b/odra-casper/rpc-client/src/casper_client/transactions.rs index d161bdc4..f27546fe 100644 --- a/odra-casper/rpc-client/src/casper_client/transactions.rs +++ b/odra-casper/rpc-client/src/casper_client/transactions.rs @@ -156,6 +156,7 @@ impl super::CasperClient { let transaction = self.new_wasm_deploy_transaction(module_bytes, args, timestamp)?; log::debug(serde_json::to_string_pretty(&transaction).unwrap()); + self.ensure_not_pinned()?; let watch = self.watcher.start_watching().await?; let response = put_transaction( @@ -198,6 +199,7 @@ impl super::CasperClient { let transaction = self.new_call_transaction(addr, call_def, timestamp)?; log::debug(serde_json::to_string_pretty(&transaction).unwrap()); + self.ensure_not_pinned()?; let watch = self.watcher.start_watching().await?; let response = put_transaction( @@ -221,6 +223,7 @@ impl super::CasperClient { async fn put_transaction(&self, transaction: Transaction) -> Result { log::debug("[TX] Starting event watcher before sending transaction..."); + self.ensure_not_pinned()?; let watch = self.watcher.start_watching().await?; log::debug("[TX] Event watcher ready, now sending transaction..."); diff --git a/odra-casper/wasm-env/Cargo.toml b/odra-casper/wasm-env/Cargo.toml index 3f286134..fb040e0e 100644 --- a/odra-casper/wasm-env/Cargo.toml +++ b/odra-casper/wasm-env/Cargo.toml @@ -30,3 +30,5 @@ missing_docs = "warn" [features] default = [] disable-allocator = [] +# Compiles `ContractEnv::debug` into `casper_print`; for wasm run on the test VM, not for production. +test-support = ["casper-contract/test-support"] diff --git a/odra-casper/wasm-env/src/host_functions.rs b/odra-casper/wasm-env/src/host_functions.rs index 705387ec..36643c2f 100644 --- a/odra-casper/wasm-env/src/host_functions.rs +++ b/odra-casper/wasm-env/src/host_functions.rs @@ -1079,3 +1079,14 @@ pub fn override_factory_caller() { CALLER_OVERRIDE = true; } } + +/// Prints `message` on the host through `casper_print`. Compiled in only with the `test-support` +/// feature; a production contract has no trace of it. +#[cfg(feature = "test-support")] +pub fn debug(message: &str) { + casper_contract::contract_api::runtime::print(message); +} + +/// No-op without the `test-support` feature. +#[cfg(not(feature = "test-support"))] +pub fn debug(_message: &str) {} diff --git a/odra-casper/wasm-env/src/wasm_contract_env.rs b/odra-casper/wasm-env/src/wasm_contract_env.rs index f80a7ad1..ff51d48b 100644 --- a/odra-casper/wasm-env/src/wasm_contract_env.rs +++ b/odra-casper/wasm-env/src/wasm_contract_env.rs @@ -16,6 +16,10 @@ use odra_core::{ContractContext, ContractEnv}; pub struct WasmContractEnv; impl ContractContext for WasmContractEnv { + fn debug(&self, message: &str) { + host_functions::debug(message); + } + fn get_value(&self, key: &[u8]) -> Option { host_functions::get_value(key).map(Bytes::from) } diff --git a/odra-cli/src/cli.rs b/odra-cli/src/cli.rs index 135cde47..1a0329a8 100644 --- a/odra-cli/src/cli.rs +++ b/odra-cli/src/cli.rs @@ -61,6 +61,7 @@ impl Default for OdraCli { impl OdraCli { /// Creates a new empty instance of the Odra CLI. pub fn new() -> Self { + env_setup::apply_state_root_hash_arg(std::env::args()); let host_env = env_setup::create_host_env(); Self { main_cmd: MainCmd::default(), diff --git a/odra-cli/src/cli/env_setup.rs b/odra-cli/src/cli/env_setup.rs index b48bf1cf..024175f1 100644 --- a/odra-cli/src/cli/env_setup.rs +++ b/odra-cli/src/cli/env_setup.rs @@ -22,6 +22,7 @@ const ENV_NODE_ADDRESS: &str = "ODRA_CASPER_LIVENET_NODE_ADDRESS"; const ENV_CHAIN_NAME: &str = "ODRA_CASPER_LIVENET_CHAIN_NAME"; const ENV_EVENTS_URL: &str = "ODRA_CASPER_LIVENET_EVENTS_URL"; const ENV_SECRET_KEY_PATH: &str = "ODRA_CASPER_LIVENET_SECRET_KEY_PATH"; +const ENV_STATE_ROOT_HASH: &str = "ODRA_CASPER_LIVENET_STATE_ROOT_HASH"; const DOCS_URL: &str = "https://odra.dev/docs/backends/livenet#setup"; @@ -212,3 +213,53 @@ fn fail(msg: &str) -> ! { prettycli::error(msg); std::process::exit(1); } + +/// Honours a global `--state-root-hash ` before the host environment exists. +/// +/// The environment is created once, ahead of argument parsing, so the flag is picked out of the +/// raw arguments and handed to the livenet backend through its environment variable. +pub(super) fn apply_state_root_hash_arg(args: impl IntoIterator) { + if let Some(hash) = state_root_hash_arg(args) { + std::env::set_var(ENV_STATE_ROOT_HASH, hash); + } +} + +fn state_root_hash_arg(args: impl IntoIterator) -> Option { + let flag = format!("--{}", crate::cmd::args::ARG_STATE_ROOT_HASH); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + if arg == flag { + return args.next(); + } + if let Some(value) = arg.strip_prefix(&format!("{flag}=")) { + return Some(value.to_string()); + } + } + None +} + +#[cfg(test)] +mod state_root_hash_arg_tests { + use super::state_root_hash_arg; + + fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn finds_the_flag_in_both_forms() { + assert_eq!( + state_root_hash_arg(args(&["cli", "--state-root-hash", "abcd", "status"])), + Some("abcd".to_string()) + ); + assert_eq!( + state_root_hash_arg(args(&["cli", "status", "--state-root-hash=abcd"])), + Some("abcd".to_string()) + ); + assert_eq!(state_root_hash_arg(args(&["cli", "status"])), None); + assert_eq!( + state_root_hash_arg(args(&["cli", "--state-root-hash"])), + None + ); + } +} diff --git a/odra-cli/src/cmd/args.rs b/odra-cli/src/cmd/args.rs index a0ab6478..f75885e4 100644 --- a/odra-cli/src/cmd/args.rs +++ b/odra-cli/src/cmd/args.rs @@ -10,6 +10,7 @@ use odra::schema::casper_contract_schema::NamedCLType; pub const ARG_ATTACHED_VALUE: &str = "attached_value"; pub const ARG_GAS: &str = "gas"; pub const ARG_CONTRACTS: &str = "contracts-toml"; +pub const ARG_STATE_ROOT_HASH: &str = "state-root-hash"; pub const ARG_PRINT_EVENTS: &str = "print-events"; pub const ARG_NUMBER: &str = "number"; pub const ARG_JSON: &str = "json"; @@ -126,7 +127,8 @@ pub enum Arg { EventsNumber, PrintEvents, DeployMode, - Json + Json, + StateRootHash } impl Arg { @@ -138,7 +140,8 @@ impl Arg { Arg::EventsNumber => ARG_NUMBER, Arg::PrintEvents => ARG_PRINT_EVENTS, Arg::DeployMode => ARG_DEPLOY_MODE, - Arg::Json => ARG_JSON + Arg::Json => ARG_JSON, + Arg::StateRootHash => ARG_STATE_ROOT_HASH } } } @@ -152,7 +155,8 @@ impl From for clap::Arg { Arg::EventsNumber => arg_number("Number of events to print"), Arg::PrintEvents => arg_print_events(), Arg::DeployMode => arg_deploy_mode(), - Arg::Json => arg_json() + Arg::Json => arg_json(), + Arg::StateRootHash => arg_state_root_hash() } } } @@ -177,6 +181,19 @@ fn arg_gas() -> clap::Arg { .action(ArgAction::Set) } +fn arg_state_root_hash() -> clap::Arg { + clap::Arg::new(ARG_STATE_ROOT_HASH) + .help( + "Read the chain state as of this state root hash (hex) instead of the latest one. \ + Commands that send transactions fail while it is set." + ) + .long(ARG_STATE_ROOT_HASH) + .required(false) + .global(true) + .value_name("HEX") + .action(ArgAction::Set) +} + fn arg_contracts() -> clap::Arg { clap::Arg::new(ARG_CONTRACTS) .help("The path to the file with the deployed contracts. Relative to the project root.") diff --git a/odra-cli/src/cmd/main.rs b/odra-cli/src/cmd/main.rs index e97760e6..7b792de2 100644 --- a/odra-cli/src/cmd/main.rs +++ b/odra-cli/src/cmd/main.rs @@ -37,6 +37,7 @@ impl MainCmd { .arg_required_else_help(true) .arg(Arg::Contracts) .arg(Arg::Json) + .arg(Arg::StateRootHash) .subcommands( self.sub_cmds .iter() diff --git a/odra-cli/src/container.rs b/odra-cli/src/container.rs index 2033f489..81450543 100644 --- a/odra-cli/src/container.rs +++ b/odra-cli/src/container.rs @@ -162,6 +162,14 @@ pub struct DeployedContractsContainer { } impl DeployedContractsContainer { + /// Wraps already read data and the storage it came from. + pub(crate) fn with_data(data: ContractsData, storage: impl ContractStorage + 'static) -> Self { + Self { + data: std::cell::RefCell::new(data), + storage: std::cell::RefCell::new(Box::new(storage)) + } + } + /// Creates a new instance. pub(crate) fn instance(storage: impl ContractStorage + 'static) -> Self { match storage.read() { diff --git a/odra-cli/src/lib.rs b/odra-cli/src/lib.rs index 0462312e..ecf996aa 100644 --- a/odra-cli/src/lib.rs +++ b/odra-cli/src/lib.rs @@ -19,7 +19,7 @@ mod utils; pub use cli::OdraCli; pub use cmd::args::CommandArg; pub use container::{ContractProvider, DeployedContractsContainer}; -pub use utils::{log, DeployerExt}; +pub use utils::{log, ContractLoaderExt, DeployerExt}; pub mod scenario { //! Traits and structs for defining custom scenarios. diff --git a/odra-cli/src/utils.rs b/odra-cli/src/utils.rs index c7ab97df..7e85504e 100644 --- a/odra-cli/src/utils.rs +++ b/odra-cli/src/utils.rs @@ -5,7 +5,11 @@ use odra::{ OdraContract }; -use crate::{ContractProvider, DeployedContractsContainer}; +use crate::{ + container::{ContractError, ContractStorage, FileContractStorage}, + ContractProvider, DeployedContractsContainer +}; +use std::path::PathBuf; const DEFAULT_CONTRACTS_FILE: &str = "resources/contracts.toml"; @@ -88,3 +92,105 @@ pub trait DeployerExt: Sized { impl + 'static> DeployerExt for T { type Contract = T; } + +/// Loads already deployed contracts from a contracts file written by the `deploy` command. +/// +/// Saves copying package hashes out of `resources/contracts.toml` into a script: +/// +/// ```ignore +/// let token = MyToken::load_from_default_file(&env)?; +/// let token = MyToken::load_from_file(&env, "resources/casper-test-contracts.toml")?; +/// ``` +pub trait ContractLoaderExt: Sized { + /// Contract that implements OdraContract for Self. + type Contract: OdraContract + 'static; + + /// Loads the contract from the contracts file at `path`. + /// + /// A relative `path` is resolved against the project root, like the `--contracts-toml` option + /// of the CLI. + fn load_from_file( + env: &HostEnv, + path: impl Into + ) -> Result<::HostRef, crate::deploy::Error> { + Self::load_from_file_named(env, path, None) + } + + /// Loads the contract registered under `package_name` from the contracts file at `path`. + fn load_from_file_named( + env: &HostEnv, + path: impl Into, + package_name: Option + ) -> Result<::HostRef, crate::deploy::Error> { + let container = container_from_file(path.into())?; + Ok(container.contract_ref_named::(env, package_name)?) + } + + /// Loads the contract from the default contracts file: `resources/contracts.toml`, or + /// `resources/-contracts.toml` when `ODRA_CASPER_LIVENET_CHAIN_NAME` is set. + fn load_from_default_file( + env: &HostEnv + ) -> Result<::HostRef, crate::deploy::Error> { + Self::load_from_file(env, get_default_contracts_file()) + } +} + +impl ContractLoaderExt for T { + type Contract = T; +} + +/// Opens an existing contracts file. A relative `path` is resolved against the project root. +/// +/// Unlike the container used by the `deploy` command, this neither creates the file nor hides a +/// broken one: a missing or malformed file is an error, not an empty set of contracts. +pub(crate) fn container_from_file( + path: PathBuf +) -> Result { + let storage = FileContractStorage::new(Some(path))?; + let data = storage.read()?; + Ok(DeployedContractsContainer::with_data(data, storage)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::container::ContractProvider; + + fn temp_file(name: &str, content: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("odra-cli-{}-{name}", std::process::id())); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn missing_file_is_an_error() { + let path = std::env::temp_dir().join("odra-cli-does-not-exist.toml"); + let err = container_from_file(path).err().unwrap(); + assert!(matches!(err, ContractError::Io(_)), "{err:?}"); + } + + #[test] + fn malformed_file_is_an_error() { + let path = temp_file("bad.toml", "this is not = [toml"); + let err = container_from_file(path).err().unwrap(); + assert!(matches!(err, ContractError::TomlDeserialize(_)), "{err:?}"); + } + + #[test] + fn contracts_are_read_from_the_file() { + let path = temp_file( + "ok.toml", + r#" +time = "2026-09-17" + +[[contracts]] +name = "Token" +package_name = "Token" +package_hash = "hash-0101010101010101010101010101010101010101010101010101010101010101" +"# + ); + let container = container_from_file(path).unwrap(); + assert!(container.address_by_name("Token").is_some()); + assert!(container.address_by_name("Other").is_none()); + } +} diff --git a/odra-macros/src/ast/factory/impl_item.rs b/odra-macros/src/ast/factory/impl_item.rs index a4d349ae..6c5cf7af 100644 --- a/odra-macros/src/ast/factory/impl_item.rs +++ b/odra-macros/src/ast/factory/impl_item.rs @@ -320,6 +320,9 @@ mod test { fn ident() -> odra::prelude::string::String { Erc20Factory::ident() } + fn contract_name() -> odra::prelude::string::String { + ::contract_name() + } } impl odra::host::EntryPointsCallerProvider for Erc20FactoryHostRef { diff --git a/odra-macros/src/ast/factory/parts/test_parts.rs b/odra-macros/src/ast/factory/parts/test_parts.rs index 98413ff3..f8afc877 100644 --- a/odra-macros/src/ast/factory/parts/test_parts.rs +++ b/odra-macros/src/ast/factory/parts/test_parts.rs @@ -211,6 +211,9 @@ mod test { fn ident() -> odra::prelude::string::String { Erc20Factory::ident() } + fn contract_name() -> odra::prelude::string::String { + ::contract_name() + } } impl odra::host::EntryPointsCallerProvider for Erc20FactoryHostRef { diff --git a/odra-macros/src/ast/host_ref_item.rs b/odra-macros/src/ast/host_ref_item.rs index dcb7ce19..c3eb43c9 100644 --- a/odra-macros/src/ast/host_ref_item.rs +++ b/odra-macros/src/ast/host_ref_item.rs @@ -58,7 +58,9 @@ pub struct HasIdentTraitImplItem { #[syn(braced)] brace_token: syn::token::Brace, #[syn(in = brace_token)] - ident_fn: IdentFnItem + ident_fn: IdentFnItem, + #[syn(in = brace_token)] + name_fn: NameFnItem } impl TryFrom<&'_ ModuleImplIR> for HasIdentTraitImplItem { @@ -71,7 +73,8 @@ impl TryFrom<&'_ ModuleImplIR> for HasIdentTraitImplItem { for_token: Default::default(), ref_ident: module.host_ref_ident()?, brace_token: Default::default(), - ident_fn: module.try_into()? + ident_fn: module.try_into()?, + name_fn: module.try_into()? }) } } @@ -315,6 +318,31 @@ struct IdentFnItem { fn_item: FnItem } +/// `fn contract_name() -> String { ::contract_name() }` - the host ref +/// reports the same contract name as the module, so `InstallConfig::new::()` and +/// `::()` agree. Fully qualified, so an entry point of the same name cannot shadow it. +#[derive(syn_derive::ToTokens)] +struct NameFnItem { + fn_item: FnItem +} + +impl TryFrom<&'_ ModuleImplIR> for NameFnItem { + type Error = syn::Error; + + fn try_from(module: &'_ ModuleImplIR) -> Result { + let name = quote::format_ident!("contract_name"); + let ty_string = utils::ty::string(); + let ty_has_ident = utils::ty::has_ident(); + let module_ident = module.module_ident()?; + let ret_ty: syn::ReturnType = utils::misc::ret_ty(&ty_string); + let expr: syn::Expr = parse_quote!(<#module_ident as #ty_has_ident>::#name()); + + Ok(Self { + fn_item: FnItem::new(&name, vec![], ret_ty, expr.as_block()) + }) + } +} + impl TryFrom<&'_ ModuleImplIR> for IdentFnItem { type Error = syn::Error; diff --git a/odra-macros/src/ast/ident_item.rs b/odra-macros/src/ast/ident_item.rs index dd4defab..4eafcb6c 100644 --- a/odra-macros/src/ast/ident_item.rs +++ b/odra-macros/src/ast/ident_item.rs @@ -12,7 +12,9 @@ pub struct HasIdentImplItem { #[syn(braced)] brace_token: syn::token::Brace, #[syn(in = brace_token)] - ident_fn: IdentFnItem + ident_fn: IdentFnItem, + #[syn(in = brace_token)] + name_fn: Option } impl TryFrom<&'_ ModuleStructIR> for HasIdentImplItem { @@ -25,7 +27,34 @@ impl TryFrom<&'_ ModuleStructIR> for HasIdentImplItem { for_token: Default::default(), module_ident: ir.module_ident(), brace_token: Default::default(), - ident_fn: ir.try_into()? + ident_fn: ir.try_into()?, + name_fn: NameFnItem::for_module(ir) + }) + } +} + +/// `fn contract_name() -> String`, emitted only when `#[odra::module(name = "..")]` was given; otherwise +/// the trait's default (the ident) applies. +#[derive(syn_derive::ToTokens)] +pub struct NameFnItem { + sig: syn::Signature, + #[syn(braced)] + brace_token: syn::token::Brace, + #[syn(in = brace_token)] + expr: syn::Expr +} + +impl NameFnItem { + fn for_module(ir: &ModuleStructIR) -> Option { + let name = ir.contract_name(); + if name.is_empty() { + return None; + } + let ret_ty = utils::ty::string(); + Some(Self { + sig: parse_quote!(fn contract_name() -> #ret_ty), + brace_token: Default::default(), + expr: utils::expr::string_from(name) }) } } @@ -63,6 +92,23 @@ mod test { #[test] fn test_entrypoints() { let module = test_utils::mock::module_definition(); + let expected = quote!( + impl odra::contract_def::HasIdent for CounterPack { + fn ident() -> odra::prelude::string::String { + odra::prelude::string::String::from("CounterPack") + } + fn contract_name() -> odra::prelude::string::String { + odra::prelude::string::String::from("MyCounterPack") + } + } + ); + let actual = HasIdentImplItem::try_from(&module).unwrap(); + test_utils::assert_eq(actual, expected); + } + + #[test] + fn no_name_attribute_no_name_fn() { + let module = test_utils::mock::factory_module_definition(); let expected = quote!( impl odra::contract_def::HasIdent for CounterPack { fn ident() -> odra::prelude::string::String { diff --git a/odra-macros/src/ast/test_parts.rs b/odra-macros/src/ast/test_parts.rs index 406dbaa5..96bc6b68 100644 --- a/odra-macros/src/ast/test_parts.rs +++ b/odra-macros/src/ast/test_parts.rs @@ -350,6 +350,9 @@ mod test { fn ident() -> odra::prelude::string::String { Erc20::ident() } + fn contract_name() -> odra::prelude::string::String { + ::contract_name() + } } #[allow(missing_docs)] @@ -599,6 +602,9 @@ mod test { fn ident() -> odra::prelude::string::String { Erc20::ident() } + fn contract_name() -> odra::prelude::string::String { + ::contract_name() + } } impl odra::host::EntryPointsCallerProvider for Erc20HostRef { diff --git a/odra-vm/src/odra_vm_contract_env.rs b/odra-vm/src/odra_vm_contract_env.rs index 9a5b518b..d6f24ef5 100644 --- a/odra-vm/src/odra_vm_contract_env.rs +++ b/odra-vm/src/odra_vm_contract_env.rs @@ -20,6 +20,10 @@ pub struct OdraVmContractEnv { } impl ContractContext for OdraVmContractEnv { + fn debug(&self, message: &str) { + println!("{message}"); + } + fn get_value(&self, key: &[u8]) -> Option { self.vm.get_var(key) } diff --git a/odra/Cargo.toml b/odra/Cargo.toml index 634b3d75..af6fdaf4 100644 --- a/odra/Cargo.toml +++ b/odra/Cargo.toml @@ -26,3 +26,5 @@ missing_docs = "warn" [features] default = [] disable-allocator = ["odra-casper-wasm-env/disable-allocator"] +# Makes `ContractEnv::debug` print from wasm (casper_print). For the test VM; production wasm should not carry it. +test-support = ["odra-casper-wasm-env/test-support"]