Skip to main content
Error propagation across async activations — why panic! doesn’t save Alice, and how Result::Err lets you compensate. A multi-step NFT trade where one leg can fail Alice pays USDT for an NFT — but each leg is a separate activation. What happens if the NFT transfer fails after she’s already paid?

Why doesn’t panic help?

Definitionpanic! is a state where an activation cannot safely continue. It must be aborted and its effects reverted on a best-effort basis (gas cost not included).
Panicking in an NFT-transfer activation might revert the NFT transfer — but it cannot revert Alice’s USDT transfer, because that’s a separate activation. So panic alone doesn’t solve cross-activation atomicity.

Result::Err to the rescue

DefinitionResult::Err is a state in which the activation did not produce the desired result. Developers must handle it as they see fit. Nothing is reverted automatically.
The developer of token-order is expected to catch the error returned by the NFT transfer, then either retry it or refund the USDTs.

Step 1 · Error handling in simple-token (Guided)

Custom errors

In simple-token/simple-token/src/simple_token_errors.rs:
#[error_type]
pub enum SimpleTokenError {
    #[error("Placeholder error - replace with your contract-specific errors")]
    Placeholder,
    #[error("Overflow error - the operation resulted in an overflow")]
    Overflow,
    #[error("Revert failed - the operation failed to revert")]
    RevertFailed,
    #[error("Failed to receive funds")]
    FailedToReceiveFunds,
    #[error("Insufficient funds")]
    InsufficientFunds,
    #[error("Amount must be greater than zero")]
    AmountMustBeGreaterThanZero { amount: AmountInSubunits },
    #[error("Transfer failed with refund error")]
    TransferFailedWithRefundError {
        delivery_error: String,
        refund_error: String,
    },
}

FailedToReceiveFunds in receive()

#[private]
pub fn receive(&mut self, amount: AmountInSubunits) -> Result<()> {
    self.wallet_balance = self.wallet_balance
        .checked_add(amount)
        .ok_or(SimpleTokenError::FailedToReceiveFunds)?;
    Ok(())
}

InsufficientFunds in transfer() and transfer_same_entity()

ensure!(amount <= self.wallet_balance, SimpleTokenError::InsufficientFunds);
self.wallet_balance = self.wallet_balance
    .checked_sub(amount)
    .ok_or(SimpleTokenError::InsufficientFunds)?;

AmountMustBeGreaterThanZero in transfer() and transfer_same_entity()

ensure!(amount > 0, SimpleTokenError::AmountMustBeGreaterThanZero { amount });

Transfer / delivery errors

Sync path: transfer_same_entity()

let result = recipient.local.receive(amount);
if let Err(delivery_error) = result {
    return self.handle_transfer_failure(delivery_error, amount);
}
#[private]
pub fn handle_transfer_failure(
    &mut self,
    delivery_error: GvmError,
    amount: AmountInSubunits,
) -> Result<()> {
    if let Some(refund_error) = self.wallet_balance.checked_add(amount) {
        return Err((SimpleTokenError::TransferFailedWithRefundError {
            delivery_error: delivery_error.to_string(),
            refund_error: refund_error.to_string(),
        }).into());
    }
    Err(delivery_error.into())
}

Async path: transfer()await or spawn

.await — wait for the result, handle it within the current activation’s lifecycle.
let receive_future = recipient.remote.receive(amount).await?;
if let Err(delivery_error) = receive_future.await {
    return self.handle_receive_failure(delivery_error, amount);
}
You can pick at runtime via a flag:
#[owner]
pub async fn transfer(
    &mut self,
    amount: AmountInSubunits,
    recipient: Holder,
    with_confirmation: bool,
) -> Result<()> {
    ensure!(amount > 0, SimpleTokenError::AmountMustBeGreaterThanZero { amount });
    ensure!(amount <= self.wallet_balance, SimpleTokenError::InsufficientFunds);
    self.wallet_balance = self.wallet_balance
        .checked_sub(amount)
        .ok_or(SimpleTokenError::InsufficientFunds)?;

    let receive_future = recipient.remote.receive(amount);
    if with_confirmation {
        if let Err(delivery_error) = receive_future.await {
            return self.handle_transfer_failure(delivery_error, amount);
        }
    } else {
        receive_future.spawn().on_error(Self::callbacks().handle_transfer_failure);
    }
    Ok(())
}

Testing

