Skip to main content
A 12-step journey through contract development with the Genie SDK. Each step is a tiny, self-contained contract that teaches exactly one new idea on top of what came before. If you read them in order, you end up with a working mental model of entities, components, access control, events, storage, cross-contract composition, and delegated authority, without ever being dropped into a wall of framework features all at once. Read this page as a guided tour. Each step below explains what the contract does, why it exists in the series, and what you should walk away understanding before moving to the next one. The Rust sources live in the framework repository and are heavily commented; think of this page as the lecture and the code as the lab exercise.

Get the code

The 12 example contracts live in the framework repository. Clone it:
git clone https://github.com/gen-bc/gen-framework-preview.git
cd gen-framework-preview
From here, every command in this tutorial assumes you’re at the repo root. The sources are under contract-libs/example-contracts/. Before starting, make sure you can build and test a contract on your machine. See Build and test a contract.

Step 1: Root deployment, the smallest contract that exists

Folder: 01-root-deployment/ The absolute minimum: a contract with zero state, zero methods, and exactly one lifecycle hook, #[deploy]. It does nothing interesting on-chain. That’s the point. What this step teaches:
  • Every Grid contract is just a Rust module annotated with #[contract].
  • Inside, you must have a root module that contains a single struct and an impl block with a #[deploy] async fn deploy() -> Result<Self>. That function is called exactly once when the contract is instantiated.
  • The macro transforms that declaration into all the glue code needed to make your contract addressable on-chain.
Before moving on, you should be able to look at the file and explain why it is, in a very literal sense, the “hello world” of the Grid. Everything else in this series is just “this, plus one more thing”.

Step 2: Root counter, persistent state and access modifiers

Folder: 02-root-counter/ Now the contract actually does something. The root component holds a counter: u64 and exposes three methods that each demonstrate one of the Grid’s access modifiers. What this step teaches:
  • Struct fields are automatically persisted. The runtime serializes the whole struct after every successful activation and re-hydrates it before the next one. You never touch storage APIs for simple state; you just write Rust.
  • #[view] marks a read-only method. Views take &self, cannot mutate, and can be called by anyone.
  • #[public] marks a state-mutating method anyone can call. Takes &mut self.
  • #[owner] marks a method only the entity’s owner can call. The runtime does the identity check for you, so your code is free of permission logic.
This is also the first step where you see the difference between calling a view (no signer needed; the return value travels back through the RPC) and submitting a transaction (signer required; the return value is the post-commit state).

Step 3: Multi-component, two components talking to each other

Folder: 03-multi-component/ A Grid contract can define more than one component type. The root is always there, but you can also declare non-root components that get installed onto entities after the fact. This step shows a minimal two-component contract: a counter root plus a resetter component that, when installed, is allowed to call back into the root. What this step teaches:
  • Multiple pub mod blocks inside #[contract], one per component type.
  • #[installation_request]: the root’s gatekeeper method that decides whether a non-root component is allowed to be installed. Use it to enforce who may install which component type and under what conditions.
  • #[install]: the initializer for non-root components. Same role as #[deploy] has for the root.
  • #[private]: a method that can only be called by other components of the same contract, not by outside signers. Together with #[installation_request], this gives a capability-like design: private functions remain callable across components but are not exposed to external signers.
  • Remote calls with .remote.method().await: how one component invokes another. The await is genuine; remote calls are asynchronous because the target component may live on a different entity.
  • Self-references (self_refs::...): macro-generated, compile-time-typed references to another component of the same contract instance. This is what makes cross-component calls type-safe.
By the end of this step you know how a single contract can be split into cooperating pieces. That’s the foundation for every non-trivial contract in the rest of the series.
Composing with already-deployed contracts (Steps 4, 6, 10, 11). These steps take a GvmContract deploy parameter pointing at another contract you’ve already pushed and deployed. From the CLI, that argument has a specific nested-tuple JSON shape that you derive from the bech32m address returned by gen client deploy. See Deploy to DevNet: composing with another contract for the shape and a decoder.

Step 4: Cross-contract install, contracts composing with other contracts

