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 }

Field, variant and event names are serde’s wire names, so they may be camelCase or kebab-case. The generator declares every object key under its wire name and quotes it when it is not a JS identifier.

A variant type may carry serde’s tagging keys; with none it is externally tagged, as before.

Keys Unit variant on the wire Payload variant on the wire Generated TS type
none "Name" { "Name": payload } { name, payload } union plus factory
tag { tag: "Name" } { tag: "Name", ...payload fields } the wire shape
tag, content { tag: "Name" } { tag: "Name", content: payload } the wire shape
untagged: true null the bare payload the wire shape
{
"kind": "variant",
"tag": "kind",
"content": "data",
"variants": [
{ "name": "NotFound", "payload": { "kind": "string" } },
{ "name": "Done" }
]
}

generates

export type Outcome =
| { kind: "NotFound"; data: string }
| { kind: "Done" };

content requires tag, and untagged excludes both; the schema rejects other combinations. Bytes inside an untagged payload are not converted to CalimeroBytes, because the value does not say which variant it is, so the generated type keeps them as number[].

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.

Methods, parameters, named record, variant and alias types, record fields, variants and events may carry an optional doc string: the author’s rustdoc for that item. A bytes type never does, named or inline: it declares nothing a doc could attach to. The SDK moves a method’s # Arguments entries onto the matching parameters, so a method’s doc holds the rest of its rustdoc. An item without documentation has no doc key.

{
"name": "get_folder",
"doc": "Look up one folder.\n\n# Errors\nFails when no folder has this id.",
"params": [
{
"name": "id",
"type": { "$ref": "FolderId" },
"doc": "Handle returned by an earlier call."
}
]
}

The generator emits these as JSDoc, so editors show them on hover:

/**
* get_folder
*
* Look up one folder.
*
* # Errors
* Fails when no folder has this id.
*
* @param params.id Handle returned by an earlier call.
*/

A method may also carry returns_doc (its # Returns section), destructive: true, idempotent: true and handler: true (an #[app::handler] event handler); each is absent otherwise. They are emitted as @returns <returns_doc> and one @remarks line naming the flags that are set, e.g. @remarks destructive, idempotent.

Type and field docs are emitted above the declaration they describe. A variant member’s doc is emitted above its factory function, or above its tag property when the enum carries tag. An event’s doc is emitted above the name property of its AbiEvent member. Member docs of a unit-only or untagged variant and the doc of a named bytes type have no declaration to attach to and are not emitted.