ic-wire 0.4.8

Bounded canonical Postcard contracts for controlled Internet Computer endpoints
Documentation

Rust runtime and Canic integration

IC Wire now provides a Postcard-only no_std + alloc runtime and a host-side Wire derive. The runtime has no platform SDK or host-tooling dependency. Packages are published on crates.io starting at 0.3.0. Canic can integrate a controlled Rust endpoint against this API; its query/update macros, auth policy and rollout remain Canic-owned.

Shared contract

This example compiles as a library documentation test:

use ic_wire::{Bytes, Limits, List, Method, Text, Wire};

#[derive(Debug, PartialEq, Wire)]
pub struct BalanceRequest {
    #[wire(id = 10)]
    pub account: u64,
    #[wire(id = 20)]
    pub labels: List<Text<32>, 4>,
}

#[derive(Debug, PartialEq, Wire)]
pub enum BalanceError {
    #[wire(id = 7)]
    Missing,
}

pub struct Balance;
impl Method for Balance {
    type Request = BalanceRequest;
    type Response = Result<u64, BalanceError>;
    const ID: u64 = 0x42414c414e4345;
    const NAME: &'static str = "balance";
    const REQUEST_LIMITS: Limits = Limits {
        bytes: 256, allocation: 1024, nodes: 32, depth: 8,
    };
    const RESPONSE_LIMITS: Limits = Limits {
        bytes: 64, allocation: 128, nodes: 8, depth: 4,
    };
}

let request = BalanceRequest { account: 7, labels: List::new(vec![])? };
let retained = ic_wire::encode_request::<Balance>(&request)?;
// The transport sends retained.as_bytes() to Balance::NAME unchanged.
let decoded = ic_wire::decode_request::<Balance>(retained.as_bytes())?;
assert_eq!(decoded, request);
let reply = ic_wire::encode_response::<Balance>(&Ok(123))?;
assert_eq!(ic_wire::decode_response::<Balance>(reply.as_bytes())?, Ok(123));
# Ok::<(), ic_wire::Error>(())

Fields serialize in ascending explicit wire(id) order; source declaration order is irrelevant. IDs are schema authority, not tags transmitted per field. Renaming or reordering source fields while preserving IDs/types preserves bytes. Adding, removing or changing a field is a coordinated contract change, even if its type is Option. Choose a new method contract ID for incompatible bytes; update all producers/consumers and resolve retained requests before retiring the old layout. There is one current contract, without fallback readers or automatic migration.

Enums use explicit u32 wire(id) tags and support unit variants or one payload field (use a named record for several values). Rust variant order is irrelevant. Records support up to 16 named fields, including an empty record. Generic derives, recursive graphs, maps, raw f32/f64 and architecture-dependent integers are excluded. Supported leaves are unit, bool, fixed-width signed/unsigned integers through 128 bits, and FiniteF32/FiniteF64. Option, Result, bounded List/Bytes/Text, Serde-supported fixed arrays and Box<T: Wire> compose. Boxes are transparent: bytes, schema, node count and depth match T, while the heap bound includes size_of::<T>() plus T's nested heap. Zero-sized boxes add no allocation. Boxing does not make recursive or unbounded types finite. Result uses Postcard's fixed tags 0=Ok, 1=Err; Option uses 0=None, 1=Some.

Principal values cross as Bytes<29>; adapters convert/validate with their existing principal type. IC Wire adds no principal identity type. Owned values can leave their bounded wrappers through Bytes::into_vec, Text::into_string and List::into_vec without copying or allocating. Consumers then own the recovered buffers; constructing another wrapper checks its bound again. A field of Bytes<29> alone establishes a byte bound, not caller authentication. Keep the dependency name ic-wire so generated paths resolve. Consumers do not need to depend directly on Serde or derive its traits for these records.

Finite floats and SQL-shaped messages