Add the new with_confirmation argument:
holder_client
    .transfer(
        AmountInSubunits::new(1),
        Holder::new(*holder2_client.component_id()),
        true,
    )
    .expect("transfer payload build should succeed")
    .execute(&gen_client, &holder_signer).await
    .expect("transfer should succeed");
Now try transferring 0 — the test should fail with AmountMustBeGreaterThanZero:
ActivationResult(ExecutionFailed { error: ExecutionError(GvmErrorDetails {
  code: 1005, name: "AmountMustBeGreaterThanZero", msg: "Amount must be greater than zero" ...
}) })
And 50 (more than the holder owns) — InsufficientFunds:
ActivationResult(ExecutionFailed { error: ExecutionError(GvmErrorDetails {
  code: 1004, name: "InsufficientFunds", msg: "Insufficient funds" ...
}) })
Checkpoint — happy-path test passes; 0-amount and over-balance variants fail with the expected named errors.
simple-token/src/simple_token_contracts.rs
use genie::contract;

/// Implementation of the SimpleToken contract
#[contract]
pub mod simple_token {
    /// Root component for SimpleToken
    pub mod root {
        use genie::{deploy, installation_request, owner, view, Result};

        /// Root implementation for SimpleToken
        pub struct SimpleTokenRoot {
            /// Stored value for testing state changes
            value: u64,
        }

        impl SimpleTokenRoot {
            /// Handles installation requests for SimpleToken components.
            #[installation_request]
            pub async fn installation_request(
                &mut self,
                _component_type: SimpleTokenComponents,
            ) -> Result<()> {
                Ok(())
            }

            /// Deploys the SimpleToken contract
            #[deploy]
            pub async fn deploy() -> Result<Self> {
                Ok(Self { value: 0 })
            }

            /// Sets the stored value (owner-only activation)
            #[owner]
            pub fn set_value(&mut self, new_value: u64) -> Result<()> {
                self.value = new_value;
                Ok(())
            }

            /// Gets the stored value (view function)
            #[view]
            pub fn get_value(&self) -> Result<u64> {
                Ok(self.value)
            }
        }
    }

    /// Holder component for SimpleToken
    pub mod holder {
        use crate::simple_token_errors::SimpleTokenError;
        use alloc::string::ToString;
        use genie::GvmError;
        use genie::{AmountInSubunits, Result, ensure, install, owner, private, view};
        use super::self_refs::Holder;

        pub struct SimpleTokenHolder {
            pub wallet_balance: AmountInSubunits,
        }

        impl SimpleTokenHolder {
            #[owner]
            pub fn transfer_same_entity(
                &mut self,
                amount: AmountInSubunits,
                recipient: Holder,
            ) -> Result<()> {
                ensure!(
                    amount > 0,
                    SimpleTokenError::AmountMustBeGreaterThanZero { amount }
                );
                ensure!(
                    amount <= self.wallet_balance,
                    SimpleTokenError::InsufficientFunds
                );
                self.wallet_balance = self
                    .wallet_balance
                    .checked_sub(amount)
                    .ok_or(SimpleTokenError::InsufficientFunds)?;
                let result = recipient.local.receive(amount);
                if let Err(delivery_error) = result {
                    return self.handle_transfer_failure(delivery_error, amount);
                }
                Ok(())
            }

            #[owner]
            pub async fn transfer(
                &mut self,
                amount: AmountInSubunits,
                recipient: Holder,
                with_confirmation: bool,
            ) -> Result<()> {
                ensure!(
                    amount > 0,
                    SimpleTokenError::AmountMustBeGreaterThanZero { amount }
                );
                ensure!(
                    amount <= self.wallet_balance,
                    SimpleTokenError::InsufficientFunds
                );
                self.wallet_balance = self
                    .wallet_balance
                    .checked_sub(amount)
                    .ok_or(SimpleTokenError::InsufficientFunds)?;

                let receive_future = recipient.remote.receive(amount);
                if with_confirmation {
                    if let Err(delivery_error) = receive_future.await {
                        return self.handle_transfer_failure(delivery_error, amount);
                    }
                } else {
                    receive_future.spawn().on_error(Self::callbacks().handle_transfer_failure);
                }
                Ok(())
            }

            #[private]
            pub fn handle_transfer_failure(
                &mut self,
                delivery_error: GvmError,
                amount: AmountInSubunits,
            ) -> Result<()> {
                if let Some(refund_error) = self.wallet_balance.checked_add(amount) {
                    return Err(
                        (SimpleTokenError::TransferFailedWithRefundError {
                            delivery_error: delivery_error.to_string(),
                            refund_error: refund_error.to_string(),
                        })
                        .into(),
                    );
                }
                Err(delivery_error.into())
            }

