Skip to content

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.

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.

{
"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": []
}

A type reference (AbiTypeRef) is one of: a scalar, a bytes type, a collection, or a $ref to a named type.

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.

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.

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

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>Payload type plus a factory const <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.

{
"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>.

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.

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.

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.

{
"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.