Composability, part 2 — default routing, the cross-contract call, and the test that proves it.
Part 2 of composability. Picking up from
Make It Compile: my-token now builds with a
simple-token holder installed in each component. Now you make the
cross-contract call actually run, then test it.
Prerequisites · Checkpoints 1–3 from the previous chapter pass — both
crates build and each my-token component stores its own holder.
Phase 4 · Call across contracts with default routing
You can’t pass a Receiver straight to transfer() (type mismatch) and you
can’t cast the component id directly (runtime function mismatch). The fix is
default routing: register the receiver-side holder as the default handler
for calls of its type.
Challenge — Think about which error states are possible here and how
you’d handle them differently between .spawn() and .await?. There’s a
deep-dive on Composable Errors in
the appendix.
Checkpoint 4 — increment() compiles and the transfer() call
type-checks. Default routing is registered in each receiver’s install().
No Result return on the test fn, so every step uses
unwrap_or_else(|e| panic!(... LOCAL_RUN_INSTRUCTIONS)) instead of ?.Imports + setup
#[cfg(test)]mod tests { use super::env_utils::LOCAL_RUN_INSTRUCTIONS; use super::my_token_contract; use super::simple_token; use client_sdk::{ActivationOptions, ActivationTag}; use gen_test_tools::{TestContext, test_context}; use rstest::rstest; use crate::my_token_contract::self_refs::Receiver; use client_sdk::types::AmountInSubunits; #[rstest] #[tokio::test] async fn my_token_increment_confirmation_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 receiver_signer = ctx.new_signer(); let receiver_account = receiver_signer.account(); let incrementer_signer = ctx.new_signer(); let incrementer_account = incrementer_signer.account(); ctx.create_accounts(&[deployer_account, receiver_account, incrementer_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}"));
Install three components on three different entities. The third one
uses execute_with_options with a distinct ActivationTag so it doesn’t
dedup against the first install_incrementer:
let incrementer = my_token_contract::install_incrementer(deployer_account, _root_contract) .expect("incrementer installation payload build should succeed") .execute(&gen_client, &deployer_signer).await .expect("incrementer installation should succeed"); let receiver = my_token_contract::install_receiver(receiver_account, _root_contract) .expect("receiver installation payload build should succeed") .execute(&gen_client, &receiver_signer).await .expect("receiver installation should succeed"); let remote_incrementer = my_token_contract::install_incrementer(incrementer_account, _root_contract) .expect("remote incrementer installation payload build should succeed") .execute_with_options( &gen_client, &incrementer_signer, &ActivationOptions::builder().tag(ActivationTag::new(2)).build(), ) .await .expect("remote incrementer installation should succeed");
Increment, then assert. Runs on remote_incrementer, so its holder
shrinks, the receiver’s holder grows, and the counter ticks up.
remote_incrementer .increment(Receiver::new(*receiver.component_id()), true) .expect("increment should succeed") .execute(&gen_client, &incrementer_signer).await .expect("increment should succeed"); let incrementer_balance = incrementer .get_incrementer_balance(incrementer_account) .expect("get_incrementer_balance view payload build should succeed") .execute(&gen_client).await .expect("get_incrementer_balance should succeed"); assert!( incrementer_balance == AmountInSubunits::new(11), "Incrementer balance mismatch: expected {}, got {}", AmountInSubunits::new(11), incrementer_balance ); let counter: u128 = receiver .get_count(receiver_account) .expect("get_count view payload build should succeed") .execute(&gen_client).await .expect("get_count should succeed"); assert!( counter == 2, "Receiver counter mismatch: expected {}, got {}", 2, counter ); }}
Checkpoint 5 — the test compiles and runs. It may fail on the first run
with a ContractRefError: Instance mismatch — that’s expected, and the next
section fixes it.
Full solution: my-token + tests at the end of Phase 5
Prerequisite — the test imports simple_token from
../../simple-token/simple-token/artifacts/simple_token_modules.json,
which is the sibling simple-token/ directory already in your
my-contracts-repo checkout from
Project Setup. Bring it to
the latest end-of-workshop state and build it so the ABI exists:
cd my-contracts-repogit checkout v0.7.3 # latest tag at time of writinggen genie build simple-token/simple-token
my-token/my-token/src/my_token_contract.rs
use genie::contract;/// Implementation of the MyToken contract#[contract]pub mod my_token { /// Root component for MyToken pub mod root { use genie::{GvmContract, Result, deploy, installation_request, owner, view}; /// Root implementation for MyToken pub struct MyTokenRoot { value: u64, simple_token_contract: GvmContract, } impl MyTokenRoot { /// Handles installation requests for MyToken components. #[deploy] pub async fn deploy(simple_token_contract: GvmContract) -> Result<Self> { Ok(Self { value: 0, simple_token_contract }) } #[installation_request] pub async fn installation_request( &mut self, _component_type: MyTokenComponents, ) -> Result<GvmContract> { Ok(self.simple_token_contract) } /// 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) } } } /// incrementer component for MyToken pub mod incrementer { use super::self_refs::Receiver; use genie::{AmountInSubunits, GvmComponentHeader, GvmComponentId, GvmContract, view}; use genie::{GvmError, Result, install, owner, private}; use simple_token::simple_token_contract::instance::alpha::Holder; use simple_token::simple_token_contract::simple_token::SimpleTokenClient; /// Incrementer implementation pub struct CounterIncrementer { incrementer_simple_token_holder: Holder, } impl CounterIncrementer { #[view] pub fn get_incrementer_balance(&self) -> Result<AmountInSubunits> { Ok(self.incrementer_simple_token_holder.local.get_balance()?) } /// Install handler for Incrementer #[install] pub async fn install(installation_return_value: GvmContract) -> Result<Self> { let incrementer_simple_token_holder = SimpleTokenClient::installer() .holder.install(installation_return_value).await? .try_into()?; Ok(Self { incrementer_simple_token_holder }) } /// Transfer 1 token to the receiver #[owner] pub async fn increment(&mut self, to: Receiver, with_confirmation: bool) -> Result<()> { let receive_future = to.remote.receive(); if with_confirmation { if let Err(increment_result) = receive_future.await { //Perform an action that reflects the action failed + //Throw an error that accurately describes what happened return self.handle_increment_failure(increment_result); } else { //Action succeeded so you can update something let receiver_holder_id = GvmComponentId::new( *to.component_entity_id(), GvmComponentHeader::builder() .version(*to.header().version()) .index(*to.header().index()) .contract(*self.incrementer_simple_token_holder.header().contract()) .component_code_id(*self.incrementer_simple_token_holder.header().component_code_id()) .build() ); let transfer_future = self.incrementer_simple_token_holder.remote.transfer( AmountInSubunits::new(1), Holder::new(receiver_holder_id)?, with_confirmation, ); transfer_future.spawn(); }; } else { receive_future .spawn() .on_error(Self::callbacks().handle_increment_failure); } Ok(()) } #[owner] pub async fn increment_same_entity(&mut self, to: Receiver) -> Result<()> { if let Err(increment_result) = to.local.receive() { return self.handle_increment_failure(increment_result); } Ok(()) } #[private] pub fn handle_increment_failure(&mut self, delivery_error: GvmError) -> Result<()> { //There's nothing meaningful to fix so throw an error Err(delivery_error.into()) } } } /// Receiver component for MyToken pub mod receiver { use genie::{AmountInSubunits, Component, GvmContract}; use genie::{Result, install, private, view}; use simple_token::simple_token_contract::instance::alpha::Holder; use simple_token::simple_token_contract::simple_token::SimpleTokenClient; /// Receiver implementation pub struct CounterReceiver { counter: u128, receiver_simple_token_holder: Holder, } impl CounterReceiver { #[view] pub fn get_receiver_balance(&self) -> Result<AmountInSubunits> { Ok(self.receiver_simple_token_holder.local.get_balance()?) } /// Install handler for CounterReceiver #[install] pub async fn install(simple_token_contract: GvmContract) -> Result<Self> { let receiver_simple_token_holder: Holder = SimpleTokenClient::installer() .holder.install(simple_token_contract).await? .try_into()?; Component::set_default_route(*receiver_simple_token_holder.component_id()).await?; Ok(Self { counter: 0, receiver_simple_token_holder }) } #[view] pub fn get_count(&self) -> Result<u128> { Ok(self.counter) } #[private] pub fn receive(&mut self) -> Result<()> { self.counter += 1; Ok(()) } } }}
my-token/my-token-tests/src/lib.rs
//! MyToken Contract Test Crate//!//! This crate contains integration tests for the MyToken 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("../my-token/artifacts/my_token_modules.json")]mod my_token_contract {}#[generate_contract_client("../../simple-token/simple-token/artifacts/simple_token_modules.json")]mod simple_token {}#[cfg(test)]mod tests { use super::env_utils::LOCAL_RUN_INSTRUCTIONS; use super::my_token_contract; use super::simple_token; use client_sdk::{ActivationOptions, ActivationTag}; use gen_test_tools::{TestContext, test_context}; use rstest::rstest; use crate::my_token_contract::self_refs::Receiver; use client_sdk::types::AmountInSubunits; /// End-to-end test for the MyToken contract. #[rstest] #[tokio::test] async fn my_token_increment_confirmation_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 receiver_signer = ctx.new_signer(); let receiver_account = receiver_signer.account(); let incrementer_signer = ctx.new_signer(); let incrementer_account = incrementer_signer.account(); ctx.create_accounts(&[deployer_account, receiver_account, incrementer_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}")); let simple_token_push_id = simple_token ::push_contract(deployer_account, tokio::fs::read).await .unwrap_or_else(|e| panic!("push contract failed: {e}{LOCAL_RUN_INSTRUCTIONS}")) .execute(&gen_client, &deployer_signer).await .unwrap_or_else(|e| panic!("push contract execution failed: {e}{LOCAL_RUN_INSTRUCTIONS}")); let simple_token_root = simple_token ::deploy_contract(deployer_account, simple_token_push_id) .unwrap_or_else(|e| panic!("deploy contract failed: {e}{LOCAL_RUN_INSTRUCTIONS}")) .execute(&gen_client, &deployer_signer).await .unwrap_or_else(|e| panic!("deploy contract execution failed: {e}{LOCAL_RUN_INSTRUCTIONS}")); let _simple_token_contract = simple_token_root.gvm_contract(); // Push contract code let push_id = my_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 = my_token_contract::deploy_contract( deployer_account, push_id, _simple_token_contract, ) .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(); // Install incrementer component let incrementer = my_token_contract::install_incrementer( deployer_account, _root_contract, ) .expect("incrementer installation payload build should succeed") .execute(&gen_client, &deployer_signer) .await .expect("incrementer installation should succeed"); // Install receiver component let receiver = my_token_contract::install_receiver( receiver_account, _root_contract, ) .expect("receiver installation payload build should succeed") .execute(&gen_client, &receiver_signer) .await .expect("receiver installation should succeed"); // Install remote incrementer component let remote_incrementer = my_token_contract::install_incrementer( incrementer_account, _root_contract, ) .expect("remote incrementer installation payload build should succeed") .execute_with_options( &gen_client, &incrementer_signer, &ActivationOptions::builder() .tag(ActivationTag::new(2)) .build(), ) .await .expect("remote incrementer installation should succeed"); // 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"); // Increment the counter remote_incrementer .increment(Receiver::new(*receiver.component_id()), true) .expect("increment should succeed") .execute(&gen_client, &incrementer_signer) .await .expect("increment should succeed"); let incrementer_balance = incrementer .get_incrementer_balance(incrementer_account) .expect("get_incrementer_balance view payload build should succeed") .execute(&gen_client) .await .expect("get_incrementer_balance should succeed"); assert!( incrementer_balance == AmountInSubunits::new(11), "Incrementer balance mismatch: expected {}, got {}", AmountInSubunits::new(11), incrementer_balance ); // Get the counter let counter: u128 = receiver .get_count(receiver_account) .expect("get_count view payload build should succeed") .execute(&gen_client) .await .expect("get_count should succeed"); // Assert the counter is 2 assert!( counter == 2, "Receiver counter mismatch: expected {}, got {}", 2, counter ); }}
Troubleshooting · When the test fails with ContractRefError
Initial runs fail with a ContractRefError: Instance mismatch: the incrementer and receiver
bake a reference to simple-token’s alpha instance (a deployer_entity::component_index
address), but a freshly-deployed simple-token gets a random per-run deployer that never
equals it. There are two ways to make the addresses line up:
Reusable validator (recommended)
Fixed-seed re-pin (brittle)
Deploy simple-tokenonce, then reference it by address on every run against a validator
you keep alive. Why this beats the fixed-seed approach:
No TEST_SEED to remember — the deployer comes from a fixed byte-seed signer, reproducible on any machine.
You never redeploy in the test, so the ::1 component index can’t drift.
Reuses one long-running validator instead of spinning a fresh one per run (faster too).
Survives across runs; you only re-seed after an actual validator restart.
1. Keep one validator running. The bundled script starts the validator the first time and reuses it after:
Heads-up — The seeded instance lives in the running validator’s state. Re-run the
bootstrap once after a validator restart, or when it hits the activation block-height
validity window after long uptime. If you change simple-token’s code, rebuild both crates
and re-bootstrap.
The quick way to go green once: edit simple-token’s Cargo.toml so code_id and the
instance contracts match the deployer ID the test prints, then run with a fixed seed so the
deployer is reproducible.
Why this is brittle — It pins a one-off deployer ID and a specific TEST_SEED, and
still redeploys simple-token every run. It breaks on a validator restart, on a different
machine, or once the component index drifts — which is why the recommended tab exists.
Checkpoint 6 (final) — my_token_transfer_when_increment_test passes —
incrementer’s balance dropped by 1, receiver’s grew by 1, and the counter is 1.