FiniteF32::new(value) and FiniteF64::new(value) admit finite primitives and return Error::Malformed for NaN or infinity. .get() recovers the primitive. Decoding rejects non-finite IEEE-754 bit patterns, including signaling NaNs. Postcard encodes these transparent wrappers as native little-endian 4/8-byte floats. They add no heap allocation or dynamic traversal to Rust's scalar shape. Signed zero and subnormals are preserved, without consumer-specific normalization.

use ic_wire::{FiniteF32, FiniteF64};
assert_eq!(FiniteF32::new(-0.0)?.get().to_bits(), (-0.0_f32).to_bits());
assert_eq!(FiniteF64::new(2.5)?.get(), 2.5);
assert!(FiniteF64::new(f64::INFINITY).is_err());
# Ok::<(), ic_wire::Error>(())

Generated TypeScript uses number. A FiniteF32 input must already be exactly representable as f32; use Math.fround(value) when intentionally converting a JavaScript number. For example, 0.1 alone rejects while Math.fround(0.1) is accepted. FiniteF64 accepts finite JavaScript numbers. Both reject NaN/infinity; f32 overflow, underflow or silent rounding also rejects on encoding.

The conformance fixture includes an empty and populated SQL-shaped page with bounded columns/rows, explicit cell tags, signed/unsigned integers, both finite floats, text, bytes, booleans, null and an optional continuation value. This is a local consumer-shaped DTO, not IcyDB's actual SQL API. Convert owned String/Vec values through Text/List/Bytes at the message boundary; their constructors check bounds without granting unrestricted Wire implementations to those containers.

IcyDB's scalar-boundary documentation at commit 996498ad4edb5569964b68e0c0e3f5bb9df7d1c6 already specifies finite-only floats and normalizes negative zero (read-only inspection of icydb-core/src/types/mod.rs). That policy belongs in IcyDB's conversion; IC Wire does not silently apply it. Recursive values still need separate bounded admission and schema-generation work. Ordinary finite schemas retain the existing static resource model. Cross-language correctness does not establish new performance savings or qualify every SQL value kind, nested recursive value, or deployed SQL endpoint.

The new Float metadata variant makes Schema non-exhaustive for future additions. External metadata matches must include a fallback. This is a source API change for the next minor release, not a change to previously supported wire bytes.

Resource and failure contract

Each type has a conservative static Shape. Before encoding or decoding, the method admits its maximum envelope bytes, value heap plus codec scratch, visited nodes and nesting depth against Limits. This deliberately admits the whole schema, not merely the current small value. Tighten field bounds if a schema is rejected. The allocation figure counts requested storage, not allocator bookkeeping or the caller's input/output custody. Static arrays and record stack storage are not heap; node/depth allowances bound traversal but are not instruction-counter estimates. Manual Wire implementations are trusted contract code and must satisfy these bounds. Prefer the derive and supplied bounded types.

Length hints are checked before list allocation. When Postcard hides a hint, decode visits at most N elements and rejects an extra element without decoding it. This covers zero-byte unit elements. Bytes/text check their lengths before copying, and text rejects invalid UTF-8. Container allocation failures and malformed payloads surface as Error::Malformed through Postcard; codec scratch allocation failure is Error::Allocation. Box uses Serde's standard allocation after schema admission; allocator exhaustion may abort rather than return a typed error. Static admission bounds requested storage, not general out-of-memory recovery. Encoding reserves the admitted maximum but initializes only emitted bytes. Canonical validation compares emitted chunks directly with the input without a second output allocation. Schema admission still reserves the same conservative heap-plus-scratch allowance for both directions.

The complete envelope is ASCII IW, then the method's u64 ID in little-endian, then direction byte 0=request or 1=response, then canonical Postcard payload. The 11-byte header is checked before payload decode. Exact consumption rejects trailing bytes. Re-encoding rejects nonminimal representations. Empty arguments and unit responses still carry this header. RequestBytes and ResponseBytes are immutable and method-bound; retain their exact bytes for retry or hashing. Consumer hash domains, semantic ordering and excluded fields remain consumer-owned. Encoding is deterministic for the supplied types, not a general signature policy.

