From c8f88a3b86bb0282b4ce0124935ce963474082a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Wed, 16 Sep 2026 15:35:37 +0200 Subject: [PATCH 01/24] Document odra_test::odra_env and casper_env --- docusaurus/docs/basics/07-testing.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docusaurus/docs/basics/07-testing.md b/docusaurus/docs/basics/07-testing.md index 2479c1335..0d4a607af 100644 --- a/docusaurus/docs/basics/07-testing.md +++ b/docusaurus/docs/basics/07-testing.md @@ -134,6 +134,30 @@ the function we are calling inside the contract. Full list of functions can be found in the [`HostEnv`] documentation. +## Choosing the backend + +`odra_test::env()` returns the backend selected by the `ODRA_BACKEND` environment variable: +`cargo odra test` runs your tests on OdraVM, `cargo odra test -b casper` sets the variable and runs +them against the Casper execution engine, using the wasm files built from the contracts listed in +`Odra.toml`. + +Sometimes a test should not follow that switch. A typical case is a module that is used only as a +building block of other contracts and is not registered in `Odra.toml` - there is no wasm file for +it, so under `-b casper` `deploy` would fail. Such a test can be pinned to OdraVM with +`odra_test::odra_env()`: + +```rust title="examples/src/features/testing.rs" +#[test] +fn odra_vm_only() { + let test_env = odra_test::odra_env(); + let owner = test_env.get_account(0); + let ownable = Ownable::deploy(&test_env, OwnableInitArgs { owner }); + assert_eq!(ownable.get_owner(), owner); +} +``` + +`odra_test::casper_env()` does the opposite and always uses the Casper backend. + ## What's next We take a look at how Odra handles errors! From edc8de410ac400c231c75e1ecd75a328b3a10535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Wed, 16 Sep 2026 16:00:23 +0200 Subject: [PATCH 02/24] Document that external_contract keeps the trait --- docusaurus/docs/basics/10-cross-calls.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docusaurus/docs/basics/10-cross-calls.md b/docusaurus/docs/basics/10-cross-calls.md index dc0516a7a..d4ea58d79 100644 --- a/docusaurus/docs/basics/10-cross-calls.md +++ b/docusaurus/docs/basics/10-cross-calls.md @@ -87,7 +87,9 @@ pub trait Adder { } ``` -Odra automatically creates the `AdderContractRef` struct. Having an address, in the module context we can call: +Odra automatically creates the `AdderContractRef` struct (and `AdderHostRef` for tests). The `Adder` trait +itself is kept and both refs implement it, so it can be used as a bound (`fn sum(adder: &T)`) or +implemented by one of your modules. Having an address, in the module context we can call: ```rust struct Contract { From c6ab0f74e5620d56353bd8e9366c110b08d690ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Wed, 16 Sep 2026 16:02:43 +0200 Subject: [PATCH 03/24] Migration guide: compile-time changes in 3.0.0 --- docusaurus/docs/migrations/to-3.0.0.md | 47 +++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docusaurus/docs/migrations/to-3.0.0.md b/docusaurus/docs/migrations/to-3.0.0.md index c9cb54562..608a73c2d 100644 --- a/docusaurus/docs/migrations/to-3.0.0.md +++ b/docusaurus/docs/migrations/to-3.0.0.md @@ -9,7 +9,8 @@ Odra v3.0.0 moves to the Casper 2.x **addressable entity** stack: `casper-types` and `casper-execution-engine` 9. That is what makes it a major release. Most projects need no code changes — rebuild and carry on. Read on if you store a caller's raw `Key`, -or have upgradable contracts already deployed. +or have upgradable contracts already deployed. Two macro changes can also surface as compile errors, +see [Compile-time changes](#compile-time-changes). ## What changes @@ -105,3 +106,47 @@ Because `enable_addressable_entity()` returns `false` on backends without the sw safe to run anywhere but only *proves* something on the casper backend with `ODRA_CASPER_LEGACY_GENESIS=1`. Make sure your CI runs it that way, or it passes without executing. ::: + +## Compile-time changes + +Both are things that used to compile and silently did the wrong thing. If your project builds, you +are not affected. + +### `#[odra::module(...)]` arguments on an `impl` block are an error + +`events`, `errors`, `name`, `version` and `layout` belong to the module struct. Put on an `impl` +block they were parsed and ignored - the events never made it into the contract schema. Now the +compiler points at the misplaced argument: + +```rust +#[odra::module(events = [Transfer])] // error: `events` is not allowed on an impl block +impl Token { ... } +``` + +Move the argument to the struct: + +```rust +#[odra::module(events = [Transfer])] +pub struct Token { ... } + +#[odra::module] +impl Token { ... } +``` + +Only `factory = on` is accepted on an `impl` block, and `#[odra::module]` on a trait takes no +arguments. + +### `#[odra::external_contract]` keeps the trait + +The annotated trait is now emitted as written, and both `XxxContractRef` and `XxxHostRef` +implement it. Previously the trait disappeared, so a common workaround was to declare it twice: + +```rust +#[odra::external_contract] +pub trait Adapter { fn owner_of(&self, token_id: TokenId) -> Option
; } + +pub trait Adapter { fn owner_of(&self, token_id: TokenId) -> Option
; } // remove this copy +``` + +That copy now fails with *the name `Adapter` is defined multiple times* - delete it. The trait can +be used as a bound (`fn check(a: &T)`) or implemented by one of your modules. From d58c3de41637ad134de14b50a31fd569c277d79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Wed, 16 Sep 2026 16:13:14 +0200 Subject: [PATCH 04/24] Migration guide: Erc20 mint/burn are no longer entry points --- docusaurus/docs/migrations/to-3.0.0.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docusaurus/docs/migrations/to-3.0.0.md b/docusaurus/docs/migrations/to-3.0.0.md index 608a73c2d..ae100ef9d 100644 --- a/docusaurus/docs/migrations/to-3.0.0.md +++ b/docusaurus/docs/migrations/to-3.0.0.md @@ -150,3 +150,23 @@ pub trait Adapter { fn owner_of(&self, token_id: TokenId) -> Option
; } That copy now fails with *the name `Adapter` is defined multiple times* - delete it. The trait can be used as a bound (`fn check(a: &T)`) or implemented by one of your modules. + +### `Erc20::mint` and `Erc20::burn` are no longer entry points + +In `odra-modules`, `Erc20::mint`, `Erc20::burn` and `Ownable::unchecked_transfer_ownership` moved out +of the `#[odra::module]` impl blocks. A contract built directly from `Erc20` had an unprotected `mint` +entry point; now these functions exist only in Rust, for a wrapping module to call behind its own +check (as `OwnedToken` in the examples does): + +```rust +#[odra::module] +impl OwnedToken { + pub fn mint(&mut self, address: &Address, amount: &U256) { + self.ownable.assert_owner(&self.env().caller()); + self.erc20.mint(address, amount); + } +} +``` + +`Erc20HostRef::mint` / `try_mint` and `burn` / `try_burn` are gone; if a test relied on them, mint +through your wrapping contract or use `initial_supply` in `init`. From ab0d2906152634dbf5a75ccc307a9aca5fd0f122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 09:36:11 +0200 Subject: [PATCH 05/24] Livenet docs: rate limits, retries and read errors --- docusaurus/docs/backends/04-livenet.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docusaurus/docs/backends/04-livenet.md b/docusaurus/docs/backends/04-livenet.md index 1efd2ad8b..6f9d3b70c 100644 --- a/docusaurus/docs/backends/04-livenet.md +++ b/docusaurus/docs/backends/04-livenet.md @@ -226,6 +226,26 @@ before it is sent to the node, which is the quickest way to see what was actuall ODRA_LOG_LEVEL=debug cargo run --bin erc20_on_livenet --features=livenet ``` +### Rate limits and read errors + +Nodes and sidecars rate-limit JSON-RPC calls (NCTL and cspr.cloud both do). A burst of getter +calls - a loop over `token.balance_of(..)` for many accounts, say - can be answered with HTTP 429 +or a "request was throttled by the node" error. The Livenet backend retries such reads with an +exponential backoff (five attempts, from 200 ms up to about 3 s in total) before giving up, and +logs each retry at `debug` level. + +Every read costs the backend RPC calls: getters run your contract code locally and each storage +access is a query to the node. The backend caches the state root hash for up to five seconds (and +drops it after every transaction it sends) and reuses query responses read at that state root, so +repeated reads and the bookkeeping around each call are mostly free - but keep the number of +distinct reads in mind when a script talks to a public node. + +When the node cannot be asked at all - it is unreachable, or still throttled after the retries - +the backend stops with the real reason (`Livenet: reading of failed: ...`) +rather than reporting a missing value, because your contract code would otherwise mistake the +failure for "not set". Run with `ODRA_LOG_LEVEL=warn` or above to see failed queries that were +recovered from. + ### Handling a missing configuration `odra_casper_livenet_env::env()` panics if any of the required variables is missing or if the From 9648f2419f7dd26675fe90202bb79a7629607823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:22:25 +0200 Subject: [PATCH 06/24] Duration-based block time API (#589) --- docusaurus/docs/advanced/08-delegating-cspr.md | 6 +++--- docusaurus/docs/basics/07-testing.md | 3 ++- docusaurus/docs/migrations/to-3.0.0.md | 18 ++++++++++++++++++ docusaurus/docs/tutorials/cep18.md | 8 ++++---- .../docs/tutorials/deploying-on-casper.md | 2 +- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docusaurus/docs/advanced/08-delegating-cspr.md b/docusaurus/docs/advanced/08-delegating-cspr.md index bc44bd213..2bd9fec41 100644 --- a/docusaurus/docs/advanced/08-delegating-cspr.md +++ b/docusaurus/docs/advanced/08-delegating-cspr.md @@ -117,9 +117,9 @@ It is possible to test the delegation and undelegation of tokens in the contract You can see, that we use the new methods from HostEnv, namely: ```rust - fn advance_with_auctions(&self, time_diff: u64); - fn auction_delay(&self) -> u64; - fn unbonding_delay(&self) -> u64; + fn advance_with_auctions(&self, time_diff: Duration); + fn auction_delay(&self) -> Duration; + fn unbonding_delay(&self) -> Duration; fn delegated_amount(&self, delegator: Address, validator: PublicKey) -> U512; ``` diff --git a/docusaurus/docs/basics/07-testing.md b/docusaurus/docs/basics/07-testing.md index 0d4a607af..0529051b8 100644 --- a/docusaurus/docs/basics/07-testing.md +++ b/docusaurus/docs/basics/07-testing.md @@ -123,7 +123,8 @@ the function we are calling inside the contract. - `fn balance_of(&self, addr: &T) -> U512` - returns the balance of the account associated with the given address - `fn block_time(&self) -> u64` - returns the current value of `block_time` in milliseconds, alias: `block_time_millis` - `fn block_time_secs(&self) -> u64` - retuns the current value of `block_time` in seconds -- `fn advance_block_time(&self, time_diff: u64)` - increases the current value of `block_time` by `time_diff` in milliseconds +- `fn advance_block_time(&self, time_diff: Duration)` - increases the current value of `block_time` by `time_diff` + (`core::time::Duration`; block time has millisecond resolution) - `fn get_account(&self, n: usize) -> Address` - returns an n-th address that was prepared for you by Odra in advance; by default, you start with the 0-th account - `fn emitted_event(&self, contract_address: &R, event: T) -> bool` - verifies if the event was emitted by the contract diff --git a/docusaurus/docs/migrations/to-3.0.0.md b/docusaurus/docs/migrations/to-3.0.0.md index ae100ef9d..7a3fd70b5 100644 --- a/docusaurus/docs/migrations/to-3.0.0.md +++ b/docusaurus/docs/migrations/to-3.0.0.md @@ -170,3 +170,21 @@ impl OwnedToken { `Erc20HostRef::mint` / `try_mint` and `burn` / `try_burn` are gone; if a test relied on them, mint through your wrapping contract or use `initial_supply` in `init`. + +### Block time is shifted with `Duration` + +`HostEnv::advance_block_time` and `advance_with_auctions` take a `core::time::Duration` instead of +a number of milliseconds, and `auction_delay()` / `unbonding_delay()` return one. The old `u64` +calls fail to compile with *expected `Duration`, found integer*; wrap the value: + +```rust +use core::time::Duration; + +env.advance_block_time(60 * 60 * 1000); // before +env.advance_block_time(Duration::from_secs(60 * 60)); // after + +env.advance_with_auctions(env.auction_delay() * 2); // unchanged: Duration * 2 +``` + +Reading the block time is unchanged: `block_time()` / `block_time_millis()` / `block_time_secs()` +still return `u64`, as does `ContractEnv::get_block_time()` inside a contract. diff --git a/docusaurus/docs/tutorials/cep18.md b/docusaurus/docs/tutorials/cep18.md index a09185e0d..f620d67ac 100644 --- a/docusaurus/docs/tutorials/cep18.md +++ b/docusaurus/docs/tutorials/cep18.md @@ -275,7 +275,7 @@ fn it_works() { 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(); @@ -296,7 +296,7 @@ fn it_works() { 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(); @@ -557,7 +557,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(); @@ -578,7 +578,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/docusaurus/docs/tutorials/deploying-on-casper.md b/docusaurus/docs/tutorials/deploying-on-casper.md index 1c8752b25..834c8d7e6 100644 --- a/docusaurus/docs/tutorials/deploying-on-casper.md +++ b/docusaurus/docs/tutorials/deploying-on-casper.md @@ -90,7 +90,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(); From 81a542a212e7ccdd82b33de9f3d7f07b5d3d5662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:30:47 +0200 Subject: [PATCH 07/24] Show the Duration import in the updated tutorial snippets --- docusaurus/docs/tutorials/cep18.md | 1 + docusaurus/docs/tutorials/deploying-on-casper.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docusaurus/docs/tutorials/cep18.md b/docusaurus/docs/tutorials/cep18.md index f620d67ac..89ee0b0fe 100644 --- a/docusaurus/docs/tutorials/cep18.md +++ b/docusaurus/docs/tutorials/cep18.md @@ -533,6 +533,7 @@ impl OurToken { #[cfg(test)] mod tests { use super::*; + use core::time::Duration; use odra::host::Deployer; #[test] diff --git a/docusaurus/docs/tutorials/deploying-on-casper.md b/docusaurus/docs/tutorials/deploying-on-casper.md index 834c8d7e6..942eb2fd6 100644 --- a/docusaurus/docs/tutorials/deploying-on-casper.md +++ b/docusaurus/docs/tutorials/deploying-on-casper.md @@ -58,6 +58,7 @@ In your contract code, create a new file in the bin folder: //! Deploys a new OurToken contract on the Casper livenet and mints some tokens for the tutorial //! creator. use std::str::FromStr; +use std::time::Duration; use odra::casper_types::U256; use odra::host::{Deployer, HostEnv, HostRefLoader}; From 2a63f85e8d9688f485b4dfbe185d0fe8da4a4adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:32:50 +0200 Subject: [PATCH 08/24] Native token: contract-to-contract transfers with ContractRef::with_tokens --- docusaurus/docs/basics/12-native-token.md | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docusaurus/docs/basics/12-native-token.md b/docusaurus/docs/basics/12-native-token.md index 2cdb280e0..6520d7f5f 100644 --- a/docusaurus/docs/basics/12-native-token.md +++ b/docusaurus/docs/basics/12-native-token.md @@ -64,6 +64,41 @@ mod tests { } ``` +## Sending CSPR from a contract to a contract + +The same `with_tokens` exists on the `ContractRef` a contract uses to call another one. The amount is +taken from the calling contract's balance - either what was attached to its own call, or what it +already holds: + +```rust title="examples/src/features/native_token.rs" +use odra::ContractRef; + +#[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(); + } +} +``` + +The called entry point must be `#[odra(payable)]`, exactly as when the tokens come from an account. + ## HostEnv In a broader context of the host environment (test, livenet), you can also transfer `CSPR` tokens between accounts: From 1e09338a20a92d4070e2d97b43f1e017f37ad46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:35:45 +0200 Subject: [PATCH 09/24] Document ContractEnv::debug and the test-support feature (#616) --- .../docs/basics/06-communicating-with-host.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docusaurus/docs/basics/06-communicating-with-host.md b/docusaurus/docs/basics/06-communicating-with-host.md index a7bb530d8..7c716901e 100644 --- a/docusaurus/docs/basics/06-communicating-with-host.md +++ b/docusaurus/docs/basics/06-communicating-with-host.md @@ -54,6 +54,26 @@ In this example, we use two of them: * `get_block_time()` - returns the current block time as u64. * `caller()` - returns an Odra `Address` of the caller (this can be an external caller or another contract). +## Debug output + +`self.env().debug(message)` prints a message on the host that runs the contract - the quickest way to +see what a contract does in a test: + +```rust +self.env().debug(format!("transfer of {amount} from {from:?}")); +``` + +On OdraVM it always prints. For the Casper VM the contract has to be built with the `test-support` +feature of `odra`, which compiles the call into Casper's `casper_print` host function: + +```toml title="Cargo.toml" +odra = { version = "3.0.0", features = ["test-support"] } +``` + +Run the tests with `cargo odra test -b casper -- --nocapture` to see the output. Without the feature +the call compiles to nothing (the message arguments are still evaluated), so never ship a contract +built with `test-support`: the wasm would import a host function real networks do not provide. + :::info You will learn more functions that Odra exposes from host and types it uses in further articles. ::: From 7d59f870a54eca93f22c0b465a0b94687e313e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:36:02 +0200 Subject: [PATCH 10/24] debug docs: correct what happens on a real network --- docusaurus/docs/basics/06-communicating-with-host.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docusaurus/docs/basics/06-communicating-with-host.md b/docusaurus/docs/basics/06-communicating-with-host.md index 7c716901e..32c5e3312 100644 --- a/docusaurus/docs/basics/06-communicating-with-host.md +++ b/docusaurus/docs/basics/06-communicating-with-host.md @@ -71,8 +71,9 @@ odra = { version = "3.0.0", features = ["test-support"] } ``` Run the tests with `cargo odra test -b casper -- --nocapture` to see the output. Without the feature -the call compiles to nothing (the message arguments are still evaluated), so never ship a contract -built with `test-support`: the wasm would import a host function real networks do not provide. +the call compiles to nothing (the message arguments are still evaluated). Build production wasm +without `test-support`: on a real network the messages would only end up in the node's log, at the +cost of gas for every call. :::info You will learn more functions that Odra exposes from host and types it uses in further articles. From df6837c367649df190413f52c94f17b8cdc92372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:38:39 +0200 Subject: [PATCH 11/24] odra-cli tutorial: loading contracts from the contracts file (#566) --- docusaurus/docs/tutorials/odra-cli.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docusaurus/docs/tutorials/odra-cli.md b/docusaurus/docs/tutorials/odra-cli.md index cdc3b6f3d..4f42e1add 100644 --- a/docusaurus/docs/tutorials/odra-cli.md +++ b/docusaurus/docs/tutorials/odra-cli.md @@ -116,6 +116,22 @@ deploy the contract and adds it to a container. The address of the deployed contract is stored in a TOML file in the `resources` directory, which is created automatically by the Odra CLI library. +Outside of a deploy script - in a plain livenet binary, say - the same file spares you copying package +hashes around. [`ContractLoaderExt`] is implemented for every contract: + +```rust +use odra_cli::ContractLoaderExt; + +// `resources/contracts.toml`, or `resources/-contracts.toml` when ODRA_CASPER_LIVENET_CHAIN_NAME is set +let dog = DogContract::load_from_default_file(&env)?; +// any file, relative to the project root +let dog = DogContract::load_from_file(&env, "resources/casper-test-contracts.toml")?; +// a contract registered under a custom package name +let dog = DogContract::load_from_file_named(&env, "resources/contracts.toml", Some("dog-2".into()))?; +``` + +A missing or malformed file is an error, as is a contract that is not in it. + :::tip Gas amounts are expressed in motes, which makes them long and easy to mistype. The `cspr!` macro converts CSPR to motes at compile time, so `cspr!(350)` is `350_000_000_000` and `cspr!(2.5)` is @@ -794,4 +810,5 @@ lifetime of the REPL - choose the file when starting it, e.g. The Odra CLI library provides a powerful and convenient way to create command-line tools for your Odra contracts. It simplifies the process of deploying, interacting with, and testing your contracts, allowing you to focus on the business logic of your application. By following the examples in this tutorial, you can create your own CLI tools and streamline your development workflow. [`InstallConfig`]: https://docs.rs/odra/2.9.0/odra/host/struct.InstallConfig.html +[`ContractLoaderExt`]: https://docs.rs/odra-cli/latest/odra_cli/trait.ContractLoaderExt.html [`DeployerExt`]: https://docs.rs/odra-cli/2.9.0/odra_cli/trait.DeployerExt.html \ No newline at end of file From b72b16af2c8a0c494ecd1d7b1643bdb1fb46e6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:41:35 +0200 Subject: [PATCH 12/24] Pinned reads: ODRA_CASPER_LIVENET_STATE_ROOT_HASH and odra-cli --state-root-hash (#572) --- docusaurus/docs/backends/04-livenet.md | 13 +++++++++++++ docusaurus/docs/tutorials/odra-cli.md | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/docusaurus/docs/backends/04-livenet.md b/docusaurus/docs/backends/04-livenet.md index 6f9d3b70c..7bc4f1ef5 100644 --- a/docusaurus/docs/backends/04-livenet.md +++ b/docusaurus/docs/backends/04-livenet.md @@ -113,6 +113,9 @@ ODRA_CASPER_LIVENET_EVENTS_URL= # Optionally, you can set the gas price tolerance for the transactions. Default is 1. # ODRA_CASPER_LIVENET_GAS_PRICE_TOLERANCE= + +# Optionally, pin every read to a past state root hash (hex). Transactions are refused while set. +# ODRA_CASPER_LIVENET_STATE_ROOT_HASH= ``` :::note @@ -226,6 +229,16 @@ before it is sent to the node, which is the quickest way to see what was actuall ODRA_LOG_LEVEL=debug cargo run --bin erc20_on_livenet --features=livenet ``` +### Reading the past + +Set `ODRA_CASPER_LIVENET_STATE_ROOT_HASH` to a state root hash and every read - getters, balances, +`HostEnv::get_named_value` and friends - is answered from the chain state as of that root instead +of the latest one. Transactions are refused while the variable is set, because they would execute +at the chain tip and never show up in the pinned view. State root hashes come from +`chain_get_state_root_hash` (`casper-client get-state-root-hash --block-identifier `), or +from the `status` command of an [Odra CLI](../tutorials/odra-cli.md), which also exposes this as +the `--state-root-hash` flag. + ### Rate limits and read errors Nodes and sidecars rate-limit JSON-RPC calls (NCTL and cspr.cloud both do). A burst of getter diff --git a/docusaurus/docs/tutorials/odra-cli.md b/docusaurus/docs/tutorials/odra-cli.md index 4f42e1add..45428fe78 100644 --- a/docusaurus/docs/tutorials/odra-cli.md +++ b/docusaurus/docs/tutorials/odra-cli.md @@ -273,9 +273,15 @@ Commands: Options: -c, --contracts-toml The path to the file with the deployed contracts. Relative to the project root. --json Emit machine-readable JSON instead of human-readable text (read commands only) + --state-root-hash 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. -h, --help Print help ``` +`--state-root-hash` turns any read - a contract getter, `inspect`, `storage`, `whoami` - into a +look at the chain as it was at that root, which is how you answer "what was the balance before +that transaction?". It applies to the whole invocation (or REPL session); `deploy`, `transfer` and +mutable contract calls fail while it is set. + By default, contracts are written/read to/from the `contracts.toml` file, which is located in the `resources` directory, but you can specify a different path using the `-c` or `--contracts-toml` option. If `ODRA_CASPER_LIVENET_CHAIN_NAME` is set, the file is named after the chain instead - for example `resources/casper-test-contracts.toml` - so deployments on different networks never overwrite each other. Apart from `deploy`, `contract` and `scenario`, which you register yourself, every CLI gets the From 3f46a3619aada8c234f3fbbb01fda87f7ee6a609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:47:05 +0200 Subject: [PATCH 13/24] module name also names the package hash key (#385) --- docusaurus/docs/backends/03-casper.md | 2 ++ docusaurus/docs/migrations/to-3.0.0.md | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/docusaurus/docs/backends/03-casper.md b/docusaurus/docs/backends/03-casper.md index 110eaf745..48893a61c 100644 --- a/docusaurus/docs/backends/03-casper.md +++ b/docusaurus/docs/backends/03-casper.md @@ -106,6 +106,8 @@ When deploying a new contract you can pass some arguments to it. Every contract written in Odra expects those arguments to be set: - `odra_cfg_package_hash_key_name` - `String` type. The key under which the package hash of the contract will be stored. + Odra's own deployers use `_package_hash`, where `name` is the `name` argument of + `#[odra::module(name = "..")]` or, without one, the name of the module struct. - `odra_cfg_allow_key_override` - `Bool` type. If `true` and the key specified in `odra_cfg_package_hash_key_name` already exists, it will be overwritten. - `odra_cfg_is_upgradable` - `Bool` type. If `true`, the contract will be deployed as upgradable. - `odra_cfg_is_upgrade` - `Bool` type. If `true`, the contract will be upgraded. If we want to install a contract to should be set to `false`. diff --git a/docusaurus/docs/migrations/to-3.0.0.md b/docusaurus/docs/migrations/to-3.0.0.md index 7a3fd70b5..e1d472df8 100644 --- a/docusaurus/docs/migrations/to-3.0.0.md +++ b/docusaurus/docs/migrations/to-3.0.0.md @@ -188,3 +188,12 @@ env.advance_with_auctions(env.auction_delay() * 2); // unchanged: Duration * 2 Reading the block time is unchanged: `block_time()` / `block_time_millis()` / `block_time_secs()` still return `u64`, as does `ContractEnv::get_block_time()` inside a contract. + +### `#[odra::module(name = "..")]` names the package + +Until now `name` only changed the contract's name in the schema. It now also decides the named key +the package hash is stored under when the contract is installed through Odra (`InstallConfig`, +`UpgradeConfig`, `load_or_deploy`): `_package_hash` instead of `_package_hash`. +A contract with a `name` that was installed with 2.x keeps its old key; a fresh install with 3.0 +uses the new one, so scripts that look the package up by the named key have to follow. Modules +without `name` are unaffected. From aa7c32af99f1104c921b98f30057842ca65551cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 10:54:06 +0200 Subject: [PATCH 14/24] Migration guide: upgrades and the renamed package key --- docusaurus/docs/migrations/to-3.0.0.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docusaurus/docs/migrations/to-3.0.0.md b/docusaurus/docs/migrations/to-3.0.0.md index e1d472df8..96fba4911 100644 --- a/docusaurus/docs/migrations/to-3.0.0.md +++ b/docusaurus/docs/migrations/to-3.0.0.md @@ -197,3 +197,9 @@ the package hash is stored under when the contract is installed through Odra (`I A contract with a `name` that was installed with 2.x keeps its old key; a fresh install with 3.0 uses the new one, so scripts that look the package up by the named key have to follow. Modules without `name` are unaffected. + +Upgrades are not affected: the package is found by its address and authorized by the access URef +the account holds, so an upgrade of a 2.x deployment simply stores the package hash under the new +key as well. One thing to watch: the "already installed" guard (`allow_key_override = false`) +checks the *new* key name, so it no longer stops a fresh install next to a 2.x deployment of the +same contract - use `load_or_deploy` or check `contracts.toml` rather than relying on the revert. From 2a8a1cc4d0f9c33756f43f29a1b1ccf659d24a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 11:37:51 +0200 Subject: [PATCH 15/24] Upgrade tutorial: what happens to entry points and storage (#378) --- docusaurus/docs/tutorials/upgrades.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docusaurus/docs/tutorials/upgrades.md b/docusaurus/docs/tutorials/upgrades.md index bec043320..2c7e35860 100644 --- a/docusaurus/docs/tutorials/upgrades.md +++ b/docusaurus/docs/tutorials/upgrades.md @@ -90,6 +90,16 @@ impl CounterV2 { The contract implements the `upgrade` function, which allows executing the upgrade logic for the contract. When upgrading to a new version, the `upgrade` function is called with the new initialization parameters. We call the `try_upgrade` function with `CounterV2UpgradeArgs` - a struct [automatically generated] by the Odra framework. It is a mirror feature of the contract's initialization parameters. +Two things to keep in mind about what an upgrade does to the package: + +- **Entry points follow the new code.** After the upgrade the package exposes exactly the entry + points of `CounterV2`; anything `CounterV1` had and `CounterV2` does not is gone, and calling it + fails. In the example `CounterV1::reset` disappears and `CounterV2::set` appears. +- **Storage is kept, layout is yours to migrate.** The named keys and dictionaries stay where they + are, so `CounterV2` still sees the `counter` value written by `CounterV1` (`get_old()` reads it). + `upgrade` is the place to move data into new fields - here it copies the old counter into + `new_counter` unless a new start value was given. `init` is *not* called again. + ## Run the example Now, let's see the code in action! From 1625262cd65ab984a0be8aec9ce1d4bdc70cf96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 11:42:43 +0200 Subject: [PATCH 16/24] Docs: livenet examples live in the examples' odra_cli (#631) --- docusaurus/docs/backends/04-livenet.md | 17 ++++++++++++----- docusaurus/docs/basics/10-cross-calls.md | 7 +++++-- docusaurus/docs/tutorials/using-proxy-caller.md | 3 ++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docusaurus/docs/backends/04-livenet.md b/docusaurus/docs/backends/04-livenet.md index 7bc4f1ef5..a63a0e839 100644 --- a/docusaurus/docs/backends/04-livenet.md +++ b/docusaurus/docs/backends/04-livenet.md @@ -122,9 +122,11 @@ ODRA_CASPER_LIVENET_EVENTS_URL= CSPR.cloud is a service that provides mainnet and testnet Casper nodes on demand. ::: -With the proper value in place, we can write our tests or deploy scenarios. In the examples, we can find -a simple binary that deploys a contract and calls it. The test is located in the [erc20_on_livenet.rs] file. -Let's go through the code: +With the proper value in place, we can write our tests or deploy scenarios. The smallest possible +program deploys a contract and calls it - the code below is that program. (In the Odra repository +the same steps live in the examples' [Odra CLI](../tutorials/odra-cli.md) as the `erc20-transfer` +scenario: `cargo run --bin odra_cli -- scenario erc20-transfer --amount 1000`.) Let's go through +the code: ```rust //! Deploys an ERC20 contract and transfers some tokens to another address. @@ -284,6 +286,11 @@ To run the above code, we simply need to run the binary with the `livenet` featu cargo run --bin erc20_on_livenet --features=livenet ``` +For anything beyond a one-off script, register the contracts with an [Odra CLI](../tutorials/odra-cli.md) +instead: it keeps the deployed addresses in `resources/-contracts.toml`, exposes every entry point +as a command and turns scripts like the one above into named scenarios. The examples in the Odra +repository are organised that way. + :::note Before executing the binary, make sure you built the wasm file - the Livenet backend deploys the artifact from `wasm/`, and fails with `Failed to find wasm file` if it is missing: @@ -346,10 +353,10 @@ has to be used first. If your `integration.env` file has a value that IS present override the value from the `.env` file. ```bash -ODRA_CASPER_LIVENET_ENV=integration cargo run --bin erc20_on_livenet --features=livenet +ODRA_CASPER_LIVENET_ENV=integration cargo run --bin odra_cli --features=livenet -- deploy ``` To sum up - this command will firstly load the `integration.env` file and then load the missing values from `.env` file. [.env.sample]: https://github.com/odradev/odra/blob/release/2.9.0/examples/.env.sample -[erc20_on_livenet.rs]: https://github.com/odradev/odra/blob/release/2.9.0/examples/bin/erc20_on_livenet.rs \ No newline at end of file +[odra_cli.rs]: https://github.com/odradev/odra/blob/release/3.0.0/examples/bin/odra_cli.rs \ No newline at end of file diff --git a/docusaurus/docs/basics/10-cross-calls.md b/docusaurus/docs/basics/10-cross-calls.md index d4ea58d79..c41a98bce 100644 --- a/docusaurus/docs/basics/10-cross-calls.md +++ b/docusaurus/docs/basics/10-cross-calls.md @@ -116,14 +116,17 @@ construct a `...ContractRef` by hand. Sometimes it is useful to load the deployed contract instead of deploying it by ourselves. This is especially useful when we want to test our contracts in [Livenet](../backends/04-livenet.md) backend. We can load the contract using `load` method on the `Deployer`: -```rust title="examples/bin/erc20_on_livenet.rs" -fn _load_erc20(env: &HostEnv) -> Erc20HostRef { +```rust +fn load_erc20(env: &HostEnv) -> Erc20HostRef { let address = "hash-d26fcbd2106e37be975d2045c580334a6d7b9d0a241c2358a4db970dfd516945"; let address = Address::from_str(address).unwrap(); Erc20::load(env, address) } ``` +With the [Odra CLI](../tutorials/odra-cli.md) the address does not have to be pasted at all: +`Erc20::load_from_default_file(&env)` reads it from the contracts file the `deploy` command wrote. + ## Testing Let's see how we can test our cross calls using this knowledge: diff --git a/docusaurus/docs/tutorials/using-proxy-caller.md b/docusaurus/docs/tutorials/using-proxy-caller.md index 1ba74b55c..7a6e2e7c4 100644 --- a/docusaurus/docs/tutorials/using-proxy-caller.md +++ b/docusaurus/docs/tutorials/using-proxy-caller.md @@ -143,11 +143,12 @@ name = "tlw_on_livenet" path = "bin/tlw_on_livenet.rs" required-features = ["livenet"] test = false +# (in the Odra repository this is the `tlw` scenario of the examples' `odra_cli` binary) ... # other sections ``` -```rust title=examples/bin/tlw_on_livenet.rs showLineNumbers +```rust title=bin/tlw_on_livenet.rs showLineNumbers //! Deploys an [odra_examples::contracts::tlw::TimeLockWallet] contract, then deposits and withdraw some CSPRs. use odra::casper_types::{AsymmetricType, PublicKey, U512}; use odra::host::{Deployer, HostRef}; From b30c71836b29e3228481b47b0eeedc5f4eeafc78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 13:29:26 +0200 Subject: [PATCH 17/24] Document ContractEnv::call_stack / nth_caller and HostEnv snapshots Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/basics/06-communicating-with-host.md | 31 +++++++++++++++++ docusaurus/docs/basics/07-testing.md | 33 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/docusaurus/docs/basics/06-communicating-with-host.md b/docusaurus/docs/basics/06-communicating-with-host.md index 32c5e3312..323809018 100644 --- a/docusaurus/docs/basics/06-communicating-with-host.md +++ b/docusaurus/docs/basics/06-communicating-with-host.md @@ -54,6 +54,37 @@ In this example, we use two of them: * `get_block_time()` - returns the current block time as u64. * `caller()` - returns an Odra `Address` of the caller (this can be an external caller or another contract). +## Call stack + +`caller()` is the address right behind the contract: an account, or another contract that called it. +Sometimes that is not enough. A token with a dedicated burner contract wants to burn from the +*account* that called the burner, not from the burner itself. `self.env().call_stack()` returns +every address on the way, from the account that sent the transaction to the current contract, and +`self.env().nth_caller(n)` walks it: `nth_caller(0)` is `caller()`, `nth_caller(1)` is the caller +of the caller, and so on, `None` when the stack is not that deep. + +```rust title="examples/src/features/call_stack.rs" +#[odra::module] +impl CallStackProbe { + /// Returns the immediate caller, the caller of the caller (if any) and the call stack from + /// the initiating account to this contract. + pub fn inspect(&self) -> (Address, Option
, Vec
) { + let env = self.env(); + (env.caller(), env.nth_caller(1), env.call_stack()) + } +} +``` + +Called by an account directly, `inspect` answers `(account, None, [account, probe])`. Called +through another contract, it answers `(relay, Some(account), [account, relay, probe])`. The same on +OdraVM, CasperVM and livenet. + +:::caution +Trusting an address further up the stack has the usual `tx.origin` caveats: a user can be tricked +into calling a malicious contract that then calls yours. Check the immediate caller first, as the +burner example does, and only then look behind it. +::: + ## Debug output `self.env().debug(message)` prints a message on the host that runs the contract - the quickest way to diff --git a/docusaurus/docs/basics/07-testing.md b/docusaurus/docs/basics/07-testing.md index 0529051b8..5e3d01ca9 100644 --- a/docusaurus/docs/basics/07-testing.md +++ b/docusaurus/docs/basics/07-testing.md @@ -128,6 +128,8 @@ the function we are calling inside the contract. - `fn get_account(&self, n: usize) -> Address` - returns an n-th address that was prepared for you by Odra in advance; by default, you start with the 0-th account - `fn emitted_event(&self, contract_address: &R, event: T) -> bool` - verifies if the event was emitted by the contract +- `fn take_snapshot(&self)` / `fn restore_snapshot(&self)` - remember the state of the test VM and bring + it back, see [Snapshots](#snapshots) below - `fn enable_addressable_entity(&self) -> bool` - switches the backend from legacy mode to addressable-entity mode, migrating the chain state like a real network upgrade would; returns `false` if the backend does not support the switch or already runs in that mode (see the @@ -135,6 +137,37 @@ the function we are calling inside the contract. Full list of functions can be found in the [`HostEnv`] documentation. +## Snapshots + +A test often needs one expensive setup (deploy a few contracts, mint, approve, wire them together) +and then several scenarios branching off it. Instead of repeating the setup, take a snapshot of +the VM and go back to it: + +```rust title="examples/src/features/testing.rs" +let env = odra_test::env(); +let token = OwnedToken::deploy(&env, /* .. */); +// ... configure the contracts ... + +env.take_snapshot(); + +// Scenario A +token.transfer(&alice, &U256::from(100)); +env.advance_block_time(Duration::from_secs(60 * 60)); +assert_eq!(token.balance_of(&alice), U256::from(100)); + +// Back to the starting point: scenario A never happened. +env.restore_snapshot(); +assert_eq!(token.balance_of(&alice), U256::zero()); + +// Scenario B starts from the same point; the snapshot can be restored again and again. +``` + +A snapshot covers the contract storage, CSPR balances, events and the block time. Only the last +snapshot is kept: taking a new one replaces it. The caller chosen with `set_caller` and the gas +report are not part of it. Snapshots work on OdraVM and on CasperVM (where restoring is only a +pointer back to an older state root); on livenet the state lives on a real chain, so +`take_snapshot` panics there. + ## Choosing the backend `odra_test::env()` returns the backend selected by the `ODRA_BACKEND` environment variable: From aeff804bde465532391a2b3a03298b2d9edb194e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 13:59:35 +0200 Subject: [PATCH 18/24] Document HostEnv::concurrently and the async CasperClient API Co-Authored-By: Claude Opus 5 (1M context) --- docusaurus/docs/backends/04-livenet.md | 48 ++++++++++++++++++++++++++ docusaurus/docs/basics/07-testing.md | 2 ++ 2 files changed, 50 insertions(+) diff --git a/docusaurus/docs/backends/04-livenet.md b/docusaurus/docs/backends/04-livenet.md index a63a0e839..1b503d978 100644 --- a/docusaurus/docs/backends/04-livenet.md +++ b/docusaurus/docs/backends/04-livenet.md @@ -342,6 +342,54 @@ Basically, if the entrypoint function is not mutable or does not make a call to node is used for the state query only. However, the Livenet needs to know the connection between the contracts and the code, so make sure to deploy or load already deployed contracts +## Doing several things at once + +Every transaction waits for its block and every read is a round trip to the node, so a script that +deploys five contracts or reads fifty balances spends most of its time waiting. `HostEnv::concurrently` +runs one closure per item, spread over a few worker threads, each with its own node connection and its +own `HostEnv` (same caller and gas as yours), and returns the results in the order of the items: + +```rust title="examples/bin/odra_cli.rs" +env.set_gas(cspr!(450)); +let addresses = env.concurrently((0..count).collect(), |env, i| { + let mut args = erc20_args(); + args.name = format!("Plascoin {i}"); + Erc20::deploy(env, args).address() +}); +let tokens: Vec = addresses.iter().map(|a| Erc20::load(env, *a)).collect(); + +let supplies = env.concurrently(addresses, |env, address| { + Erc20::load(env, address).total_supply() +}); +``` + +The closure gets the environment to use; it cannot capture yours (a `HostEnv` cannot be sent to +another thread, the compiler says so). Deploy inside, return the address, and `load` it in your own +environment. The same code runs on OdraVM and CasperVM, where the items simply run one after another, +so a test written this way needs no livenet. + +`cargo run --bin odra_cli --features livenet -- scenario concurrent` runs this against your node and +prints how long the reads take one after another and concurrently. + +## Async code + +The livenet `HostEnv` is synchronous, like every other backend. Underneath, `CasperClient` in +`odra-casper-rpc-client` is async: every network call exists twice, `xxx` (blocking) and `xxx_async`. +An async program (a web service, a `#[tokio::main]` tool) can use the client directly and run several +calls at once: + +```rust +let client = CasperClient::new(CasperClientConfiguration::from_env()?); +let balances = futures::future::join_all( + accounts.iter().map(|account| client.get_balance_async(account)) +).await; +``` + +The blocking calls drive the async ones on a process-wide Tokio runtime. They also work inside a +multi-thread Tokio runtime, which is what `#[tokio::main]` gives you; inside a current-thread runtime +they refuse to run (blocking there would stall every other task), so use the async flavour or +`tokio::task::spawn_blocking`. + ## Multiple environments It is possible to have multiple environments for the Livenet backend. This is useful if we want to easily switch between multiple accounts, diff --git a/docusaurus/docs/basics/07-testing.md b/docusaurus/docs/basics/07-testing.md index 5e3d01ca9..7db7cfe7e 100644 --- a/docusaurus/docs/basics/07-testing.md +++ b/docusaurus/docs/basics/07-testing.md @@ -130,6 +130,8 @@ the function we are calling inside the contract. - `fn emitted_event(&self, contract_address: &R, event: T) -> bool` - verifies if the event was emitted by the contract - `fn take_snapshot(&self)` / `fn restore_snapshot(&self)` - remember the state of the test VM and bring it back, see [Snapshots](#snapshots) below +- `fn concurrently(&self, items: Vec, f: impl Fn(&HostEnv, T) -> R) -> Vec` - runs `f` once + per item; one after another here, on worker threads on [livenet](../backends/04-livenet.md#doing-several-things-at-once) - `fn enable_addressable_entity(&self) -> bool` - switches the backend from legacy mode to addressable-entity mode, migrating the chain state like a real network upgrade would; returns `false` if the backend does not support the switch or already runs in that mode (see the From e85b434918fad903a83e82145059f8612b24e150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 14:29:19 +0200 Subject: [PATCH 19/24] Document #[odra(offchain)] Co-Authored-By: Claude Opus 5 (1M context) --- docusaurus/docs/advanced/03-attributes.md | 42 +++++++++++++++++++++++ docusaurus/docs/tutorials/odra-cli.md | 4 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docusaurus/docs/advanced/03-attributes.md b/docusaurus/docs/advanced/03-attributes.md index 7ebde73a0..011071c4c 100644 --- a/docusaurus/docs/advanced/03-attributes.md +++ b/docusaurus/docs/advanced/03-attributes.md @@ -96,6 +96,48 @@ mod test { } ``` +## Offchain + +Some functions are useful to have on a contract but should never be entry points: iterating a whole +list, aggregating many balances, building a report. On chain they would cost too much gas or not fit +in a transaction at all. `#[odra(offchain)]` keeps such a function in the module, but out of the +deployed contract: it is not a wasm entry point and not in the schema. It runs on the host instead, +reading the contract's state, so it costs nothing and has no size limit. + +### Example + +```rust title="examples/src/features/offchain.rs" +#[odra::module] +impl BalanceBook { + pub fn deposit(&mut self, amount: U256) { /* .. */ } + + pub fn balance_of(&self, owner: &Address) -> U256 { + self.balances.get_or_default(owner) + } + + /// Every holder with its balance: a loop over the whole list, fine on the host. + #[odra(offchain)] + pub fn all_balances(&self) -> Vec<(Address, U256)> { + self.holders + .iter() + .map(|holder| (holder, self.balance_of(&holder))) + .collect() + } +} +``` + +The function is on the `HostRef` like any getter (`book.all_balances()`, `book.try_all_balances()`), +so tests, deploy scripts and scenarios call it the usual way. OdraVM runs it directly, CasperVM runs +it on the host against the VM's storage, and livenet executes it offline against the chain state, +exactly as it does with every non-mutating entry point. odra-cli lists it under `contract ` +marked as offchain. + +The rules: it takes `&self` (it cannot change the state, emit events or transfer tokens), it lives in +a plain `impl` block of the module (not in a trait impl, since the trait is also implemented by the +`ContractRef`), and it cannot be `payable` or `non_reentrant`. It can call the contract's own +functions and non-mutating entry points of other contracts. Other contracts cannot call it: it does +not exist on chain, so it is not on the `ContractRef`. + ## Mixing attributes A function can accept more than one attribute, with one exception: a constructor cannot be payable. diff --git a/docusaurus/docs/tutorials/odra-cli.md b/docusaurus/docs/tutorials/odra-cli.md index 45428fe78..c383505d7 100644 --- a/docusaurus/docs/tutorials/odra-cli.md +++ b/docusaurus/docs/tutorials/odra-cli.md @@ -434,7 +434,9 @@ Commands: help Print this message or the help of the given subcommand(s) ``` -And when a contract is selected, it will show us the available methods: +And when a contract is selected, it will show us the available methods. Functions marked +`#[odra(offchain)]` (see [Attributes](../advanced/03-attributes.md#offchain)) are listed too, marked +as offchain: they run on the host and send no transaction, so they take no `--gas`. ```bash cargo run --bin odra_cli -- contract DogContract From f74ea21924950870de27a6a18038c8b45c855354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Thu, 17 Sep 2026 15:02:49 +0200 Subject: [PATCH 20/24] Document native events on livenet Co-Authored-By: Claude Opus 5 (1M context) --- docusaurus/docs/backends/04-livenet.md | 7 +++++++ docusaurus/docs/basics/09-events.md | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/docusaurus/docs/backends/04-livenet.md b/docusaurus/docs/backends/04-livenet.md index 1b503d978..51d5282fa 100644 --- a/docusaurus/docs/backends/04-livenet.md +++ b/docusaurus/docs/backends/04-livenet.md @@ -342,6 +342,13 @@ Basically, if the entrypoint function is not mutable or does not make a call to node is used for the state query only. However, the Livenet needs to know the connection between the contracts and the code, so make sure to deploy or load already deployed contracts +## Native events + +Native events emitted by the transactions this environment sends are readable the usual way +(`native_events_count`, `get_native_event`, `last_call().emitted_native_events`). Casper keeps a +message's payload only in the execution result of its transaction, so events emitted earlier, or by +someone else, are not visible; see [Events](../basics/09-events.md#native-events-on-livenet). + ## Doing several things at once Every transaction waits for its block and every read is a round trip to the node, so a script that diff --git a/docusaurus/docs/basics/09-events.md b/docusaurus/docs/basics/09-events.md index b13da301f..7479b4bbd 100644 --- a/docusaurus/docs/basics/09-events.md +++ b/docusaurus/docs/basics/09-events.md @@ -122,6 +122,18 @@ fn test_party() { To explore more event testing functions, check the [`HostEnv`] documentation. +### Native events on livenet + +The same functions work on the [livenet backend](../backends/04-livenet.md), with one difference +that comes from Casper itself. A CES event is stored in the contract's state, so any client can read +event number N at any time. A native event is a Casper message: its payload travels only in the +execution result of the transaction that emitted it, and the chain keeps no readable list of past +messages. The livenet environment therefore records the native events of the transactions it sends, +and `native_events_count`, `get_native_event`, `emitted_native_event` and +`last_call().emitted_native_events` answer from that record. Events emitted before the environment was +created, or by someone else's transactions, are not visible to it. If a script needs to find every +event a contract ever emitted, use CES events, or an event store such as the Casper sidecar. + ## What's next Read the next article to learn how to call other contracts from the contract context. From c3b903fb229d8c5c5a74fa6d6b5f5af7c2ef0657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Fri, 18 Sep 2026 08:43:23 +0200 Subject: [PATCH 21/24] Document contracts from other crates in Odra.toml Co-Authored-By: Claude Opus 5 (1M context) --- docusaurus/docs/basics/03-odra-toml.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docusaurus/docs/basics/03-odra-toml.md b/docusaurus/docs/basics/03-odra-toml.md index d6d871fb8..d49e3b7e2 100644 --- a/docusaurus/docs/basics/03-odra-toml.md +++ b/docusaurus/docs/basics/03-odra-toml.md @@ -42,6 +42,27 @@ fqn = "flipper::Flipper" fqn = "counter::Counter" ``` +## Contracts from other crates + +A contract does not have to live in your project. If the `fqn` starts with the name of a crate your +project depends on, Odra builds that crate's contract for you. For example, with `odra-modules` in +your `Cargo.toml`: + +```toml +[[contracts]] +fqn = "odra_modules::erc20::Erc20" + +[[contracts]] +fqn = "odra_modules::cep18_token::Cep18" +``` + +`cargo odra build -c Erc20` and `cargo odra schema -c Erc20` write `wasm/Erc20.wasm` and +`resources/casper_contract_schemas/erc20_schema.json` in the current project, next to the wasm files +and schemas of your own contracts - there is nothing to copy from the other crate's directory. + +The contract is then used like any local one: the `Erc20HostRef` comes from `odra_modules::erc20`, +and `env.deploy` in tests or a livenet script finds the wasm by the struct name as usual. + ## What's next In the next section, we'll take a closer look at the code that was generated by Odra by default - the famous `Flipper` contract. From c0cf70c2c08a94717e76f59a9150fbdb53e933b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Mon, 5 Oct 2026 12:39:51 +0200 Subject: [PATCH 22/24] Document #[odra::ref_helpers] (odra#490) Co-Authored-By: Claude Opus 5.5 (1M context) --- docusaurus/docs/basics/10-cross-calls.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docusaurus/docs/basics/10-cross-calls.md b/docusaurus/docs/basics/10-cross-calls.md index c41a98bce..c3b4b2643 100644 --- a/docusaurus/docs/basics/10-cross-calls.md +++ b/docusaurus/docs/basics/10-cross-calls.md @@ -112,6 +112,30 @@ AdderContractRef::new(self.env(), address).add(3, 5) construct a `...ContractRef` by hand. ::: +### Helpers on the refs +A convenience function built from entry point calls is often needed both in a contract (on the +`ContractRef`) and in tests (on the `HostRef`). Instead of writing it twice, put it in an inherent impl +block named after the module or the external contract trait and mark it with `#[odra::ref_helpers]`: + +```rust +#[odra::external_contract] +pub trait NameToken { + fn metadata(&self, id: Maybe, hash: Maybe) -> String; +} + +#[odra::ref_helpers] +impl NameToken { + pub fn metadata_by_hash(&self, hash: String) -> String { + self.metadata(Maybe::None, Maybe::Some(hash)) + } +} +``` + +The block is copied into `impl NameTokenContractRef` and `impl NameTokenHostRef` (the latter only +outside wasm), so `token.metadata_by_hash(hash)` works in a contract and in a test alike. The helpers +may call only what both refs have - the entry points. A helper calling an entry point that takes +`&mut self` has to take `&mut self` too. + ### Loading the contract Sometimes it is useful to load the deployed contract instead of deploying it by ourselves. This is especially useful when we want to test our contracts in [Livenet](../backends/04-livenet.md) backend. We can load the contract using `load` method on the `Deployer`: From 561bc54b39f02d1e64302af898c1e4503054fe63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Tue, 6 Oct 2026 09:41:36 +0200 Subject: [PATCH 23/24] Target Odra 2.10.0: own migration guide, AE-only 3.0.0 guide The party changes ship in 2.10.0 on the current Casper stack, without the addressable-entity switch. Their migration notes move to to-2.10.0.md (plus the livenet client, error-code and custom-backend notes); to-3.0.0.md goes back to the addressable-entity guide from master. The time API takes a Duration or milliseconds and the delays stay in milliseconds, so the Duration section is a tip now, not a breaking change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../docs/advanced/08-delegating-cspr.md | 6 +- docusaurus/docs/backends/04-livenet.md | 2 +- .../docs/basics/06-communicating-with-host.md | 2 +- docusaurus/docs/basics/07-testing.md | 4 +- docusaurus/docs/migrations/to-2.10.0.md | 131 ++++++++++++++++++ docusaurus/docs/migrations/to-3.0.0.md | 102 +------------- docusaurus/static/llms.txt | 1 + 7 files changed, 141 insertions(+), 107 deletions(-) create mode 100644 docusaurus/docs/migrations/to-2.10.0.md diff --git a/docusaurus/docs/advanced/08-delegating-cspr.md b/docusaurus/docs/advanced/08-delegating-cspr.md index 2bd9fec41..2bc293a33 100644 --- a/docusaurus/docs/advanced/08-delegating-cspr.md +++ b/docusaurus/docs/advanced/08-delegating-cspr.md @@ -117,9 +117,9 @@ It is possible to test the delegation and undelegation of tokens in the contract You can see, that we use the new methods from HostEnv, namely: ```rust - fn advance_with_auctions(&self, time_diff: Duration); - fn auction_delay(&self) -> Duration; - fn unbonding_delay(&self) -> Duration; + fn advance_with_auctions(&self, time_diff: impl BlockTimeDiff); // Duration or milliseconds + fn auction_delay(&self) -> u64; // milliseconds + fn unbonding_delay(&self) -> u64; // milliseconds fn delegated_amount(&self, delegator: Address, validator: PublicKey) -> U512; ``` diff --git a/docusaurus/docs/backends/04-livenet.md b/docusaurus/docs/backends/04-livenet.md index 51d5282fa..411d64053 100644 --- a/docusaurus/docs/backends/04-livenet.md +++ b/docusaurus/docs/backends/04-livenet.md @@ -414,4 +414,4 @@ ODRA_CASPER_LIVENET_ENV=integration cargo run --bin odra_cli --features=livenet To sum up - this command will firstly load the `integration.env` file and then load the missing values from `.env` file. [.env.sample]: https://github.com/odradev/odra/blob/release/2.9.0/examples/.env.sample -[odra_cli.rs]: https://github.com/odradev/odra/blob/release/3.0.0/examples/bin/odra_cli.rs \ No newline at end of file +[odra_cli.rs]: https://github.com/odradev/odra/blob/release/2.10.0/examples/bin/odra_cli.rs \ No newline at end of file diff --git a/docusaurus/docs/basics/06-communicating-with-host.md b/docusaurus/docs/basics/06-communicating-with-host.md index 323809018..4b37e757a 100644 --- a/docusaurus/docs/basics/06-communicating-with-host.md +++ b/docusaurus/docs/basics/06-communicating-with-host.md @@ -98,7 +98,7 @@ On OdraVM it always prints. For the Casper VM the contract has to be built with feature of `odra`, which compiles the call into Casper's `casper_print` host function: ```toml title="Cargo.toml" -odra = { version = "3.0.0", features = ["test-support"] } +odra = { version = "2.10.0", features = ["test-support"] } ``` Run the tests with `cargo odra test -b casper -- --nocapture` to see the output. Without the feature diff --git a/docusaurus/docs/basics/07-testing.md b/docusaurus/docs/basics/07-testing.md index 7db7cfe7e..193abb4a8 100644 --- a/docusaurus/docs/basics/07-testing.md +++ b/docusaurus/docs/basics/07-testing.md @@ -123,8 +123,8 @@ the function we are calling inside the contract. - `fn balance_of(&self, addr: &T) -> U512` - returns the balance of the account associated with the given address - `fn block_time(&self) -> u64` - returns the current value of `block_time` in milliseconds, alias: `block_time_millis` - `fn block_time_secs(&self) -> u64` - retuns the current value of `block_time` in seconds -- `fn advance_block_time(&self, time_diff: Duration)` - increases the current value of `block_time` by `time_diff` - (`core::time::Duration`; block time has millisecond resolution) +- `fn advance_block_time(&self, time_diff: impl BlockTimeDiff)` - increases the current value of `block_time` by + `time_diff`: a `core::time::Duration` or a number of milliseconds (block time has millisecond resolution) - `fn get_account(&self, n: usize) -> Address` - returns an n-th address that was prepared for you by Odra in advance; by default, you start with the 0-th account - `fn emitted_event(&self, contract_address: &R, event: T) -> bool` - verifies if the event was emitted by the contract diff --git a/docusaurus/docs/migrations/to-2.10.0.md b/docusaurus/docs/migrations/to-2.10.0.md new file mode 100644 index 000000000..35f16bee7 --- /dev/null +++ b/docusaurus/docs/migrations/to-2.10.0.md @@ -0,0 +1,131 @@ +--- +sidebar_position: 7 +description: Migration guide to v2.10.0 +--- + +# Migration guide to v2.10.0 from 2.9 + +Odra v2.10.0 is a feature release on the same Casper stack as 2.9. Most projects only bump the +version: + +```toml +odra = "2.10.0" +``` + +A few changes can still need attention: + +- [Compile-time changes](#compile-time-changes): arguments of `#[odra::module]` on an `impl` block + and a duplicated `#[odra::external_contract]` trait. +- [`Erc20::mint` and `Erc20::burn`](#erc20-mint-burn) are no longer entry points (a security fix). +- [`#[odra::module(name = "..")]`](#module-name) also names the package-hash key. +- [Livenet client and error codes](#livenet-client-and-error-codes), for code that uses + `CasperClient` directly or matches on error codes. +- [Custom backends](#custom-backends). + +:::tip +`HostEnv::advance_block_time` and `advance_with_auctions` now also accept a `core::time::Duration`, +so `env.advance_block_time(Duration::from_secs(60))` states the unit itself. A number still means +milliseconds, so existing calls keep working. +::: + +## Compile-time changes + +Both are things that used to compile and silently did the wrong thing. If your project builds, you +are not affected. + +### `#[odra::module(...)]` arguments on an `impl` block are an error + +`events`, `errors`, `name`, `version` and `layout` belong to the module struct. Put on an `impl` +block they were parsed and ignored - the events never made it into the contract schema. Now the +compiler points at the misplaced argument: + +```rust +#[odra::module(events = [Transfer])] // error: `events` is not allowed on an impl block +impl Token { ... } +``` + +Move the argument to the struct: + +```rust +#[odra::module(events = [Transfer])] +pub struct Token { ... } + +#[odra::module] +impl Token { ... } +``` + +Only `factory = on` is accepted on an `impl` block, and `#[odra::module]` on a trait takes no +arguments. + +### `#[odra::external_contract]` keeps the trait + +The annotated trait is now emitted as written, and both `XxxContractRef` and `XxxHostRef` +implement it. Previously the trait disappeared, so a common workaround was to declare it twice: + +```rust +#[odra::external_contract] +pub trait Adapter { fn owner_of(&self, token_id: TokenId) -> Option
; } + +pub trait Adapter { fn owner_of(&self, token_id: TokenId) -> Option
; } // remove this copy +``` + +That copy now fails with *the name `Adapter` is defined multiple times* - delete it. The trait can +be used as a bound (`fn check(a: &T)`) or implemented by one of your modules. + +## `Erc20::mint` and `Erc20::burn` are no longer entry points {#erc20-mint-burn} + +In `odra-modules`, `Erc20::mint`, `Erc20::burn` and `Ownable::unchecked_transfer_ownership` moved out +of the `#[odra::module]` impl blocks. A contract built directly from `Erc20` had an unprotected `mint` +entry point; now these functions exist only in Rust, for a wrapping module to call behind its own +check (as `OwnedToken` in the examples does): + +```rust +#[odra::module] +impl OwnedToken { + pub fn mint(&mut self, address: &Address, amount: &U256) { + self.ownable.assert_owner(&self.env().caller()); + self.erc20.mint(address, amount); + } +} +``` + +`Erc20HostRef::mint` / `try_mint` and `burn` / `try_burn` are gone; if a test relied on them, mint +through your wrapping contract or use `initial_supply` in `init`. + +## `#[odra::module(name = "..")]` names the package {#module-name} + +Until now `name` only changed the contract's name in the schema. It now also decides the named key +the package hash is stored under when the contract is installed through Odra (`InstallConfig`, +`UpgradeConfig`, `load_or_deploy`): `_package_hash` instead of `_package_hash`. +A contract with a `name` that was installed with 2.9 or earlier keeps its old key; a fresh install with 2.10 +uses the new one, so scripts that look the package up by the named key have to follow. Modules +without `name` are unaffected. + +Upgrades are not affected: the package is found by its address and authorized by the access URef +the account holds, so an upgrade of a 2.9 deployment simply stores the package hash under the new +key as well. One thing to watch: the "already installed" guard (`allow_key_override = false`) +checks the *new* key name, so it no longer stops a fresh install next to a 2.9 deployment of the +same contract - use `load_or_deploy` or check `contracts.toml` rather than relying on the revert. + +## Livenet client and error codes + +Only code that uses `odra-casper-rpc-client` directly, or matches on error codes, is affected. + +- `CasperClient::get_value`, `get_named_value`, `get_dictionary_value` and `events_count` return + `Result>`: `Ok(None)` is a value the node does not have, `Err` is a node that could not be + asked. Handle both cases where you used to get the value directly. +- `CasperClient::deploy_wasm` takes `&self` instead of `&mut self`. +- The blocking `CasperClient` calls panic inside a current-thread Tokio runtime and point to the new + `xxx_async` methods. Use those, or a multi-thread runtime (`#[tokio::main]`). +- Reading a stored value as the wrong type reverts with the concrete `bytesrepr` error + (`LeftOverBytes`, `EarlyEndOfStream`, ...) instead of `Formatting`. A named argument that exists but + has the wrong type reverts with `ExecutionError::InvalidArg` (138) instead of `MissingArg`. + +## Custom backends + +`ContractContext` gained `call_stack` and `debug`, and `HostContext` gained `take_snapshot`, +`restore_snapshot` and `thread_env_factory`. All of them have default implementations, so an existing +backend keeps compiling; override them to support the new features. + +`odra::entry_point_callback::EntryPoint` has a new `is_offchain` field. If you build entry points +with a struct literal, switch to `EntryPoint::new`, `new_payable` or `new_offchain`. diff --git a/docusaurus/docs/migrations/to-3.0.0.md b/docusaurus/docs/migrations/to-3.0.0.md index 96fba4911..dad32164f 100644 --- a/docusaurus/docs/migrations/to-3.0.0.md +++ b/docusaurus/docs/migrations/to-3.0.0.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 description: Migration guide to v3.0.0 --- @@ -9,8 +9,7 @@ Odra v3.0.0 moves to the Casper 2.x **addressable entity** stack: `casper-types` and `casper-execution-engine` 9. That is what makes it a major release. Most projects need no code changes — rebuild and carry on. Read on if you store a caller's raw `Key`, -or have upgradable contracts already deployed. Two macro changes can also surface as compile errors, -see [Compile-time changes](#compile-time-changes). +or have upgradable contracts already deployed. ## What changes @@ -106,100 +105,3 @@ Because `enable_addressable_entity()` returns `false` on backends without the sw safe to run anywhere but only *proves* something on the casper backend with `ODRA_CASPER_LEGACY_GENESIS=1`. Make sure your CI runs it that way, or it passes without executing. ::: - -## Compile-time changes - -Both are things that used to compile and silently did the wrong thing. If your project builds, you -are not affected. - -### `#[odra::module(...)]` arguments on an `impl` block are an error - -`events`, `errors`, `name`, `version` and `layout` belong to the module struct. Put on an `impl` -block they were parsed and ignored - the events never made it into the contract schema. Now the -compiler points at the misplaced argument: - -```rust -#[odra::module(events = [Transfer])] // error: `events` is not allowed on an impl block -impl Token { ... } -``` - -Move the argument to the struct: - -```rust -#[odra::module(events = [Transfer])] -pub struct Token { ... } - -#[odra::module] -impl Token { ... } -``` - -Only `factory = on` is accepted on an `impl` block, and `#[odra::module]` on a trait takes no -arguments. - -### `#[odra::external_contract]` keeps the trait - -The annotated trait is now emitted as written, and both `XxxContractRef` and `XxxHostRef` -implement it. Previously the trait disappeared, so a common workaround was to declare it twice: - -```rust -#[odra::external_contract] -pub trait Adapter { fn owner_of(&self, token_id: TokenId) -> Option
; } - -pub trait Adapter { fn owner_of(&self, token_id: TokenId) -> Option
; } // remove this copy -``` - -That copy now fails with *the name `Adapter` is defined multiple times* - delete it. The trait can -be used as a bound (`fn check(a: &T)`) or implemented by one of your modules. - -### `Erc20::mint` and `Erc20::burn` are no longer entry points - -In `odra-modules`, `Erc20::mint`, `Erc20::burn` and `Ownable::unchecked_transfer_ownership` moved out -of the `#[odra::module]` impl blocks. A contract built directly from `Erc20` had an unprotected `mint` -entry point; now these functions exist only in Rust, for a wrapping module to call behind its own -check (as `OwnedToken` in the examples does): - -```rust -#[odra::module] -impl OwnedToken { - pub fn mint(&mut self, address: &Address, amount: &U256) { - self.ownable.assert_owner(&self.env().caller()); - self.erc20.mint(address, amount); - } -} -``` - -`Erc20HostRef::mint` / `try_mint` and `burn` / `try_burn` are gone; if a test relied on them, mint -through your wrapping contract or use `initial_supply` in `init`. - -### Block time is shifted with `Duration` - -`HostEnv::advance_block_time` and `advance_with_auctions` take a `core::time::Duration` instead of -a number of milliseconds, and `auction_delay()` / `unbonding_delay()` return one. The old `u64` -calls fail to compile with *expected `Duration`, found integer*; wrap the value: - -```rust -use core::time::Duration; - -env.advance_block_time(60 * 60 * 1000); // before -env.advance_block_time(Duration::from_secs(60 * 60)); // after - -env.advance_with_auctions(env.auction_delay() * 2); // unchanged: Duration * 2 -``` - -Reading the block time is unchanged: `block_time()` / `block_time_millis()` / `block_time_secs()` -still return `u64`, as does `ContractEnv::get_block_time()` inside a contract. - -### `#[odra::module(name = "..")]` names the package - -Until now `name` only changed the contract's name in the schema. It now also decides the named key -the package hash is stored under when the contract is installed through Odra (`InstallConfig`, -`UpgradeConfig`, `load_or_deploy`): `_package_hash` instead of `_package_hash`. -A contract with a `name` that was installed with 2.x keeps its old key; a fresh install with 3.0 -uses the new one, so scripts that look the package up by the named key have to follow. Modules -without `name` are unaffected. - -Upgrades are not affected: the package is found by its address and authorized by the access URef -the account holds, so an upgrade of a 2.x deployment simply stores the package hash under the new -key as well. One thing to watch: the "already installed" guard (`allow_key_override = false`) -checks the *new* key name, so it no longer stops a fresh install next to a 2.x deployment of the -same contract - use `load_or_deploy` or check `contracts.toml` rather than relying on the revert. diff --git a/docusaurus/static/llms.txt b/docusaurus/static/llms.txt index 1d59ae0c5..6231cd50f 100644 --- a/docusaurus/static/llms.txt +++ b/docusaurus/static/llms.txt @@ -89,6 +89,7 @@ - [Migration guide to v1.3.0](https://odra.dev/docs/migrations/to-1.3.0) - [Migration guide to v2.0.0 from 1.*](https://odra.dev/docs/migrations/to-2.0.0) - [Migration guide to v2.1.0 from 2.0.*](https://odra.dev/docs/migrations/to-2.1.0) +- [Migration guide to v2.10.0 from 2.9](https://odra.dev/docs/migrations/to-2.10.0) - [Migration guide to v2.6.0 from 2.*](https://odra.dev/docs/migrations/to-2.6.0) - [Migration guide to v3.0.0 from 2.*](https://odra.dev/docs/migrations/to-3.0.0) From 5cfd4067611e9afc0b54cb799224a2c713e3a054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20P=C5=82askonka?= Date: Wed, 7 Oct 2026 09:38:00 +0200 Subject: [PATCH 24/24] Document the dapp registry modules Add a Dapp Registry guide covering DappRegistryBase, DappContractBase, OwnedDappRegistry, composing a registry with AccessControl and registering contracts deployed by a factory. List the modules in "Using odra-modules" and in llms.txt. Co-Authored-By: Claude Opus 5.5 --- .../docs/examples/using-odra-modules.md | 11 + docusaurus/docs/tutorials/dapp-registry.md | 257 ++++++++++++++++++ docusaurus/static/llms.txt | 1 + 3 files changed, 269 insertions(+) create mode 100644 docusaurus/docs/tutorials/dapp-registry.md diff --git a/docusaurus/docs/examples/using-odra-modules.md b/docusaurus/docs/examples/using-odra-modules.md index b8525fb1c..60da10302 100644 --- a/docusaurus/docs/examples/using-odra-modules.md +++ b/docusaurus/docs/examples/using-odra-modules.md @@ -206,5 +206,16 @@ Ownership can be transferred in a two-step process by using `transfer_ownership( A module allowing to implement an emergency stop mechanism that can be triggered by any account. +### Dapp + +#### Dapp registry + +The `dapp` modules group the contracts of one dapp. `DappRegistryBase` stores the dapp metadata and the +list of its contracts, and verifies every contract it adds by calling the contract's `get_dapp_registry`. +`DappContractBase` stores the registry a contract belongs to. Neither module checks the caller, so they +compose with `Ownable` or `AccessControl`. `OwnedDappRegistry` is a ready-to-deploy registry managed by +its owner, where contracts registered as factories may add the contracts they deploy. Read more in the +[Dapp Registry](../tutorials/dapp-registry.md) tutorial. + [Installation guide]: ../getting-started/installation.md [Odra repository]: https://github.com/odradev/odra diff --git a/docusaurus/docs/tutorials/dapp-registry.md b/docusaurus/docs/tutorials/dapp-registry.md new file mode 100644 index 000000000..4d9dd66e9 --- /dev/null +++ b/docusaurus/docs/tutorials/dapp-registry.md @@ -0,0 +1,257 @@ +--- +sidebar_position: 13 +--- + +# Dapp Registry + +A dapp on Casper is usually more than one contract: a token, a vault, a governance contract, a few +contracts deployed later by a factory. Wallets, explorers and other tools see them as unrelated +package hashes. The `dapp` modules of `odra-modules` group them: a **registry** contract describes the +dapp and lists the contracts that belong to it, and every **member** contract points back to its +registry, so the membership can be verified from both sides. + +## Overview + +The modules live in `odra_modules::dapp`: + +| Item | Kind | Purpose | +|---|---|---| +| `DappRegistryBase` | module | Stores the dapp metadata and the list of member contracts. | +| `DappContractBase` | module | Stores the address of the registry a contract belongs to. | +| `OwnedDappRegistry` | contract | A ready-to-deploy registry: `DappRegistryBase` guarded by `Ownable`. | +| `DappRegistry` | external contract trait | The registry interface, used to call a registry by address. | +| `DappContract` | external contract trait | The member interface, used to call a member by address. | +| `DappMetadata` | type | Name, description, website URL and icon URL of the dapp. | + +The registry itself always belongs to the dapp: `get_dapp_contracts` returns it first, and +`is_dapp_contract` is `true` for its own address. + +### Verification + +When a contract is added, the registry calls `get_dapp_registry` on it and reverts with +`DappRegistryMismatch` unless the contract returns the registry's address. Nobody can list a contract +in a dapp it does not belong to, and a member cannot claim a registry that has not accepted it. + +Every contract you add must therefore expose a `get_dapp_registry` entry point. Adding an account +address reverts with `NotAContract`; adding a contract without that entry point fails with a VM error. + +### Factories + +A contract added with `is_factory = true` may register the contracts it deploys. `OwnedDappRegistry` +lets the owner add any contract, and a registered factory add contracts that are not factories +themselves. Removing a contract also removes its factory permission. + +### Authorization + +`DappRegistryBase` and `DappContractBase` do not check who calls them. Their read-only functions +are entry points; the functions that change state (`set_dapp_metadata`, `add_dapp_contract`, +`remove_dapp_contract` and `set_dapp_registry`) are plain Rust methods that are not exposed by the +module. The contract that composes the module decides who may call them, with `Ownable`, +`AccessControl` or its own rules. + +## Deploying a registry + +`OwnedDappRegistry` is the quickest way to start. The deployer becomes the owner. + +```rust +use odra::host::{Deployer, HostRef, NoArgs}; +use odra_modules::dapp::{DappMetadata, OwnedDappRegistry}; + +let mut registry = OwnedDappRegistry::deploy(&env, NoArgs); +registry.set_dapp_metadata(DappMetadata { + name: "My Dapp".to_string(), + description: "Lending on Casper".to_string(), + website_url: "https://mydapp.io".to_string(), + icon_url: "https://mydapp.io/icon.png".to_string() +}); +``` + +It exposes: + +- `set_dapp_metadata`, `remove_dapp_contract`: owner only. +- `add_dapp_contract`: the owner, or a registered factory adding a contract that is not a factory. +- `get_dapp_metadata`, `get_dapp_contracts`, `is_dapp_contract`, `is_dapp_factory`, `get_dapp_registry`. +- `get_owner`, `transfer_ownership`, `renounce_ownership` from `Ownable`. + +## Writing a member contract + +Compose `DappContractBase` and set the registry in `init`. Delegate `get_dapp_registry` so the +registry can verify the contract. Exposing `set_dapp_registry` is optional; if you do, guard it. + +```rust +use odra::prelude::*; +use odra_modules::access::Ownable; +use odra_modules::dapp::DappContractBase; + +#[odra::module] +pub struct Vault { + ownable: SubModule, + dapp: SubModule +} + +#[odra::module] +impl Vault { + pub fn init(&mut self, registry: Address) { + let owner = self.env().caller(); + self.ownable.init(owner); + self.dapp.set_dapp_registry(®istry); + } + + pub fn set_dapp_registry(&mut self, registry: &Address) { + self.ownable.assert_owner(&self.env().caller()); + self.dapp.set_dapp_registry(registry); + } + + delegate! { + to self.dapp { + fn get_dapp_registry(&self) -> Address; + } + } +} +``` + +Then deploy the member with the registry address and add it: + +```rust +let vault = Vault::deploy(&env, VaultInitArgs { registry: registry.address() }); +registry.add_dapp_contract(&vault.address(), false); + +assert_eq!(registry.get_dapp_contracts(), vec![registry.address(), vault.address()]); +``` + +## Building your own registry + +To use other rules than a single owner, compose `DappRegistryBase` with another access module. +Here, only accounts with a curator role manage the dapp: + +```rust +use odra::prelude::*; +use odra_modules::access::{AccessControl, Role, DEFAULT_ADMIN_ROLE}; +use odra_modules::dapp::{DappMetadata, DappRegistryBase}; + +/// Accounts with this role may add and remove contracts. +pub const CURATOR_ROLE: Role = [1u8; 32]; + +#[odra::module] +pub struct CuratedDappRegistry { + access_control: SubModule, + dapp: SubModule +} + +#[odra::module] +impl CuratedDappRegistry { + pub fn init(&mut self, metadata: DappMetadata) { + let admin = self.env().caller(); + self.access_control.unchecked_grant_role(&DEFAULT_ADMIN_ROLE, &admin); + self.access_control.unchecked_grant_role(&CURATOR_ROLE, &admin); + self.dapp.set_dapp_metadata(metadata); + } + + pub fn add_dapp_contract(&mut self, dapp_contract: &Address, is_factory: bool) { + self.access_control.check_role(&CURATOR_ROLE, &self.env().caller()); + self.dapp.add_dapp_contract(dapp_contract, is_factory); + } + + pub fn remove_dapp_contract(&mut self, dapp_contract: &Address) { + self.access_control.check_role(&CURATOR_ROLE, &self.env().caller()); + self.dapp.remove_dapp_contract(dapp_contract); + } + + delegate! { + to self.dapp { + fn get_dapp_metadata(&self) -> DappMetadata; + fn get_dapp_contracts(&self) -> Vec
; + fn is_dapp_contract(&self, dapp_contract: &Address) -> bool; + fn is_dapp_factory(&self, dapp_contract: &Address) -> bool; + fn get_dapp_registry(&self) -> Address; + } + to self.access_control { + fn has_role(&self, role: &Role, address: &Address) -> bool; + fn grant_role(&mut self, role: &Role, address: &Address); + fn revoke_role(&mut self, role: &Role, address: &Address); + } + } +} +``` + +To let factories register contracts in such a registry, call `self.dapp.assert_dapp_factory(&caller)` +for callers without the role, as `OwnedDappRegistry` does. + +## Registering contracts deployed by a factory + +A contract that deploys other contracts can register them right away, once the registry lists it as a +factory. The flow, using [Odra factories](../advanced/09-factory.md): + +1. The admin adds the spawning contract with `add_dapp_contract(&spawner, true)`. +2. The spawner calls `new_contract` on a factory and passes the registry address to the new contract's `init`. +3. The spawner calls `add_dapp_contract(&new_contract, false)` on the registry through + `DappRegistryContractRef`. +4. The registry checks that the caller is a registered factory, calls `get_dapp_registry` on the new + contract and adds it. + +```rust +use odra::{prelude::*, ContractRef}; +use odra_modules::dapp::{DappContractBase, DappRegistryContractRef}; + +#[odra::module] +pub struct DappCounterSpawner { + dapp: SubModule, + factory: Var
, + spawned: Var +} + +#[odra::module] +impl DappCounterSpawner { + // `init` and `get_dapp_registry` omitted. + + pub fn spawn(&mut self) -> Address { + let registry = self.dapp.get_dapp_registry(); + let factory = self.factory.get().unwrap_or_revert(self); + let n = self.spawned.get_or_default(); + // The name keys the child in the factory, so each child gets its own. + let mut factory = DappCounterFactoryContractRef::new(self.env(), factory); + let (address, _) = factory.new_contract(format!("DappCounter{}", n), registry); + self.spawned.set(n + 1); + + DappRegistryContractRef::new(self.env(), registry).add_dapp_contract(&address, false); + address + } +} +``` + +The whole example, with the `DappCounter` contract and a test, is in +[`examples/src/factory/dapp.rs`](https://github.com/odradev/odra/blob/release/2.10.0/examples/src/factory/dapp.rs). + +:::note +Factories work on the Casper VM only, so run such tests with `cargo odra test -b casper`. +::: + +## Events and errors + +Events: + +| Event | Emitted by | Fields | +|---|---|---| +| `DappMetadataChanged` | registry | `name`, `description`, `website_url`, `icon_url` | +| `DappContractAdded` | registry | `contract`, `is_factory`, `registrar` (the caller) | +| `DappContractRemoved` | registry | `contract`, `registrar` | +| `DappRegistryChanged` | member | `previous_registry`, `new_registry` | + +Errors (`odra_modules::dapp::errors::Error`): + +| Error | Code | When | +|---|---|---| +| `NotAContract` | 22000 | An account address is added, or set as a registry. | +| `DappContractAlreadyRegistered` | 22001 | The contract, or the registry itself, is already in the dapp. | +| `DappContractNotRegistered` | 22002 | Removing a contract that is not in the dapp. | +| `DappRegistryMismatch` | 22003 | The contract's `get_dapp_registry` returns a different address. | +| `CallerNotDappFactory` | 22004 | A contract that is not a registered factory tries to add a contract. | +| `DappRegistryNotSet` | 22005 | `get_dapp_registry` is called on a member before its registry is set. | +| `CannotRemoveDappRegistry` | 22006 | Removing the registry itself. | + +## Things to know + +- Removing a contract moves the last contract into its place, so the order of `get_dapp_contracts` + changes. +- `get_dapp_contracts` reads every member from storage. For a dapp with many contracts, prefer + `is_dapp_contract` in contract code. diff --git a/docusaurus/static/llms.txt b/docusaurus/static/llms.txt index 6231cd50f..d1f3a459f 100644 --- a/docusaurus/static/llms.txt +++ b/docusaurus/static/llms.txt @@ -69,6 +69,7 @@ - [Access Control](https://odra.dev/docs/tutorials/access-control) - [Build, Deploy and Read the State of a Contract](https://odra.dev/docs/tutorials/build-deploy-read) - [CEP-18](https://odra.dev/docs/tutorials/cep18) +- [Dapp Registry](https://odra.dev/docs/tutorials/dapp-registry) - [Deploying a Token on Casper Livenet](https://odra.dev/docs/tutorials/deploying-on-casper) - [ERC-20](https://odra.dev/docs/tutorials/erc20) - [Ticketing System](https://odra.dev/docs/tutorials/nft)