Develop Cairo contracts for Eternum with strict TDD workflow - plan, test first, then implement
Develop Cairo contracts for the Eternum project using Test-Driven Development. This skill enforces a strict workflow: Plan -> Test First -> Implement -> Review.
NO IMPLEMENTATION CODE WITHOUT A FAILING TEST FIRST.
If you write implementation before tests, delete it and start over.
Before writing any code, understand:
Create a design:
Models Needed:
[Model Name]
- #[key] field_name: type <- what uniquely identifies this?
- data_field: type
Systems Needed:
[System Name]
- function_name(params) -> what it does
Relationships:
System X reads/writes Model A, B
CRITICAL: Before implementing anything, study how similar features are built in the codebase. Don't invent new patterns when established ones exist.
# Find similar models
Grep("similar_concept", "contracts/l3/game/src/models")
# Find similar systems
Grep("similar_action", "contracts/l3/game/src/systems")
# Find config patterns (if your feature needs config)
Grep("register_preset", "contracts/l3/game/src/systems/registrar")
Grep("Config", "contracts/l3/game/src/models/config.cairo")
# Find deployer integration patterns
Grep("buildPresetRegistration", "config/deployer/clean/registrar/preset.ts")
Follow the registrar preset pattern. Add immutable preset fields and their payload builders; do not add legacy per-world configuration setters.
Track each piece with TodoWrite:
TodoWrite([
{content: "Write tests for Model A", status: "pending", activeForm: "Writing tests for Model A"},
{content: "Implement Model A (make tests pass)", status: "pending", activeForm: "Implementing Model A"},
{content: "Write tests for System X", status: "pending", activeForm: "Writing tests for System X"},
{content: "Implement System X (make tests pass)", status: "pending", activeForm: "Implementing System X"},
{content: "Review code", status: "pending", activeForm: "Reviewing code"},
])
contracts/l3/game/src/systems/<feature>/tests/<test_name>.cairo
#[cfg(test)]
mod tests {
use dojo::model::{ModelStorage, ModelStorageTest};
use dojo::world::WorldStorageTrait;
use dojo_cairo_test::{ContractDef, ContractDefTrait, NamespaceDef, TestResource};
use crate::constants::{DEFAULT_NS, DEFAULT_NS_STR};
// Import models (use m_ prefix for TEST_CLASS_HASH)
use crate::models::your_feature::{YourModel, m_YourModel};
// Import systems
use crate::systems::your_feature::contracts::your_systems::{
IYourSystemsDispatcher, IYourSystemsDispatcherTrait, your_systems,
};
// Import testing helpers
use crate::utils::testing::helpers::{
MOCK_TICK_CONFIG, MOCK_CAPACITY_CONFIG, tspawn_world,
tstore_tick_config, tstore_capacity_config,
};
fn namespace_def() -> NamespaceDef {
NamespaceDef {
namespace: DEFAULT_NS_STR(),
resources: [
// Models (use m_ prefix for TEST_CLASS_HASH)
TestResource::Model(m_YourModel::TEST_CLASS_HASH),
// Contracts
TestResource::Contract(your_systems::TEST_CLASS_HASH),
].span(),
}
}
fn contract_defs() -> Span<ContractDef> {
[
ContractDefTrait::new(DEFAULT_NS(), @"your_systems")
.with_writer_of([dojo::utils::bytearray_hash(DEFAULT_NS())].span()),
].span()
}
fn setup() -> (WorldStorage, IYourSystemsDispatcher) {
let mut world = tspawn_world(namespace_def(), contract_defs());
// Store required configs
tstore_tick_config(ref world, MOCK_TICK_CONFIG());
tstore_capacity_config(ref world, MOCK_CAPACITY_CONFIG());
// Get system dispatcher
let (system_addr, _) = world.dns(@"your_systems").unwrap();
let dispatcher = IYourSystemsDispatcher { contract_address: system_addr };
(world, dispatcher)
}
#[test]
#[available_gas(3000000000000)]
fn test_happy_path() {
let (mut world, dispatcher) = setup();
// Arrange
let owner = starknet::contract_address_const::<'owner'>();
starknet::testing::set_contract_address(owner);
// Act
dispatcher.your_action(/* params */);
// Assert
let result: YourModel = world.read_model(/* key */);
assert!(result.field == expected_value, "wrong field value");
}
#[test]
#[available_gas(3000000000000)]
#[should_panic(expected: ('Expected error', 'ENTRYPOINT_FAILED'))]
fn test_fails_when_unauthorized() {
let (mut world, dispatcher) = setup();
let unauthorized = starknet::contract_address_const::<'unauthorized'>();
starknet::testing::set_contract_address(unauthorized);
// This should panic
dispatcher.your_action(/* params */);
}
}
Run the test to verify it fails:
cd contracts/l3/game
scarb test test_your_feature
MANDATORY: Confirm the test fails because the feature is missing, not due to syntax errors.
use starknet::ContractAddress;
use crate::alias::ID;
#[derive(Introspect, Copy, Drop, Serde)]
#[dojo::model]
pub struct YourModel {
#[key]
pub entity_id: ID,
pub owner: ContractAddress,
pub value: u128,
}
#[generate_trait]
pub impl YourModelImpl of YourModelTrait {
fn some_helper(self: @YourModel) -> u128 {
*self.value * 2
}
fn assert_valid(self: @YourModel) {
assert!(*self.value > 0, "value must be positive");
}
}
use crate::alias::ID;
#[starknet::interface]
pub trait IYourSystems<T> {
fn your_action(ref self: T, entity_id: ID, param1: u128);
}
#[dojo::contract]
pub mod your_systems {
use dojo::model::ModelStorage;
use crate::alias::ID;
use crate::constants::DEFAULT_NS;
use crate::models::your_feature::{YourModel, YourModelTrait};
use crate::models::owner::OwnerAddressTrait;
use crate::models::structure::StructureOwnerStoreImpl;
#[abi(embed_v0)]
impl YourSystemsImpl of super::IYourSystems<ContractState> {
fn your_action(ref self: ContractState, entity_id: ID, param1: u128) {
let mut world = self.world(DEFAULT_NS());
// Verify ownership
let owner = StructureOwnerStoreImpl::retrieve(ref world, entity_id);
owner.assert_caller_owner();
// Read current state
let mut model: YourModel = world.read_model(entity_id);
// Validate
model.assert_valid();
// Modify state
model.value = param1;
// Write back
world.write_model(@model);
}
}
}
scarb test test_your_feature
All tests must pass before proceeding.
Clean up code while keeping tests green. Run tests after each change.
Models:
#[derive(Introspect, Copy, Drop, Serde)]#[dojo::model]Systems:
#[starknet::interface] trait#[dojo::contract] moduleassert! with clear error messagesTests:
#[should_panic]Check for:
A feature is not complete until the entire stack is wired up. Before marking done, verify:
contracts/l3/game/src/models/contracts/l3/game/src/systems/models/config.cairosystems/registrar/contracts.cairopackages/provider/src/index.tspackages/types/src/types/config/source/buildPresetRegistration() in
config/deployer/clean/registrar/preset.tsSelf-Learning Principle: Before implementing a new pattern (like config setters), search for existing examples:
# Find existing config patterns
Grep("set_.*_config", "contracts/l3/game/src/systems")
Grep("buildPresetRegistration", "config/deployer/clean/registrar")
cd contracts/l3/game
scarb test
Use the smallest type that fits the value range. Don't upsize unless aggregation requires it.
| Value Range | Type | Use Case |
|---|---|---|
| 0-255 | u8 |
Percentages, small counts |
| 0-65,535 | u16 |
Config values, rates per second |
| 0-4.29B | u32 |
Aggregated rates, medium counts |
| 0-340 undecillion | u128 |
Accumulated totals, balances |
Example: A config field for "points per second" that will never exceed 1000 should be u16, not u32. Only upsize
when values are aggregated (e.g., summing multiple u16 rates into a u32 total).
Scaling and precision belong in the client/config layer, not contracts.
| Layer | Responsibility |
|---|---|
config/source/ |
Apply precision multipliers, human-readable values |
config/deployer/clean/registrar/preset.ts |
Pass scaled values to contracts |
contracts/ |
Store and compute with pre-scaled integers |
Bad (precision in contracts):
const PRECISION: u32 = 10;
let scaled = config.rate * PRECISION;
Good (precision in client):
// _shared_.ts
const PRECISION = 10;
const RATE = 50 * PRECISION; // 500
Prefer simple data structures over complex index-based patterns.
Bad (over-engineered):
struct Winners {
count: u32,
}
struct Winner {
index: u32,
wonder_id: ID,
}
// Requires managing count, iterating indices
Good (simple):
struct Winners {
wonder_ids: Array<ID>,
}
// Direct array operations
When ownership or state can change during operations, test all permutations:
#[test]
fn test_ownership_changes_during_operation() {
// Setup: entity owned by player_a
// Act: transfer to player_b mid-operation
// Assert: both states updated correctly
}
For reference on patterns:
# Model design patterns
Skill("book:dojo-model")
# System patterns
Skill("book:dojo-system")
# Test patterns
Skill("book:dojo-test")
# Code review
Skill("book:dojo-review")
contracts/l3/game/src/
āāā models/
ā āāā your_feature.cairo # Models
āāā systems/
ā āāā your_feature/
ā āāā contracts.cairo # Systems
ā āāā tests/
ā āāā test_feature.cairo # Tests
āāā utils/testing/helpers.cairo # Test helpers
# Run specific test
scarb test test_your_feature
# Run all tests
scarb test
# Build only
scarb build
// Models
use dojo::model::{ModelStorage, ModelStorageTest};
use crate::alias::ID;
// Systems
use dojo::model::ModelStorage;
use crate::constants::DEFAULT_NS;
// Tests
use dojo_cairo_test::{NamespaceDef, TestResource, ContractDef, ContractDefTrait};
use crate::utils::testing::helpers::{tspawn_world, MOCK_TICK_CONFIG};
From utils/testing/helpers.cairo:
tspawn_world(namespace_def, contract_defs) - Creates test worldMOCK_*_CONFIG() - Config factory functionststore_*_config() - Store config helperstgrant_resources() - Grant resources to entitytspawn_simple_realm() - Create realm for testingtspawn_explorer() - Create explorer for testing1. PLAN
- Understand requirements
- Design models/systems
- Create todo list
2. TEST FIRST (RED)
- Create test file
- Write failing tests
- Verify tests fail
3. IMPLEMENT (GREEN)
- Write minimal code
- Verify tests pass
4. REFACTOR
- Clean up code
- Keep tests green
5. REVIEW
- Code quality check
- Security review
- Full test suite
Remember: Delete any implementation code written before tests.
Don't create complex index-based storage when a simple Array<T> works. Question every level of indirection.
A contract change without deployer integration is incomplete. Always trace the full path: Model ā System ā Provider ā Types ā Config ā Deployer.
Don't use u32 when u16 fits. Don't use u128 when u32 fits. Larger types cost more gas and obscure intent.
Before creating a new way to do something, search for how it's already done. The codebase is your teacher.