            #[private]
            pub fn receive(&mut self, amount: AmountInSubunits) -> Result<()> {
                self.wallet_balance = self
                    .wallet_balance
                    .checked_add(amount)
                    .ok_or(SimpleTokenError::FailedToReceiveFunds)?;
                Ok(())
            }

            #[view]
            pub fn get_balance(&self) -> Result<AmountInSubunits> {
                Ok(self.wallet_balance)
            }

            #[install]
            pub async fn install(_installation_return_value: ()) -> Result<Self> {
                Ok(SimpleTokenHolder {
                    wallet_balance: AmountInSubunits::new(12), // example starting balance
                })
            }
        }
    }
}
simple-token/src/simple_token_errors.rs
use alloc::string::{String, ToString};
use genie::{AmountInSubunits, error_type};

/// Possible errors for SimpleToken operations.
///
/// Extend this enum with your contract-specific errors.
#[error_type]
pub enum SimpleTokenError {
    #[error("Placeholder error - replace with your contract-specific errors")]
    Placeholder,
    #[error("Overflow error - the operation resulted in an overflow")]
    Overflow,
    #[error("Revert failed - the operation failed to revert")]
    RevertFailed,
    #[error("Failed to receive funds")]
    FailedToReceiveFunds,
    #[error("Insufficient funds")]
    InsufficientFunds,
    #[error("Amount must be greater than zero")]
    AmountMustBeGreaterThanZero { amount: AmountInSubunits },
    #[error("Transfer failed with refund error")]
    TransferFailedWithRefundError {
        delivery_error: String,
        refund_error: String,
    },
}
simple-token-tests/src/lib.rs
//! SimpleToken Contract Test Crate
//!
//! This crate contains integration tests for the SimpleToken contract.

use client_sdk_macros::generate_contract_client;

mod env_utils;

/// Contract client generated from the ABI.
/// This module is generated by the `#[generate_contract_client]` macro.
#[generate_contract_client("../simple-token/artifacts/simple_token_modules.json")]
mod simple_token_contract {}

#[cfg(test)]
mod tests {
    use super::env_utils::LOCAL_RUN_INSTRUCTIONS;
    use super::simple_token_contract;
    use gen_test_tools::{TestContext, test_context};
    use rstest::rstest;
    use crate::simple_token_contract::self_refs::Holder;
    use client_sdk::{ActivationOptions, ActivationTag, types::AmountInSubunits};

