DEV Community

yuzurush
yuzurush

Posted on • Updated on

Soroban Contracts 101 : Errors

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This should output a .wasm file in the ../target directory:

../target/wasm32-unknown-unknown/release/soroban_errors_contract.wasm
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You should see the following output:

1
Enter fullscreen mode Exit fullscreen mode

If we run the command a few times and on the 6th invocation the result will be :

error: HostError
Value: Status(ContractError(1))
...
Enter fullscreen mode Exit fullscreen mode

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)