Canic adapter work

Keep application services callable with consumer-owned values. Endpoint adapters own decoding, conversion, dispatch, response encoding and transport. Method IDs and retained canonical bytes describe the Wire contract. Endpoint integration and qualification stay with the consumer.

  1. Bind each owned query/update to a shared Method. Canic's macro should validate that the function's argument and return types match that binding at compile time. Keep one request object; an empty request is unit or an empty record.
  2. Check raw IC argument length against REQUEST_LIMITS.bytes before copying it. Call decode_request, then Canic's existing startup/auth/semantic stages in their required order. Caller-only checks can run before decoding; proof-dependent checks require the admitted envelope. No handler runs after admission failure.
  3. Dispatch the existing synchronous/async handler; encode_response bounds the reply and supplies exact bytes to the raw reply primitive. If response encoding fails after a handler changes local state, trap to roll back the transaction; a normal rejection can commit that mutation. This does not undo effects already committed before an external await. Keep application Result errors inside the response, separate from codec and transport failure.
  4. Outbound adapters encode_request once, send those bytes to Method::NAME and decode_response under the same Method. Canic still owns query/update selection, cycles, deadlines, call errors, access policy and safe retry decisions.
  5. Keep wire method/limit metadata and actual callable-export validation together. The consumer owns its executor and platform integration.

make check-typed-diagnostics compiles the actual runtime test contracts in an isolated compiler invocations with locked, previously fetched dependencies offline. It first checks valid synchronous and async consumer-shaped adapters, each binding its handler argument and response to the same Method associated types. It then checks expected compilation failures: missing field IDs, duplicate field/variant IDs, unbounded String, generic and tuple contracts, wrong request/reply types, wrong handler argument/return types and an async handler with the wrong future output. Unexpected diagnostics print their logs; failed fixtures are retained for inspection. These are private adapter-shape checks, not implemented Canic macros, executor or auth policy. Canic should preserve these type relationships in its own expansions and run macro-owned compile-fail tests when integration begins.

TypeScript generation

Enable ic-wire's typescript feature in a host generator, then write ic_wire::typescript::generate::<YourMethod>()? to a method-specific .ts file and ic_wire::typescript::RUNTIME to wire-runtime.ts beside it. Generation uses Wire::SCHEMA, emitted by the same derive that owns Rust field order, variant IDs and bounds. It rejects inadmissible methods, opaque manual implementations and excessively large/deep metadata. Hand-written metadata must faithfully match its serializer and shape; it is trusted code, not a schema supplied by a caller. The optional feature adds serde_json only to generation; leave it disabled in canister dependencies. No SDK or transport is imported by generated modules.

Each module exports Request, Response, methodName, encodeRequest, decodeRequest, encodeResponse and decodeResponse. The consumer supplies transport; this example illustrates the codec calls only:

import * as apply from './generated/apply.js';
const bytes = apply.encodeRequest(request);
const reply: Uint8Array = await transport.send(apply.methodName, bytes);
const response = apply.decodeResponse(reply);

Retain the encoded bytes for retries rather than regenerating them from a mutable object. Canic still owns safe retry, authentication, query/update selection and certificate verification. Browser transport reply allocation happens before decode.

Rust value TypeScript projection
u8 through u32, i8 through i32 Checked integer number
u64, u128, i64, i128 bigint
(), bool null, boolean
FiniteF32, FiniteF64 Finite number; f32 must be exactly representable
Bytes<N>, Text<N> Uint8Array, UTF-8 byte-bounded string
List<T,N>, [T;N] ReadonlyArray<T>; bounded/exact count checked at runtime
Option<T> null or { some: T }, preserving nested options and unit
Result<T,E> { ok: T } or { err: E }
Derived record Named object; fields encoded in explicit ID order
Derived enum { tag: "Variant", value: payload }; unit payload is null