Folder: 04-cross-contract-install/ Step 3 showed two components of the same contract cooperating. Step 4 shows two different contracts cooperating: #[deploy] takes a GvmContract for a deployed Step 3 instance and installs its resetter there. The installing component is set as owner of the installed component. What this step teaches:
  • Contract composition follows naturally from the installer pattern learned in Step 3. The only difference is that the installer is triggered from another contract instead of from a client.
  • Install ownership. The installing component is set as owner of the installed component, a useful mental model for access control when composing contracts.
  • Client::installer(): the auto-generated entrypoint for asking a foreign contract to install one of its component types on you.
  • A GvmContract is a runtime handle. You pass it around, store it, hand it to other contracts, compare them for equality, and use it to install components. Step 4 passes one in via #[deploy] and calls installation.
After this step you understand you can use an already deployed contract by installing its components and calling through its generated client (from that contract’s Rust crate). You do not redeploy that contract, copy its code, or maintain a separate ABI by hand.

Step 5: Custom errors, typed recoverable failure values

Folder: 05-custom-errors/ Up to this point every method returned Result<T> but never actually returned an Err. Step 5 introduces typed errors: a counter with a configurable maximum that returns Err(CounterMaxError::MaxReached) once the limit is reached. What this step teaches:
  • #[error_type]: the macro that turns an enum into a Grid-aware error type. It derives Borsh, thiserror, and the glue needed to serialize the error across contract boundaries.
  • Errors are values, not panics. Returning Err(...) does not roll back state on its own; the caller receives the error and decides what to do next.
  • Deploy parameters. #[deploy] can take arguments the same way any other method can. Each deployment can use different values, so every instance is configured independently.
This step is also the first time a method in the series gets rejected; submitting a 4th increment past a max of 3 returns a MaxReached error. You’ll use that very behavior as the setup for Step 6.

Step 6: Async error handling, reacting to another contract’s error

Folder: 06-async-error-handling/ A contract that wraps Step 5’s counter and automatically resets it whenever the increment fails with MaxReached. You see two patterns: handle it inline after .await, or schedule a callback that runs in a later activation only if the call fails. What this step teaches:
  • gvm_error.try_as::<ConcreteErrorType>(): how to downcast an opaque cross-contract error back into the specific typed error variant you care about, so you can branch on it.
  • Pattern A, await then match. Wait for the remote result in the same method. .await yields while the call runs; when it completes you branch on Ok/Err.
  • Pattern B, spawn + on_error. Schedule the remote call and return. If it fails, the runtime invokes your #[private] callback in a separate activation.
  • Putting it together. Both paths detect MaxReached and reset; A keeps everything in one call chain, B splits “fire” and “recover on failure” across activations.
This step is where “errors as values” starts paying off: you can pattern-match on another contract’s failures as easily as on your own.

Step 7: Events and context, talking to the outside world

Folder: 07-events-and-context/ A counter that emits structured events on every mutation, validates its input, sets a gas budget, and reads the caller’s identity from within the method body. What this step teaches:
  • #[event_type] struct SomeEvent { ... }: declarative events. The struct’s fields become the event payload, serialized with Borsh and made visible to indexers and off-chain subscribers.
  • .emit(): the only thing you need to call to actually emit an event instance. No manual indexing, no topic juggling.
  • ensure!(cond, err): an ergonomic bail!-style helper for early input validation.
  • #[gas(min, max)]: an attribute that declares the gas bracket a method is allowed to use. The runtime enforces the upper bound and rejects calls that can’t pay the lower one.
  • Self::context(): returns the current activation’s metadata, including CallerIdentity (an enum with variants like Signer and Component). This is how you know who actually triggered the call, useful for logging, access policies that go beyond #[owner], or recording authorship in events.
After this step you can build contracts that are observable from the outside and that know something about the context they execute in. Both are prerequisites for anything production-shaped.

Step 8: Collections and storage, data structures and the raw storage API

Folder: 08-collections-and-storage/ A per-player scoreboard backed by two complementary storage strategies: the main score map lives in a struct field, and detailed per-player audit logs live under manually managed storage keys. What this step teaches:
  • IndexMap<K, V> as a struct field. The Grid-provided collection types work exactly like their indexmap counterparts but are Borsh-friendly, so you can keep non-trivial amounts of keyed data directly on your component struct.
  • Why you might not want to keep everything on the struct. Struct fields are loaded and re-saved in full on every call. For data that is large, rarely touched, or addressed by dynamic key, the manual storage API is the right tool.
  • Storage::save_by_key(key, value) / Storage::load_by_key(key) / Storage::delete_by_key(key): the manual path. You construct the key yourself (typically derived from user input), serialize whatever you want, and decide exactly when to read or write.
  • StorageKey: a typed wrapper for raw bytes, with helpers to derive safe, namespaced keys.
