Your First Contract
End-to-end: scaffold → write → test. By the end you'll have a working contract that passes a behaviour suite, with execution traces visible on demand.
This chapter uses Rust. For TinyGo / AssemblyScript / C, the patterns are identical; the per-language README.md in each scaffolded project carries the syntactic equivalent. otigen new --lang <go|as|c> produces the other-language scaffolds — it falls through to the minimal counter starter when no Rust-only template is requested.
1. Scaffold
otigen new my-counter --lang rust --from counter
cd my-counter
✓ Scaffolded my-counter — Rust contract from `counter` (7 files)
my-counter/
├─ src/lib.rs the contract (start here)
├─ otigen.toml state schema · functions · networks
└─ tests/contract.test.toml behaviour tests
Next steps:
cd my-counter
otigen test # compile + run the behaviour suite
otigen deploy # against `otigen devnet`
otigen test invokes the per-language compiler by default; --no-compile opts out when a CI step has already produced the wasm.
What landed:
my-counter/
├── Cargo.toml # cdylib + release profile tuned for WASM size
├── Makefile # build / test / deploy / inspect / verify shortcuts
├── README.md # project-local Pyde cheatsheet
├── otigen.toml # contract metadata + state schema + network
├── .gitignore
├── src/
│ └── lib.rs # your contract (start here)
└── tests/
└── contract.test.toml # behaviour tests
Seven files. The Rust scaffold pulls host fns + the entry macro from the pyde-host family of crates on crates.io, referenced from Cargo.toml (no src/host_fns.rs in the project tree — the macro substrate is the canonical interface). You'll spend your time in:
src/lib.rs— your contract code.otigen.toml— declares state fields + function signatures + network endpoints.tests/contract.test.toml— behaviour assertions.
2. The default contract
otigen new --from counter produces a minimal counter — one uint64 storage slot, two entry points:
#![allow(unused)] fn main() { // src/lib.rs (excerpt — the file ships with header docs not reproduced here) #![no_std] extern crate alloc; use core::panic::PanicInfo; use pyde_host as pyde; #[panic_handler] fn panic(_info: &PanicInfo) -> ! { core::arch::wasm32::unreachable() } // Reads `otigen.toml`'s `[state] schema` at compile time and emits // one typed accessor per field. `storage::counter()` returns a // `CounterField` with `.read() -> u64`, `.write(value: u64)`, // `.delete()`. Misspelling a field name or supplying the wrong type // is a compile error. pyde::declare_storage!(); #[pyde::entry] fn increment() -> u64 { let next = storage::counter().read().wrapping_add(1); storage::counter().write(next); next } #[pyde::entry] fn get() -> u64 { storage::counter().read() } }
The #[pyde::entry] macro wraps each function in the spec-mandated () -> () WASM shim (HOST_FN_ABI_SPEC §3.5.2) — it decodes calldata from pyde::calldata_*, calls the inner body, and surfaces the return value via pyde::return_ (the trailing underscore avoids the Rust keyword). You write idiomatic Rust; the macro handles the chain-side ABI.
The corresponding otigen.toml:
[state]
schema = [
{ name = "counter", type = "uint64" },
]
[functions.increment]
attributes = ["entry"] # callable from outside the contract
inputs = []
outputs = ["uint64"]
[functions.get]
attributes = ["entry", "view"] # callable + must not mutate (engine enforces)
inputs = []
outputs = ["uint64"]
For the meaning of attributes values (entry, view, payable, constructor, fallback, receive), see HOST_FN_ABI_SPEC §3.5. Mapping fields use type = "map" with keys = [...], value = "...", or the Solidity-style sugar type = "mapping(K => V)" (lowered to the canonical form at build time; up to 3 keys including nested mapping(K => mapping(K2 => V))).
3. Add a function
Let's add a decrement that reverts when the counter is at zero. This exercises three things:
- Reading + writing storage in the same call.
- Reverting from inside the contract.
- Asserting the revert path in a test.
Append to src/lib.rs:
#![allow(unused)] fn main() { #[pyde::entry] fn decrement() -> u64 { let current = storage::counter().read(); if current == 0 { // pyde::revert never returns; the engine rolls back every // state change since this call started. pyde::revert("counter: at zero"); } let next = current - 1; storage::counter().write(next); next } }
Declare it in otigen.toml:
[functions.decrement]
attributes = ["entry"]
inputs = []
outputs = ["uint64"]
4. Add tests
Edit tests/contract.test.toml — append three new [[tests]] entries:
# decrement from zero reverts with "counter: at zero".
[[tests]]
name = "decrement_at_zero_reverts"
[[tests.calls]]
function = "decrement"
expect.revert = "counter: at zero"
# decrement from non-zero succeeds and reaches zero.
[[tests]]
name = "decrement_to_zero"
[[tests.calls]]
function = "increment"
[[tests.calls]]
function = "increment"
[[tests.calls]]
function = "decrement"
[[tests.calls]]
function = "decrement"
expect.return_value = "0"
[tests.expect]
storage.counter = "0"
# decrement after revert leaves state untouched (rollback semantics).
[[tests]]
name = "revert_rolls_back_state"
[[tests.calls]]
function = "increment"
[[tests.calls]]
function = "decrement"
expect.return_value = "0"
# This call reverts. State changes since the call started are
# discarded; the counter stays at the value from the previous call.
[[tests.calls]]
function = "decrement"
expect.revert = "counter: at zero"
[[tests.calls]]
function = "get"
expect.return_value = "0"
5. Run the tests
otigen test
→ Compiling (rust) — cargo build --target wasm32-unknown-unknown --release
Finished `release` profile [optimized] target(s) in 11.28s
✓ Compiled → ./target/wasm32-unknown-unknown/release/my_counter.wasm
Running 6 tests in ./tests/contract.test.toml (via engine)
✓ get_returns_zero_initially (29.55 ms)
✓ increment_advances_by_one (7.72 ms)
✓ three_increments_yield_three (6.82 ms)
✓ decrement_at_zero_reverts (8.04 ms)
✓ decrement_to_zero (8.91 ms)
✓ revert_rolls_back_state (9.13 ms)
test result: ok. 6 passed; 0 failed; 0 skipped (6 ran)
(Cargo converts the kebab-case project name to snake_case for the wasm filename — that's why my-counter produces my_counter.wasm.)
(If you get a different result, jump to Debugging — the most common cause is forgetting cargo build --release, but otigen test invokes that for you by default.)
The runner executes against pyde-engine-wasm-exec::WasmExecutor — the same code path mainnet uses. That's the (via engine) marker in the output line. The legacy in-process mock is still available via --no-engine for the handful of cases the engine can't yet host (today: parachains).
6. Raise the verbosity
otigen test accepts the standard clap -v flag, repeated for more detail:
otigen test # default — per-test pass/fail + duration
otigen test -v # + per-test gas used + INFO logs from the runner
otigen test -vv # + emitted event list (topic0 + sizes)
otigen test -vvv # + per-call traces (fn args / return / gas)
otigen test -vvvv # + storage diffs (slot → before / after)
otigen test --json # NDJSON event stream for CI / scripted consumers
To see successful calls' return values + gas, use otigen test -v — -v adds gas to the pass line; -vvv adds per-call traces (fn args + return + gas).
otigen test --dry-run
--dry-run parses + resolves every test without executing the WASM — useful for verifying that your storage.<field> assertions resolve to the same Poseidon2-derived slot the contract writes to.
7. Lock in a gas budget
Once a test is green and the gas number looks reasonable, freeze it as a regression guard:
[[tests.calls]]
function = "increment"
expect.return_value = "1"
expect.gas_max = "250000" # fail if increment grows past the current budget — pick a number your green run is comfortably under
expect.gas_max is an upper-bound check — your contract can use any value ≤ the budget. Prefer gas_max over expect.gas (exact match) — exact is brittle to opcode-level codegen changes.
8. Next: custom types & complex arguments
Real contracts pass more than primitives across the ABI. Two common needs — structs and enums — are declared in a [types.<Name>] block in otigen.toml, then referenced by bare name in function signatures.
Here's a minimal Order struct + Status enum threaded through a create_order entry point.
Declare the types in otigen.toml
[types.Order]
fields = [
{ name = "id", type = "uint64" },
{ name = "maker", type = "address" },
{ name = "amount", type = "uint128" },
{ name = "paid", type = "bool" },
]
[types.Status]
variants = [
{ name = "Pending" },
{ name = "Active" },
{ name = "Cancelled" },
]
[functions.create_order]
attributes = ["entry", "view"] # body has no storage writes — declarable view
inputs = ["Order"]
outputs = ["Status"]
Custom types are referenced by bare name in function inputs / outputs ("Order", "Status") and via the struct(<Name>) wrapper in [state].schema (e.g. type = "struct(Order)"). v1 enums are unit-only — no data-carrying variants. See OTIGEN_BINARY_SPEC §4.13 for the full schema.
Declare the matching Rust types
Every custom type needs #[derive(BorshSerialize, BorshDeserialize)] — the macro substrate calls borsh on these types when decoding #[pyde::entry] arguments and round-tripping storage:
#![allow(unused)] fn main() { use borsh::{BorshSerialize, BorshDeserialize}; use pyde_host as pyde; #[derive(BorshSerialize, BorshDeserialize)] pub struct Order { pub id: u64, pub maker: pyde::Address, pub amount: u128, pub paid: bool, } #[derive(BorshSerialize, BorshDeserialize)] pub enum Status { Pending, Active, Cancelled, } #[pyde::entry] fn create_order(order: Order) -> Status { // store, validate, emit — whatever the contract needs. // The macro decoded `order` from calldata for you. Status::Pending } }
Call it with typed args
JSON5 object literal for the struct, variant name for the enum return decode:
otigen call <addr> create_order '{id:1,maker:devnet-0,amount:100,paid:false}'
# excerpt — header lines (Target / RPC / Calldata) omitted for brevity
Call create_order on 0xe37844… (devnet)
Mode: view (pyde_call — no tx, no gas, no nonce)
✓ Call succeeded.
Return: Pending
Field order in the literal doesn't matter — '{amount:100,maker:devnet-0,id:1,paid:false}' works equivalently. Address-typed fields accept wallet names from the keystore OR 0x-prefixed hex. Unquoted 0x hex literals 16+ chars long don't need surrounding quotes inside the JSON5.
What just happened
You scaffolded a project, added a function, wrote tests for both the success and failure paths, and saw the contract execute end-to-end through the production WASM executor.
The next chapter — Shipping — covers the build pipeline and the deploy flow. Then Inspect & Verify shows how to read state from a deployed contract.
For the deeper why (host fn ABI, slot derivation, WASM constraints), see WASM Contract Author Guide.