    /// End-to-end test for the SimpleToken contract.
    ///
    /// This test verifies:
    /// - Contract code can be pushed to the validator
    /// - Contract can be deployed successfully
    /// - Root component is installed during deployment
    /// - View function (get_value) returns initial value
    /// - Activation function (set_value) changes state
    /// - View function returns updated value after activation
    #[rstest]
    #[tokio::test]
    async fn simple_token_e2e_test(#[future] test_context: TestContext) {
        super::env_utils::assert_gen_cli_available();
        let ctx = test_context.await;

        let deployer_signer = ctx.new_signer();
        let deployer_account = deployer_signer.account();

        let holder_signer  = ctx.new_signer();
        let holder_account = holder_signer.account();

        let holder2_signer  = ctx.new_signer();
        let holder2_account = holder2_signer.account();

        ctx.create_accounts(&[deployer_account, holder_account, holder2_account])
            .await.unwrap_or_else(|e| panic!("create_accounts failed: {e}{LOCAL_RUN_INSTRUCTIONS}"));

        let gen_client = ctx
            .gen_client()
            .unwrap_or_else(|e| panic!("gen client creation failed: {e}{LOCAL_RUN_INSTRUCTIONS}"));

        // Push contract code
        let push_id = simple_token_contract::push_contract(
            deployer_account,
            tokio::fs::read,
        )
        .await
        .unwrap_or_else(|e| panic!("contract push payload build failed: {e}{LOCAL_RUN_INSTRUCTIONS}"))
        .execute(&gen_client, &deployer_signer)
        .await
        .unwrap_or_else(|e| panic!("contract push failed: {e}{LOCAL_RUN_INSTRUCTIONS}"));

        // Deploy contract
        let root = simple_token_contract::deploy_contract(
            deployer_account,
            push_id,
            // TODO: Add your deploy parameters here
        )
        .unwrap_or_else(|e| panic!("contract deploy payload build failed: {e}{LOCAL_RUN_INSTRUCTIONS}"))
        .execute(&gen_client, &deployer_signer)
        .await
        .unwrap_or_else(|e| panic!("contract deploy failed: {e}{LOCAL_RUN_INSTRUCTIONS}"));

        let _root_contract = root.gvm_contract();

        let holder_client = simple_token_contract
            ::install_holder(holder_account, _root_contract)
            .expect("holder installation payload build should succeed")
            .execute(&gen_client, &holder_signer).await
            .expect("holder installation should succeed");

        let holder2_client = simple_token_contract
            ::install_holder(holder2_account, _root_contract)
            .expect("holder2 installation payload build should succeed")
            .execute_with_options(
                &gen_client,
                &holder2_signer,
                &ActivationOptions::builder().tag(ActivationTag::new(2)).build()
            ).await
            .expect("holder2 installation should succeed");

        holder_client
            .transfer(
                AmountInSubunits::new(1),
                Holder::new(*holder2_client.component_id()),
                true,
            )
            .expect("transfer payload build should succeed")
            .execute(&gen_client, &holder_signer).await
            .expect("transfer should succeed");

        let balance = holder2_client
            .get_balance(deployer_account)
            .expect("get_balance view payload build should succeed")
            .execute(&gen_client).await
            .expect("get_balance view should succeed");

        assert!(
            balance == AmountInSubunits::new(13),
            "Receiver balance mismatch: expected {}, got {}",
            AmountInSubunits::new(13),
            balance
        );

        // Test view function - should return initial value of 0
        let value = root
            .get_value(deployer_account)
            .expect("get_value view payload build should succeed")
            .execute(&gen_client)
            .await
            .expect("get_value view should succeed");
        assert_eq!(value, 0, "Initial value should be 0");

        // Test activation function - set a new value
        root.set_value(42)
            .expect("set_value activation payload build should succeed")
            .execute(&gen_client, &deployer_signer)
            .await
            .expect("set_value activation should complete successfully");

        // Verify the value was changed using view function
        let value = root
            .get_value(deployer_account)
            .expect("get_value view payload build should succeed after set_value")
            .execute(&gen_client)
            .await
            .expect("get_value view should succeed after set_value");
        assert_eq!(value, 42, "Value should be updated to 42");

        // TODO: Add your test assertions here
    }
}

Step 2 · Error handling in my-token (Exercise)

Add the same error-handling logic to incrementer and receiver. The tests in my-token-tests/src/lib.rs tell you which errors are expected for which inputs.
Starter and solution — Check out the starting tag (stash first):
cd my-token
git stash
git checkout v0.5.3
Solution tag:
git reset --hard HEAD
git checkout v0.6.2
my-token/src/my_token_errors.rs
// my-token @ v0.5.3
#[error_type]
pub enum MyTokenError {
    #[error("Overflow")] Overflow,
    // TODO: add error variants that match the expectations in the tests
}
my-token/src/my_token_contracts.rs
// my-token @ v0.5.3
pub mod incrementer {
    use super::self_refs::Receiver;
    use crate::my_token_errors::MyTokenError;
    use genie::{Result, install, owner};

    pub struct Incrementer;

    impl Incrementer {
        #[owner]
        pub fn increment_same_entity(&mut self, to: Receiver) -> Result<()> {
            // TODO: call to.local.receive(); on failure, route the delivery
            //       error through a private handle_increment_failure() that
            //       returns the right MyTokenError variant.
            to.local.receive()?;
            Ok(())
        }

        #[owner]
        pub async fn increment(&mut self, to: Receiver) -> Result<()> {
            // TODO: add a `with_confirmation: bool` parameter, then either
            //       `.await` the result and recover, or `.spawn()` and
            //       attach `.on_error(...)` to a private callback.
            to.remote.receive().await?;
            Ok(())
        }

        #[install]
        pub async fn install(_iv: ()) -> Result<Self> { Ok(Self) }
    }
}

pub mod receiver {
    use crate::my_token_errors::MyTokenError;
    use genie::{Result, install, private, view};

    pub struct Receiver { counter: u128 }

    impl Receiver {
        #[private]
        pub fn receive(&mut self) -> Result<()> {
            // TODO: guard the checked_add with the right MyTokenError variant.
            self.counter = self.counter.checked_add(1).ok_or(MyTokenError::Overflow)?;
            Ok(())
        }

        #[view]
        pub fn get_counter(&self) -> Result<u128> { Ok(self.counter) }

        #[install]
        pub async fn install(_iv: ()) -> Result<Self> { Ok(Self { counter: 0 }) }
    }
}