By the end of the step you can pick between “put it on the struct” and “put it under its own key” based on how the data is accessed, rather than guessing.

Step 9: Entity creation and callbacks, factories and async results

Folder: 09-entity-creation-and-callbacks/ A contract that, on demand, creates a brand-new on-chain entity, installs a worker component on it, and fires an async ping that resolves via a callback. Functionally: a factory. What this step teaches:
  • Component::create_new_entity(): yes, contracts can create entities. Combined with installers, this is how you build factory contracts that spin up per-user inboxes, per-pool accounts, per-auction escrows, and so on.
  • The three callback shapes, seen together. Step 6 already showed .on_error(...). This step adds:
    • spawn(future).on_success(cb): the callback runs only if the future resolves to Ok(...).
    • spawn(future).on_result(cb): the callback runs on both success and error, and receives the full CallbackResult.
  • The mental model: you don’t .await the spawned call yourself. You register the continuation, return, and the runtime invokes your callback later with the call’s outcome as input, possibly in a different block.
After this step you understand the complete “spawn + callback” vocabulary, which is the standard Grid idiom for any long-running or cross-entity workflow.

Step 10: Fungible-token integration, calling a real production contract

Folder: 10-fungible-token-integration/ A treasury contract that composes with the Grid’s genesis fungible-token contract. On deploy, the treasury installs its own holder component on the FT and stores the reference. From then on, the treasury is a real token account: it can transfer, burn, and check balances.
Getting an FT to compose with. The treasury’s #[deploy] takes a GvmContract pointing at an existing FT instance. The recommended path is to push and deploy your own FT instance first using the genesis fungible-token manifest at contract-libs/genesis-framework/fungible-token/artifacts/fungible_token_modules.json. See Deploy to DevNet for the push/deploy commands and the JSON shape for the ft_contract argument.
What this step teaches:
  • Real cross-contract calls against a non-trivial target. Unlike Step 4 (which installed a component from a contract we also wrote), here the counterparty is a real production contract shipped with the Grid’s genesis.
  • Installing a foreign component on yourself via the auto-generated client (FungibleTokenClient::installer().holder.install(...)).
  • Remote method chaining on that installed component (client.holder.remote.transfer(...), .burn(...), .balance()), each returning a future you .await.
  • Why self_refs matter here. The FT’s transfer method takes a typed holder reference, not a raw GvmComponentId. You pass the typed ref through and the compiler (and the runtime) stops you from handing it a holder that doesn’t belong to the right FT contract.
This step is where the earlier primitives pay off: by the time you hit it, nothing about “contract A installs and drives contract B” is new. All that’s new is that B happens to be a real, live token contract.

Step 11: Default routes, making one contract stand in for another

Folder: 11-default-routes/ A proxy contract that, on deploy, points at an existing Step 5 counter and sets up a default route so that any method call made against the proxy’s entity silently executes on the target entity instead. What this step teaches:
  • Component::set_default_route(target): the primitive. After this call, the proxy’s entity advertises “for this contract code, forward everything to target”. Callers don’t have to know.
  • Why this is useful beyond toy examples. Default routes are the primitive behind upgradability (point the route at a new implementation), access control shims (gate the calls before forwarding), telemetry wrappers (count what passes through), and scoped proxies (different callers, different targets).
  • Component::remove_default_route(contract): the inverse, for when the proxy should stop shadowing.
  • The granularity: a route covers all methods of the targeted contract on the proxy’s entity. There is no per-method filtering at this layer; if you want that, layer it on top.
After this step you have a clear picture of how a contract becomes transparent to its callers, which is the foundation of almost every upgrade story in the Grid.

Step 12: Delegated access, temporarily borrowing owner authority