Outgoing values are checked as well as incoming bytes. WireError.code is one of limit, malformed, identity or value. Decoding rejects nonminimal varints, unknown tags, invalid UTF-8, trailing bytes and wrong method/direction; encoding rejects unsafe integer representations, lone UTF-16 surrogates and extra fields. A leading Unicode BOM is retained as text. Decoded byte buffers own their data. Logical node/depth, frame, collection and UTF-8 byte bounds are enforced before unbounded work/allocation. JavaScript engine heap usage is not the Rust allocation budget: object overhead, temporary buffers and engine limits differ.

make check-typescript uses the prepared standalone Node and TypeScript tools. It generates 90 Rust vectors covering every supported primitive and composition, type-checks the emitted modules, and checks both directions in Node, including truncation, identity errors and hostile input/value cases. This is codec conformance; transport and authentication require consumer qualification. These checks are not performance evidence. The maintained fixture is crates/ic-wire/examples/typescript_conformance/.

Compile-time rejection examples

Missing field identities are rejected:

#[derive(ic_wire::Wire)]
struct Missing { value: u64 }

Duplicate identities are rejected:

#[derive(ic_wire::Wire)]
struct Duplicate { #[wire(id = 1)] a: u64, #[wire(id = 1)] b: bool }

Unbounded containers cannot enter a derived contract:

#[derive(ic_wire::Wire)]
struct Unbounded { #[wire(id = 1)] items: Vec<u64> }

A method cannot encode the wrong request type:

use ic_wire::{Limits, Method};
struct Read;
impl Method for Read {
    type Request = u64;
    type Response = bool;
    const ID: u64 = 1;
    const NAME: &'static str = "read";
    const REQUEST_LIMITS: Limits = Limits { bytes: 64, allocation: 128, nodes: 8, depth: 4 };
    const RESPONSE_LIMITS: Limits = Self::REQUEST_LIMITS;
}
let _ = ic_wire::encode_request::<Read>(&true);

Delivery sizing and inspection

Use encoded_request_len::<M>(&request) or encoded_response_len::<M>(&response) to measure the complete frame without an output buffer. These helpers retain whole-schema admission and require deterministic serializers. A separate size check adds a serialization pass; ordinary encoding stays single-pass. See consumer qualification and the offline packet viewer for qualification ownership and diagnostics.

Package and license

Use the registry dependency ic-wire = "0.3". Enable features = ["typescript"] only when generating client codecs on the host. The runtime supports Rust 1.91 and wasm32-unknown-unknown.

Licensed under the MIT License; see the packaged LICENSE.

Native Serde payloads

ic_wire::native handles consumer-owned Serde types, including String, Vec, tuples, maps and recursive enums, using the same Postcard encoding and envelope. It does not infer Wire, static maxima or TypeScript metadata for those types. Consumers own declaration order, compatible method IDs, deterministic serializers and domain validation (including finite floats where required). Unordered maps are unsuitable unless their serializer establishes deterministic ordering.

use ic_wire::{Limits, native};
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Request { text: String, values: Vec<u64> }
let limits = Limits { bytes: 4096, allocation: 65536, nodes: 4096, depth: 64 };
let request = Request { text: "hello".into(), values: vec![1, 2] };
let bytes = native::encode_request(7, &request, limits)?;
assert_eq!(native::encoded_frame_len(&request, limits)?, bytes.len());
assert_eq!(native::decode_request::<Request>(7, &bytes, limits)?, request);
# Ok::<(), ic_wire::Error>(())

encode/decode and encoded_len operate on payloads without headers. encode_request/encode_response accept borrowed Serialize values without an owned Deserialize requirement. Native decode accepts Deserialize<'de> values: strings and explicit byte views can borrow directly from the input. Ordinary owned callers remain supported; finite Wire contracts remain owned. Borrowing does not skip resource admission or canonical verification, and does not make arbitrary integer sequences borrowable. The decoded value cannot outlive its input. encoded_frame_len includes the 11-byte header for either direction. Retain the returned bytes unchanged for any consumer-authorized retry; IDs alone do not establish idempotency. Sizing does not retain a serialization for later delivery.

encode_into, encode_request_into and encode_response_into write into a caller-owned Vec<u8> and return Result<(), Error>. They clear previous contents on entry and leave the buffer empty on error. Successful calls retain capacity; serialization failures may discard capacity. They preserve the two-pass native admission/sizing contract and the existing frame bytes. Existing capacity never relaxes byte limits. Oversized retained capacity is caller-owned storage, not a measurement of this operation's allocation allowance; shrink or drop it according to the caller's memory policy. Keep separate immutable bytes for retries before reusing a buffer.

use ic_wire::{Limits, native};
let limits = Limits { bytes: 4096, allocation: 65536, nodes: 4096, depth: 32 };
let mut output = Vec::new();
native::encode_request_into(7, &"hello", limits, &mut output)?;
let text: &str = native::decode_request(7, &output, limits)?;
assert_eq!(text, "hello");
// Once the borrowed view is no longer used, the buffer can be reused.
native::encode_response_into(7, &"world", limits, &mut output)?;
# Ok::<(), ic_wire::Error>(())
use ic_wire::{Limits, native};
let limits = Limits { bytes: 64, allocation: 1024, nodes: 64, depth: 16 };
let text: &str = {
    let frame = native::encode_request(7, &"hello", limits).unwrap();
    native::decode_request(7, &frame, limits).unwrap()
}; // The input storage cannot be dropped while its decoded view is used.
println!("{text}");

Limits are explicit per operation. Native bytes bounds the payload or full frame, respectively. allocation reserves that entire byte allowance for output scratch before admitting value storage. The remaining allowance charges eight times visited value sizes and text/byte lengths; node and depth accounting follows Serde traversal, including seeds and enum wrappers. It is deliberately conservative, not an allocator/peak-memory measurement. Unlike finite Wire schema admission, it follows actual values. Different borrowed/owned representations may require different traversal allowances. Native decoding suppresses collection size hints and admits elements before their visitors can allocate. Canonical checking streams serialization against the input without allocating a second output buffer.

Custom Serde code is trusted: work and allocations outside these visited boundaries must be bounded by its owner. Standard/custom Deserialize allocations may abort on allocator exhaustion; the adapter does not make arbitrary Serde allocation fallible. The codec's own output reserve is fallible. Resource refusals are Error::Limit; invalid payloads, trailing bytes, identity and noncanonical encodings retain typed errors. Caller input custody is excluded. Native depth allowances must fit the consumer's stack budget. No schema-independent performance claim is made.

The traversal adapters are adapted from IcyDB's native admission implementation; its MIT notice is retained in src/native/LICENSE-MIT. IcyDB's numeric policies, SQL semantics, endpoint roster and storage encodings are not imported.

Concrete native TypeScript metadata

typescript::RUNTIME accepts trusted concrete metadata through createCodec(method, types, checks), including named recursive ref nodes, tuple items and transparent newtype wrappers. Consumers supply byte, node and depth allowances for both directions. Dynamic lengths are checked before copying or descending. Missing/cyclic aliases and sparse arrays reject. Concrete nodes and named references can name trusted scalar checks that run before encoding and after decoding; every check along an alias chain applies.

Metadata extraction, domain validation and endpoint identities remain consumer-owned. Host generators can pass that normalized registry and method list to typescript::generate_registry(&types, &methods, "./native-runtime.js"). It emits named TypeScript types and typed query/update codec objects, using the same type renderer as generate::<Method>(). The selected module must export createCodec; a consumer adapter can supply its scalar validators to the shared runtime. Reflection and domain-specific constraints stay with the consumer. Generation checks names, references, supported type shapes and bounded metadata traversal; it preserves supplied wire metadata and never infers native limits. Registry names such as __proto__ retain their ordinary own-property meaning. The finite Rust generator still rejects Schema::Opaque; it does not infer native metadata or JavaScript heap bounds. The shared runtime compiles with strict TypeScript, noUncheckedIndexedAccess and exactOptionalPropertyTypes.