ABI Format
The generator reads a WASM-ABI v1 manifest: a JSON file describing a
Calimero contract’s public surface. Every shape here mirrors the embedded
wasm-abi-v1.schema.json; every example below passes --validate.
Manifest structure
Section titled “Manifest structure”interface AbiManifest { schema_version: 'wasm-abi/1'; // must be exactly this string types: Record<string, AbiTypeDef>; // named type definitions methods: AbiMethod[]; // callable contract methods events: AbiEvent[]; // emitted events state_root?: string; // type name backing contract state state_version?: number; // state schema version (≥ 1) migrations?: AbiMigrationEdge[]; // declared migration edges}schema_version, types, methods, and events are required — the schema
rejects the manifest without all four (even when types/methods/events are
empty). state_root, state_version, and migrations are optional.
Minimal example
Section titled “Minimal example”{ "schema_version": "wasm-abi/1", "types": { "SetEntryRequest": { "kind": "record", "fields": [ { "name": "key", "type": { "kind": "string" } }, { "name": "value", "type": { "kind": "string" } } ] } }, "methods": [ { "name": "get_value", "params": [{ "name": "key", "type": { "kind": "string" } }], "returns": { "kind": "string" }, "returns_nullable": true, "intent": "read_only" }, { "name": "set_value", "params": [{ "name": "req", "type": { "$ref": "SetEntryRequest" } }], "returns": { "kind": "unit" }, "intent": "mutating" } ], "events": []}Type references
Section titled “Type references”A type reference (AbiTypeRef) is one of: a scalar, a bytes type, a collection,
or a $ref to a named type.
Scalar types
Section titled “Scalar types”Each is an object with a single kind:
| Type | Meaning |
|---|---|
{ "kind": "bool" } |
Boolean |
{ "kind": "string" } |
UTF-8 string |
{ "kind": "unit" } |
Void / no value |
{ "kind": "i32" } / { "kind": "i64" } |
Signed 32- / 64-bit integer |
{ "kind": "u32" } / { "kind": "u64" } |
Unsigned 32- / 64-bit integer |
{ "kind": "f32" } / { "kind": "f64" } |
32- / 64-bit float |
{ "kind": "bytes" } // variable-length{ "kind": "bytes", "size": 32 } // fixed-length (size ≥ 1)encoding is an optional free-form hint (mirrors core’s BytesType); current
SDKs omit it. In generated code, bytes values are represented by the
CalimeroBytes helper class.
Collections
Section titled “Collections”| Kind | Shape |
|---|---|
list |
{ "kind": "list", "items": <type>, "crdt_type"?: <crdt> } |
map |
{ "kind": "map", "key": <type>, "value": <type>, "crdt_type"?: <crdt> } |
record |
{ "kind": "record", "fields": [<field>…], "crdt_type"?: <crdt>, "inner_type"?: <type> } |
tuple |
{ "kind": "tuple", "elements": [<type>…] } (at least one element) |
$ref |
{ "$ref": "TypeName" } — reference to a named type |
A $ref normally names an entry in types, but it may also name a raw Rust
type the generator recognizes (String, bool, u8…u64, i8…i64,
f32/f64, and generic forms like Vec<T>, Option<T>, Result<T, E>, (),
and tuples). Those are mapped straight to TypeScript instead of resolved against
types.
CRDT-backed collections
Section titled “CRDT-backed collections”Any list, map, or record may carry a crdt_type marking it as a
CRDT-backed state collection. The schema accepts exactly these 12 values:
lww_register |
counter |
vector |
replicated_growable_array |
fugue_text |
authored_vector |
shared_storage |
unordered_map |
sorted_map |
authored_map |
unordered_set |
sorted_set |
Named type definitions
Section titled “Named type definitions”The types map defines reusable named types. A named AbiTypeDef is one of
four kinds: record, variant, alias, or bytes.
// Record (struct) — fields is an array of { name, type, nullable? }{ "kind": "record", "fields": [ { "name": "name", "type": { "kind": "string" } }, { "name": "count", "type": { "kind": "u32" } } ]}
// Variant (enum) — variants is an array of { name, code?, payload? }{ "kind": "variant", "variants": [ { "name": "Pending" }, { "name": "Done", "payload": { "kind": "string" } } ]}
// Alias — names another type{ "kind": "alias", "target": { "kind": "u64" } }
// Bytes — a named bytes type (e.g. a fixed-size hash){ "kind": "bytes", "size": 64 }Aliases are newtypes, and they are branded
Section titled “Aliases are newtypes, and they are branded”kind: "alias" is emitted only for a Rust one-field tuple struct — a newtype
such as pub struct FolderId(pub String);.
A plain type FolderId = String cannot derive AbiType, so it never reaches
the manifest as a named type.
Every alias in a manifest is therefore a distinction the ABI author declared
deliberately.
Emitting export type FolderId = string would throw that distinction away:
FolderId, ContextId and any bare string would all be mutually assignable.
Aliases over a string or numeric primitive are instead emitted as a
branded type plus a constructor:
export type FolderId = string & { readonly __brand: 'FolderId' };export const FolderId = (value: string): FolderId => value as FolderId;The brand exists only in the type system — at runtime a FolderId is the
string it always was, and the JSON on the wire is unchanged.
Branding stops there, and deliberately:
| Alias target | Emitted as | Why |
|---|---|---|
string, i32/i64/u32/u64/f32/f64 |
branded | TypeScript would otherwise erase the distinction entirely |
bool |
plain alias | two inhabitants; nothing to confuse |
bytes |
plain alias (CalimeroBytes) |
already a class; an intersection would reject real instances |
list, map, tuple, inline record |
plain alias | structurally distinct already, and branding would force a cast on every literal |
$ref to a record or variant |
unchanged | records emit an interface and variants a discriminated union, both of which already carry their own shape |
$ref to another alias |
branded under its own name | struct A(B) and B are distinct types in Rust, so they are distinct here |
A newtype over a type the manifest does not define is left as a plain alias.
Note what branding cannot do: a field declared as a bare {"kind": "string"}
stays a bare string.
Branding recovers a distinction the ABI made; it cannot invent one the app
never declared.
Guarding such a field means giving it a newtype in the Rust source first.
How variants are emitted depends on their payloads:
- All-unit variants (no payloads) become a string-literal union, e.g.
type Status = 'Pending' | 'Active'— serde serializes these as bare strings. - Mixed / payload-bearing variants become a discriminated-union
<Name>Payloadtype plus a factoryconst <Name>with a constructor per variant.
A payload-bearing variant passed as a method parameter is rewritten to serde’s
{ Variant: payload } shape before the call, and a unit member of such a variant
to the bare string "Variant", so the ergonomic { name, payload } form never
reaches the node.
A newtype over a variant is rewritten the same way as the variant it wraps.
A response is decoded the other way, from the declared return type rather than
from the shape the JSON happens to have.
So a returned payload-bearing variant is untagged back to { name, payload },
a returned bytes becomes a CalimeroBytes, and a returned list<u32> stays
plain numbers even where it sits beside bytes.
Methods whose return type reaches neither bytes nor a payload-bearing variant
emit no conversion at all.
A null response is passed through untouched whether or not the method declares
returns_nullable.
One limit is worth knowing: a type that reaches itself is decoded only down to
the first cycle, so below that point values arrive as raw JSON and the declared
type is not honoured.
For a self-referential Node { hash: bytes, child: Node }, node.hash is a
CalimeroBytes but node.child.hash is still a plain array, and calling
.toArray() on it throws even though the type checker accepted it.
Decoding a map value also requires Object.fromEntries, so a consumer’s
tsconfig needs lib at ES2019 or newer.
Methods
Section titled “Methods”{ "name": "create_post", "params": [{ "name": "title", "type": { "kind": "string" } }], "returns": { "$ref": "Post" }, "returns_nullable": false, "intent": "mutating", "errors": [{ "code": "TITLE_TOO_LONG" }], "xcall_callable": false, "xcall_callers": "any_in_namespace"}Only name and params are required; everything else is optional. The example
above generates createPost(params: { title: string }): Promise<Post>.
Method intent
Section titled “Method intent”intent declares whether a method reads or writes state:
| Value | Meaning |
|---|---|
read_only |
Reads state only; no mutation. |
mutating |
Writes / changes state. |
unspecified |
Not declared. |
A method with no intent field is treated as potentially mutating (the node
defaults an absent intent to write intent). The generator surfaces a declared
intent as an @intent JSDoc tag on the method — documentation only; it does not
yet change how the method is called.
Cross-context calls (xcall)
Section titled “Cross-context calls (xcall)”Two optional fields describe cross-context invocation, declared by the app author:
| Field | Values | Meaning |
|---|---|---|
xcall_callable |
boolean |
Whether the method is a cross-context entry point. Absent/false means it is not. |
xcall_callers |
any_in_namespace | same_app |
Who may invoke it. any_in_namespace (the default) allows any context in the namespace; same_app restricts callers to contexts running the same application. Enforced by the node. |
The generator surfaces both on the method as an @xcall JSDoc tag, because
nothing in the emitted signature distinguishes a cross-context entry point from
an ordinary method:
/** * transfer * * @xcall same_app (callers must run the same application id) */public async transfer(params: { to: string }): Promise<void>An xcall_callable method with no declared xcall_callers is tagged
any_in_namespace, since that is the value the node applies. The tag is
documentation: the policy is enforced by the node, not by the generated client.
A TEE timer (#[app::tee(every = "..")]) carries its period:
| Field | Values | Meaning |
|---|---|---|
tee_every_secs |
integer, at least 1 |
The node’s TEE scheduler fires the method once per this many seconds, on one TEE authority. Absent for every other method. |
The generator tags such a method @tee every <n>s. Calling it from a client is
refused inside the method: only the TEE scheduler may run it.
State versioning & migrations
Section titled “State versioning & migrations”When a contract’s state schema evolves, the manifest records the current
state_version and the migration edges that reach it. Each edge names the
migration method and the version it upgrades from (note the camelCase wire
key fromVersion):
{ "state_version": 3, "migrations": [ { "method": "migrate_v1_to_v2", "fromVersion": 1 }, { "method": "migrate_v2_to_v3", "fromVersion": 2 } ]}Modelled as AbiMigrationEdge { method: string; fromVersion: number }. Both
state_version and migrations are optional, top-level fields.
A method named by a migration edge is tagged on the emitted client, so it is not mistaken for ordinary app API:
/** * migrate_v1_to_v2 * * @migration from state version 1 to 2 — invoked by the node during upgrade, not by app code */state_version itself is not emitted: it describes the deployed state, not the
client’s call surface.
Events
Section titled “Events”{ "name": "PostCreated", "payload": { "$ref": "PostCreatedPayload" }}An event has a name and an optional payload (a single type reference —
typically a $ref to a record). For an event with a payload, the generator
emits a <EventName>Payload type alias; it also emits a single AbiEvent union
over all events, e.g.:
export type PostCreatedPayload = { /* … */};
export type AbiEvent = | { name: 'PostCreated'; payload: PostCreatedPayload } | { name: 'PostDeleted' };Events whose payload is inline unit (or absent) omit the payload field in
the union.