Folder: 12-delegated-access/ A vault with a #[owner]-gated withdraw method, paired with a manager component that isn’t the vault’s owner but still needs to be able to withdraw. The manager makes the call with a delegated-owner grant attached, and the runtime treats the call as if it came from the vault’s actual owner, but only for this one call. What this step teaches:
  • Access in the Grid is activation-scoped, not session-scoped. A grant gives the recipient authority for a specific call, not for a lifetime. This is why delegated access feels more like a capability token than a role.
  • The three explicit parts of a grant. Who receives it (origin), where it applies (scope, usually a specific component), what it grants (attributes, for example Owner).
  • The implicit fourth part: the stamp. Before dispatching the outgoing call, the runtime attaches the current activation’s identity to the grant. That stamp is what makes the grant trustworthy. A component can only delegate authority that flows from its own execution; you cannot forge a grant that claims to be somebody else, because the runtime won’t validate it.
  • Two equivalent APIs:
    • .with_delegated_owner(target_component_id): the high-level shorthand. One line: grant owner authority over one target for one call.
    • .with_delegated_access(container): the low-level manual builder. Use it when you need non-owner attributes, or when the grant needs a structure with_delegated_owner doesn’t cover.
This is the final step because it relies on every earlier piece (multi-component contracts, remote calls, access modifiers) to show off the subtlest and most production-relevant pattern in the series.
Delegation grants are an SDK feature, not a CLI one. gen client build-activation and sign-and-submit-activation have no flag to attach a delegation grant. You can deploy Step 12 from the CLI and call deposit / get_balance, but to exercise the actual delegated-withdraw flow that’s the lesson, drive it from Rust. Read example-tests/src/lib.rs::step_12_delegated_access for a working reference.

Build and test the contracts

Every contract in the series has a corresponding test in example-tests/, and the test story itself is worth understanding because it is how real clients talk to these contracts.

Step 1: Build all 12 contracts

The cloned repository ships only the manifests and ABIs under each step’s artifacts/. The compiled .o binaries are produced by the build and are required at test time. Build all 12 examples plus the genesis fungible-token contract that Step 10 depends on:
for dir in contract-libs/example-contracts/{0[1-9],1[0-2]}-*/; do
  gen genie build "$dir"
done
gen genie build contract-libs/genesis-framework/fungible-token
Each artifacts/ directory now contains the _modules.json manifest plus a .o binary per component.

Step 2: Run the tests

The tests live in example-tests/src/lib.rs as plain pub async fn step_0N_xxx(ctx: &TestContext) helpers and are wrapped as #[tokio::test] #[rstest] in example-tests/tests/example_contracts_tests.rs. The wrappers carry #[ignore] so they don’t run under a plain cargo test (they need a live validator and would fail loudly without one); pass --ignored once you’re ready. --ignored is a test-runner flag, not a Cargo flag, so it has to come after --. Each test also spawns its own validator subprocess on port 30001, which means they need to run serially or they’ll collide; use --test-threads=1:
cargo test -p example-contracts-test --release -- --ignored --test-threads=1
All 12 tests should pass; total runtime is roughly 45 seconds. Your own application code doesn’t need #[ignore]; it’s purely a guard on these examples so they don’t fail in CI without a validator.

What each helper does

Each step_0N_xxx helper walks through a full client-side interaction with one step’s contract:
  1. Generate a client. The #[generate_contract_client("…modules.json")] macro at the top of the file reads each contract’s ABI and generates a typed Rust module (push_contract, deploy_contract, and a Root struct whose methods mirror the contract’s own). No hand-written bindings, no ABI-encoding boilerplate in the test body.
  2. Push the binary. push_contract(account, tokio::fs::read).execute(&gen_client, &signer) uploads the compiled RISC-V artifact and returns a content-addressed code id.
  3. Deploy. deploy_contract(account, code_id, ...deploy_args).execute(&gen_client, &signer) instantiates the code and runs its #[deploy]. The return value is a typed client you use for subsequent calls.
  4. Call methods. Views look like root.get_counter(account).execute(&gen_client).await; transactions look like root.increment().execute(&gen_client, &signer).await. Views take the caller’s GvmAccount explicitly; transactions derive caller identity from the signer.
  5. Assert. The same Result<T> that your contract returns lands in the test. Success values are what methods returned; strongly-typed errors (like Step 5’s CounterMaxError) surface as errors the test can match against.
If you want to know what “real” client code against one of these contracts looks like, read the matching step_0N_xxx helper. It is, line for line, what an application would do.

What’s next