Hi there! Welcome to my fifth post of my series called "Soroban Contracts 101", where I'll be explaining the basics of Soroban contracts, such as data storage, authentication, custom types, and more. All the code that we're gonna explain throughout this series will mostly come from soroban-contracts-101 github repository.
In this fifth post of the series, I'll be explaining how to define and generate errors. This could be very usefull, by returning finely-grained error information to callers, this enables callers to handle specific errors appropriately, rather than just generic "error occurred" information.
The Contract Code
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
LimitReached = 1,
}
const COUNTER: Symbol = symbol!("COUNTER");
const MAX: u32 = 5;
pub struct IncrementContract;
#[contractimpl]
impl IncrementContract {
pub fn increment(env: Env) -> Result<u32, Error> {
let mut count: u32 = env
.storage()
.get(COUNTER)
.unwrap_or(Ok(0)) // If no value set, assume 0.
.unwrap(); // Panic if the value of COUNTER is not u32.
log!(&env, "count: {}", count);
count += 1;
if count <= MAX {
env.storage().set(COUNTER, count);
Ok(count)
} else {
Err(Error::LimitReached)
}
}
}
To define a contract error enum in Soroban, you must:
- Use the #[contracterror] attribute to mark the enum as a valid contract error type
- Use the #[repr(u32)] attribute to indicate the enum is represented by an integer under the hood
- Use the #[derive(Copy)] attribute (required for contract errors)
- Explicitly assign integer values to each variant
So contract error
enums must adhere to those requirements, but within that you can define finely-grained variants to return specific error information from your contract methods.
In our contract above we start by defining our own custom error type, Error
. This is a required u32 enum (due to the #[repr(u32)] attribute) with a single variant, LimitReached
, for the case where a limit is reached.
The other attributes are required for contract errors and just enable various utilities.
So this allows us to return finely-grained error information from our contract methods.
The contract itself similar to our second post of this blog post series with additional contract error defined. With the contract error handler defined, the increment
function now :
- Gets and increments the count
- Checks if the count is at or below a MAX limit (5 in this case)
- If so, stores the incremented count and returns it
- Otherwise, returns our
LimitReached
error
So this contract will increment a counter up to a limit, and then start returning errors on further increments. The caller can check for the specific LimitReached error and handle it appropriately.
The Test Code
#[test]
fn test() {
let env = Env::default();
let contract_id = env.register_contract(None, IncrementContract);
let client = IncrementContractClient::new(&env, &contract_id);
assert_eq!(client.try_increment(), Ok(Ok(1)));
assert_eq!(client.try_increment(), Ok(Ok(2)));
assert_eq!(client.try_increment(), Ok(Ok(3)));
assert_eq!(client.try_increment(), Ok(Ok(4)));
assert_eq!(client.try_increment(), Ok(Ok(5)));
assert_eq!(client.try_increment(), Err(Ok(Error::LimitReached)));
std::println!("{}", env.logger().all().join("\n"));
}
#[test]
#[should_panic(expected = "Status(ContractError(1))")]
fn test_panic() {
let env = Env::default();
let contract_id = env.register_contract(None, IncrementContract);
let client = IncrementContractClient::new(&env, &contract_id);
assert_eq!(client.increment(), 1);
assert_eq!(client.increment(), 2);
assert_eq!(client.increment(), 3);
assert_eq!(client.increment(), 4);
assert_eq!(client.increment(), 5);
client.increment();
}
Our test code contains two unit tests for our contract with a limit:
The first test:
- Creates an Env and registers the contract
- Creates a client to call the contract
- Calls try_increment (which returns a Result) 5 times, checking that it returns the expected Ok result containing incrementing counts
- On the 6th call, it checks that it returns Err containing our LimitReached error
- It also logs all events from the Env (so we can see the increment events)
This tests the normal, non-error path and the limit-hitting error path of the contract.
The second test:
- Creates an Env and registers the contract
- Creates a client to call the contract
- Calls increment 6 times
This should cause a panic on the 6th call (due to the limit being reached)
The #[should_panic] attribute asserts that it does in fact panic, with a particular panic message
So this second test explicitly verifies that incrementing past the limit causes a panic, with a particular expected message.
Running Contract Tests
To ensure that the contract functions as intended, you can run the contract tests using the following command:
cargo test
If the tests are successful, you should see an output similar to:
running 2 tests
count: U32(0)
count: U32(1)
count: U32(2)
count: U32(3)
count: U32(4)
count: U32(5)
Status(ContractError(1))
contract call invocation resulted in error Status(ContractError(1))
test test::test ... ok
thread 'test::test_panic' panicked at 'called `Result::unwrap()` on an `Err` value: HostError
Value: Status(ContractError(1))
Debug events (newest first):
0: "Status(ContractError(1))"
1: "count: U32(5)"
2: "count: U32(4)"
3: "count: U32(3)"
...
test test::test_panic - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.33s
Our test here successfull, when the count
exceed the Max Limit amount that we set,it returned contract error.
Building The Contract
To build the contract, use the following command:
cargo build --target wasm32-unknown-unknown --release
This should output a .wasm file in the ../target directory:
../target/wasm32-unknown-unknown/release/soroban_errors_contract.wasm
Invoking The Contract
To invoke the increment
function of the contract, use the following command with Soroban-CLI:
soroban contract invoke \
--wasm ../target/wasm32-unknown-unknown/release/soroban_events_contract.wasm \
--id 1 \
-- \
increment
You should see the following output:
1
If we run the command a few times and on the 6th invocation the result will be :
error: HostError
Value: Status(ContractError(1))
...
Conclusion
We explored how to define errors in Soroban contracts. Defining errors have some key benefits:
- They allow returning specific, meaningful error information to callers
- This enables callers to handle particular errors appropriately
So defining contract errors are a best practice that leads to more robust, flexible and readable contract code. By following the enum representation and deriver requirements, you can define and return useful custom errors from your Soroban contracts. Stay tuned for more post in this "Soroban Contracts 101" Series where we will dive deeper into Soroban Contracts and their functionalities.
Top comments (0)