Ownable
In this tutorial, we will write a simple module that allows us to set its owner. Later, it can be reused to limit access to the contract's critical features.
Framework features
A module we will write in a minute, will help you master a few Odra features:
- storing a single value,
- defining a constructor,
- error handling,
- defining and emitting
events
. - registering a contact in a test environment,
- interactions with the test environment,
- assertions (value, events, errors assertions).
Code
Before we write any code, we define functionalities we would like to implement.
- Module has an initializer that should be called once.
- Only the current owner can set a new owner.
- Read the current owner.
- A function that fails if called by a non-owner account.
Define a module
use odra::prelude::*;
use odra::{Address, Var};
#[odra::module(events = [OwnershipChanged])]
pub struct Ownable {
owner: Var<Option<Address>>
}
That was easy, but it is crucial to understand the basics before we move on.
- L4 - Firstly, we need to create a struct called
Ownable
and apply#[odra::module(events = [OwnershipChanged])]
attribute to it. Theevents
attribute is optional but informs the Odra toolchain about the events that will be emitted by the module and includes them in the contract's metadata.OwnershipChanged
is a type that will be defined later. - L6 - Then we can define the layout of our module. It is extremely simple - just a single state value. What is most important is that you can never leave a raw type; you must always wrap it with
Var
.
Init the module
...
use odra::{Event, OdraError};
...
#[odra::module]
impl Ownable {
pub fn init(&mut self, owner: Address) {
if self.owner.get_or_default().is_some() {
self.env().revert(Error::OwnerIsAlreadyInitialized)
}
self.owner.set(Some(owner));
self.env().emit_event(OwnershipChanged {
prev_owner: None,
new_owner: owner
});
}
}
#[derive(OdraError)]
pub enum Error {
OwnerIsAlreadyInitialized = 1,
}
#[derive(Event, Debug, PartialEq, Eq)]
pub struct OwnershipChanged {
pub prev_owner: Option<Address>,
pub new_owner: Address
}
Ok, we have done a couple of things, let's analyze them one by one:
- L7 - The
impl
should be an Odra module, so add#[odra::module]
. - L9 - The
init
function is a constructor. This matters if we would like to deploy theOwnable
module as a standalone contract. - L23-26 - Before we set a new owner, we must assert there was no owner before and raise an error otherwise. For that purpose, we defined an
Error
enum. Notice that theOdraError
derive macro is applied to the enum. It generates, among others, the requiredInto<odra::OdraError>
binding. - L10-L12 - If the owner has been set already, we call
ContractEnv::revert()
function with anError::OwnerIsAlreadyInitialized
argument. - L14 - Then we write the owner passed as an argument to the storage. To do so, we call the
set()
onVar
. - L28-L32 - Once the owner is set, we would like to inform the outside world. The first step is to define an event struct. The struct must derive from
odra::Event
. We highly recommend to deriveDebug
,PartialEq
andEq
for testing purpose. - L16 - Finally, call
ContractEnv::emit_event()
passing theOwnershipChanged
instance to the function. Hence, we set the first owner, we set theprev_owner
value toNone
.
Features implementation
#[odra::module]
impl Ownable {
...
pub fn ensure_ownership(&self, address: &Address) {
if Some(address) != self.owner.get_or_default().as_ref() {
self.env().revert(Error::NotOwner)
}
}
pub fn change_ownership(&mut self, new_owner: &Address) {
self.ensure_ownership(&self.env().caller());
let current_owner = self.get_owner();
self.owner.set(Some(*new_owner));
self.env().emit_event(OwnershipChanged {
prev_owner: Some(current_owner),
new_owner: *new_owner
});
}
pub fn get_owner(&self) -> Address {
match self.owner.get_or_default() {
Some(owner) => owner,
None => self.env().revert(Error::OwnerIsNotInitialized)
}
}
}
#[derive(OdraError)]
pub enum Error {
NotOwner = 1,
OwnerIsAlreadyInitialized = 2,
OwnerIsNotInitialized = 3,
}
The above implementation relies on the concepts we have already used in this tutorial, so it should be easy for you to get along.
- L7,L31 -
ensure_ownership()
reads the current owner and reverts if it does not match the inputAddress
. Also, we need to update ourError
enum by adding a new variantNotOwner
. - L11 - The function defined above can be reused in the
change_ownership()
implementation. We pass to it the current caller, using theContractEnv::caller()
function. Then we update the state and emitOwnershipChanged
. - L21,L33 - Lastly, a getter function. Read the owner from storage, if the getter is called on an uninitialized module, it should revert with a new
Error
variantOwnerIsNotInitialized
. There is one worth-mentioning subtlety:Var::get()
function returnsOption<T>
. If the type implements theDefault
trait, you can call theget_or_default()
function, and the contract does not fail even if the value is not initialized. As theowner
is of typeOption<Address>
theVar::get()
would returnOption<Option<Address>>
, we useVar::get_or_default()
instead.
Test
#[cfg(test)]
mod tests {
use super::*;
use odra::host::{Deployer, HostEnv, HostRef};
fn setup() -> (OwnableHostRef, HostEnv, Address) {
let env: HostEnv = odra_test::env();
let init_args = OwnableInitArgs {
owner: env.get_account(0)
};
(OwnableHostRef::deploy(&env, init_args), env.clone(), env.get_account(0))
}
#[test]
fn initialization_works() {
let (ownable, env, owner) = setup();
assert_eq!(ownable.get_owner(), owner);
env.emitted_event(
ownable.address(),
&OwnershipChanged {
prev_owner: None,
new_owner: owner
}
);
}
#[test]
fn owner_can_change_ownership() {
let (mut ownable, env, owner) = setup();
let new_owner = env.get_account(1);
env.set_caller(owner);
ownable.change_ownership(&new_owner);
assert_eq!(ownable.get_owner(), new_owner);
env.emitted_event(
ownable.address(),
&OwnershipChanged {
prev_owner: Some(owner),
new_owner
}
);
}
#[test]
fn non_owner_cannot_change_ownership() {
let (mut ownable, env, _) = setup();
let new_owner = env.get_account(1);
ownable.change_ownership(&new_owner);
assert_eq!(
ownable.try_change_ownership(&new_owner),
Err(Error::NotOwner.into())
);
}
}
- L6 - Each test case starts with the same initialization process, so for convenience, we have defined the
setup()
function, which we call in the first statement of each test. Take a look at the signature:fn setup() -> (OwnableHostRef, HostEnv, Address)
.OwnableHostRef
is a contract reference generated by Odra. This reference allows us to call all the defined entrypoints, namely:ensure_ownership()
,change_ownership()
,get_owner()
, but notinit()
, which is a constructor. - L7-L11 - The starting point of every test is getting an instance of
HostEnv
by callingodra_test::env()
. Our function returns a triple: a contract ref, an env, and an address (the initial owner). Odra's#[odra::module]
attribute implements aodra::host::Deployer
forOwnableHostRef
, andOwnableInitArgs
that we pass as the second argument of theodra::host::Deployer::deploy()
function. Lastly, the module needs an owner. The easiest way is to take one from theHostEnv
. We choose the address of first account (which is the default one). - L14 - It is time to define the first test. As you see, it is a regular Rust test.
- L16-17 - Using the
setup()
function, we get the owner and a reference (in this test, we don't use the env, so we ignore it). We make a standard assertion, comparing the owner we know with the value returned from the contract.noteYou may have noticed, we use here the term
module
interchangeably withcontract
. The reason is once we deploy our module onto a virtual blockchain it may be considered a contract. - L19-25 - On the contract, only the
init()
function has been called, so we expect one event to have been emitted. To assert that, let's useHostEnv
. To get the env, we callenv()
on the contract, then callHostEnv::emitted_event
. As the first argument, pass the contract address you want to read events from, followed by an event as you expect it to have occurred. - L31 - Because we know the initial owner is the 0th account, we must select a different account. It could be any index from 1 to 19 - the
HostEnv
predefines 20 accounts. - L33 - As mentioned, the default is the 0th account, if you want to change the executor, call the
HostEnv::set_caller()
function.noteThe caller switch applies only the next contract interaction, the second call will be done as the default account.
- L46-55 - If a non-owner account tries to change ownership, we expect it to fail. To capture the error, call
HostEnv::try_change_ownership()
instead ofHostEnv::change_ownership()
.HostEnv
provides try_ functions for each contract's entrypoint. Thetry
functions returnOdraResult
(an alias forResult<T, OdraError>
) instead of panicking and halting the execution. In our case, we expect the contract to revert with theError::NotOwner
error. To compare the error, we use theError::into()
function, which converts the error into theOdraError
type.
Summary
The Ownable
module is ready, and we can test it against any defined backend. Theoretically it can be deployed as a standalone contract, but in upcoming tutorials you will see how to use it to compose a more complex contract.
What's next
In the next tutorial we will implement a ERC20 standard.