Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

as-lan

AssemblyScript SDK for building JAM services.

Overview

as-lan provides everything you need to write JAM services in AssemblyScript:

  • A service framework that handles host ABI glue so you only write business logic
  • Type-safe wrappers for JAM primitives (slots, service IDs, code hashes, …)
  • A scaffold script that sets up a ready-to-build project in seconds

Next Steps

  • Quick Start — scaffold a new service in one command
  • SDK API — types, service framework, and utilities reference
  • Testing — test framework and configurable ecalli mocks

Quick Start

Scaffold a New Service

Create a new JAM service project with a single command:

curl -sL https://todr.me/as-lan/start.sh | bash -s my-service
cd my-service
npm run build

This will:

  1. Create a my-service/ directory with a git repo
  2. Add the as-lan SDK as a git submodule
  3. Download template files from the fibonacci example and patch paths
  4. Run npm install

What You Get

my-service/
├── assembly/
│   ├── index.ts          # Entry point — re-exports refine & accumulate
│   ├── fibonacci.ts      # Example service logic (fibonacci computation)
│   ├── index.test.ts     # Unit tests
│   ├── test-run.ts       # Test runner entry point
│   └── tsconfig.json     # AssemblyScript path mappings
├── bin/
│   └── test.js           # Test runner (node)
├── sdk/                  # as-lan SDK (git submodule)
│   ├── sdk-ecalli-mocks/ # Configurable ecalli stubs for testing
│   └── pvm-adapter.wat   # Adapter mapping WASM imports to PVM ecalli host calls
├── asconfig.json
└── package.json

The ecalli host call stubs used for testing live in sdk/sdk-ecalli-mocks/ and are shared across all services. There is no per-service ecalli/ directory — just a dependency in package.json:

"ecalli": "file:./sdk/sdk-ecalli-mocks"

Implement Your Service

Edit assembly/fibonacci.ts (rename it to match your service). You need to implement refine and accumulate functions (or is_authorized for an authorizer service — see the authorizer example and the SDK API entry point pattern).

Each function takes (ptr: u32, len: u32) raw memory arguments and returns a packed u64 result. Create a context (AccumulateContext / RefineContext) inside the entry point to parse args and build the response. ctx.parseArgs() panics if the host hands back malformed data — that is a host-contract violation, not a recoverable error, so the entry point does not need to handle it.

import { AccumulateContext, RefineContext, LogMsg } from "@fluffylabs/as-lan";

// LogMsg is a lightweight buffer-based logger that avoids pulling in
// AssemblyScript's String machinery. You can also use `Logger.create("my-service")`
// with template literals for convenience (at a ~24% WASM size cost).
const logger: LogMsg = LogMsg.create("my-service");

export function accumulate(ptr: u32, len: u32): u64 {
  const ctx = AccumulateContext.create();
  const args = ctx.parseArgs(ptr, len);
  logger.str("accumulate, service ").u32(args.serviceId).str(" @").u32(args.slot).info();
  // TODO: implement your accumulate logic here.
  // Return an Optional<CodeHash> (null = no upgrade) via ctx.yieldHash.
  return ctx.yieldHash(null);
}

export function refine(ptr: u32, len: u32): u64 {
  const ctx = RefineContext.create();
  const args = ctx.parseArgs(ptr, len);
  logger.str("refine, service ").u32(args.serviceId).info();
  // TODO: implement your refine logic here — for now, echo payload back.
  return args.payload.toPtrAndLen();
}

Build & Test

You need wasm-pvm installed (cargo install wasm-pvm-cli@0.8.0) to produce PVM binaries.

npm run build          # compile WASM (debug + release) and PVM binary
npm test               # compile test target and run tests

The build pipeline:

  1. Compiles AssemblyScript to WASM (debug + release targets)
  2. Converts the release WASM to a JAM PVM binary (.pvm) using wasm-pvm

The resulting build/release.pvm is the JAM SPI binary ready for deployment.

See the Testing guide for details on writing tests and configuring ecalli mocks.

Manual Setup (without the script)

If you prefer to set things up yourself:

  1. Add the SDK as a git submodule:

    git submodule add https://github.com/tomusdrw/as-lan.git sdk
    
  2. Add dependencies to package.json:

    {
      "devDependencies": {
        "@fluffylabs/as-lan": "file:./sdk",
        "assemblyscript": "^0.28.9",
        "ecalli": "file:./sdk/sdk-ecalli-mocks"
      }
    }
    
  3. Build the ecalli mocks before first use:

    cd sdk/sdk-ecalli-mocks && npm install && npm run build && cd ../..
    
  4. Follow the patterns in the scaffolded project for assembly/index.ts, asconfig.json, etc.

See the SDK API reference for the full list of available types and utilities.

SDK API

The SDK API is organized by invocation context — each page documents the wrappers available during that entry point.

  • Common — available in all contexts (ServiceData, Preimages)
  • Refine — RefineContext, RefineFetcher, RefinePreimages, Machine
  • Accumulate — AccumulateContext, AccumulateFetcher, AccumulatePreimages
  • Authorize — AuthorizeContext, AuthorizeFetcher
  • Utilities — Logger, LogMsg, ByteBuf, Decoder, Byte Types
  • Host Calls (ecalli) — raw host call reference table

Example services

Each example in examples/ is a self-contained service that exercises a particular slice of the SDK. Browse the source for end-to-end patterns.

  • fibonacci — minimal refine + accumulate, starter template.
  • authorizer — standalone is_authorized service with gating logic.
  • ecalli-test — dispatches every ecalli host call for smoke-testing the SDK surface.
  • all-ecalli — self-authorizing service that invokes every ecalli across refine, accumulate, and authorize entry points.
  • pastebin — open-submission paste service. Refine hashes the payload with Blake2b-256; accumulate solicits the preimage and records metadata in a storage-backed ring buffer plus a slot-bucketed TTL index. Preimage bytes arrive via the xtpreimages block extrinsic — accumulate never calls provide. Good reference for the solicit-only preimage lifecycle and slot-bucket cleanup patterns.
  • library — hosts reusable PVM verification blobs as SPI-encoded preimages keyed by name, resolved and executed via ctx.nestedPvmFromSpiChecked(...).
  • nested-pvm-spi — minimal smoke test for the inner PVM: loads an embedded SPI blob and runs it through ctx.nestedPvmFromSpiChecked.

Common (All Contexts)

These wrappers are available in all invocation contexts (refine, accumulate, authorize).

Gas

All context classes expose remainingGas() which returns the gas remaining after the call (ecalli 0):

const gasLeft = ctx.remainingGas();  // i64

Service Data

High-level wrappers for service storage (read/write) and account info (info).

// Read/write access to the current service (preferred)
const storage = ctx.serviceData();
const info = storage.info();                  // Optional<AccountInfo>
const val = storage.read(key);               // key: BytesBlob → Optional<BytesBlob>
const result = storage.write(key, value);    // key/value: BytesBlob → Result<OptionalN<u64>, WriteError>

// Read-only access to another service by ID
const other = ServiceData.create(42);
const otherInfo = other.info();

Preimages

Wraps the lookup ecalli with buffer management and auto-expansion. Each context provides the appropriate preimage helper via ctx.preimages().

const preimages = ctx.preimages();  // Preimages, RefinePreimages, or AccumulatePreimages
const hash = Bytes32.zero();  // or a real hash
const result = preimages.lookup(hash);  // Optional<BytesBlob>
if (result.isSome) {
  const data = result.val!;
  // use data...
}

// Look up a preimage for a different service:
const other = preimages.lookup(hash, 42);

Context-specific extensions (RefinePreimages, AccumulatePreimages) are documented under their respective context pages.

Cryptography

Pure-AssemblyScript crypto primitives. These compile to the PVM target with no host-call dependencies.

Blake2b-256

RFC 7693 Blake2b, unkeyed, 32-byte output — the JAM preimage hash.

import { blake2b256 } from "@fluffylabs/as-lan";

const digest = blake2b256(payload);  // Uint8Array(32)

The implementation supports a single input up to 2⁶⁴ − 1 bytes (the high 64 bits of the RFC 7693 counter are hardcoded to zero). Chained hashing of arbitrarily long streams is out of scope.

Refine

Wrappers available during the refine entry point.

RefineContext

Parses arguments and provides refine-specific convenience methods. It also serves as the entry point for creating all refine-context helpers via factory methods — prefer ctx.*() over standalone *.create().

import { RefineContext } from "@fluffylabs/as-lan";

export function refine(ptr: u32, len: u32): u64 {
  const ctx = RefineContext.create();
  const args = ctx.parseArgs(ptr, len);
  // args.coreIndex, args.itemIndex, args.serviceId, args.payload, args.workPackageHash

  const gasLeft = ctx.remainingGas();  // i64 — ecalli 0

  const fetcher = ctx.fetcher();       // RefineFetcher
  const preimages = ctx.preimages();   // RefinePreimages
  const storage = ctx.serviceData();   // CurrentServiceData

  return args.payload.toPtrAndLen();
}

ctx.remainingGas() — return the remaining gas (ecalli 0).

ctx.fetcher(bufSize?) — create a RefineFetcher (fetch kinds 0-13).

ctx.preimages(bufSize?) — create a RefinePreimages helper (lookup + historicalLookup).

ctx.serviceData(bufSize?) — create a CurrentServiceData helper for storage read/write.

ctx.machine(code, entrypoint) — create an inner PVM Machine (ecalli 8). Returns ResultN<Machine, InvalidEntryPoint>.

ctx.nestedPvmFromSpi(blob, args, gas) — decode an SPI blob and set up an inner PVM ready to invoke. Returns a NestedPvm. Panics on malformed blob. See the NestedPvm section below.

ctx.nestedPvmFromSpiChecked(blob, args, gas) — same setup, but returns ResultN<NestedPvm, SpiError> instead of panicking. Prefer for preimages or other untrusted input.

ctx.exportSegment(segment) — export a data segment (ecalli 7). Returns the segment index on success, or ExportSegmentError.Full when the limit is reached.

const segment = BytesBlob.wrap(data);
const result = ctx.exportSegment(segment);  // ResultN<u32, ExportSegmentError>
if (result.isOkay) {
  const index = result.okay;  // segment index
}

RefineFetcher

Fetches context data (fetch kinds 0-13): protocol constants, work package, entropy, authorizer trace, extrinsics, imports, and work item payloads.

const fetcher = ctx.fetcher();
const wp = fetcher.workPackage();
const entropy = fetcher.entropy();
const payload = fetcher.workItemPayload(0);  // Optional<BytesBlob>

RefinePreimages

Extends base Preimages with historicalLookup (ecalli 6) for querying historical state during refinement.

const preimages = ctx.preimages();
const current = preimages.lookup(hash);              // Optional<BytesBlob>
const historical = preimages.historicalLookup(hash);  // Optional<BytesBlob>

Machine (Inner PVM)

High-level wrapper for creating and running inner PVM machines (ecalli 8-13).

import { Machine, InvokeIo, ExitReason, PageAccess, BytesBlob } from "@fluffylabs/as-lan";

const code: BytesBlob = /* PVM bytecode */;
const result = Machine.create(code, 0);
if (result.isError) { /* InvalidEntryPoint */ return; }
const machine = result.okay!;

// Set up memory pages and write data
machine.pages(0, 1, PageAccess.ReadWrite);
machine.poke(0, myData);

// Run with host-call loop
const io = InvokeIo.create(1_000_000);
io.setRegister(7, someArg);

let outcome = machine.invoke(io);
while (outcome.reason == ExitReason.Host) {
  // Handle host call (outcome.r8 = host call index)
  outcome.io.setRegister(7, responseValue);
  outcome = machine.invoke(outcome.io);
}

// Read results and clean up
const buf = BytesBlob.zero(32);
machine.peek(0, buf);
const hash = machine.expunge();

Machine API

  • Machine.create(code, entrypoint) — Create inner PVM. Returns ResultN<Machine, InvalidEntryPoint>.
  • machine.peek(source, dest) — Read from inner machine memory. Returns ResultN<bool, OutOfBounds>.
  • machine.poke(dest, data) — Write to inner machine memory. Returns ResultN<bool, OutOfBounds>.
  • machine.pages(startPage, pageCount, access) — Set page access permissions. Panics on invalid state.
  • machine.invoke(io) — Run the machine. Returns InvokeOutcome with .reason, .r8, .io.
  • machine.expunge() — Destroy the machine. Returns i64 result.

InvokeIo

Typed wrapper for the 112-byte gas+registers I/O structure:

  • InvokeIo.create(gas) — Create with initial gas limit, zeroed registers.
  • .gas — Get/set gas (read limit before invoke, remaining after).
  • .getRegister(index) / .setRegister(index, value) — Access registers r0-r12.

ExitReason

Halt (0), Panic (1), Fault (2), Host (3), Oob (4).

PageAccess

Inaccessible (0), Read (1), ReadWrite (2).

Calling convention for library-style inner PVMs

For the common case of invoking a library PVM (an inner blob that takes some input, returns some output, and halts), examples/library/ uses NestedPvm with SPI-encoded preimages:

On entry: NestedPvm.fromSpi(blob, payload, gas) sets up the inner PVM per the SPI memory layout and places the payload in the read-only args region at SPI_ARGS_SEGMENT_START (0xFEFF_0000). r7 / r8 are initialised to the args pointer and length respectively — the same (ptr, len) convention every JAM service entry point receives.

On halt: the inner PVM places its output anywhere in its memory and returns a packed ptrAndLen in r7 — low 32 bits = address, high 32 bits = length, matching the SDK’s ptrAndLen(Uint8Array) helper. The caller unpacks r7, calls vm.peek(outAddr, buf) for outLen bytes, then vm.expunge().

Why this matters: writing a library PVM to a different convention means consumers have to special-case your library. Following this convention lets authors of ed25519, blake2b, and similar verification primitives all be invoked identically.

See examples/library/assembly/refine.ts for the full reference implementation (error handling, malformed-blob handling, peek unwind on failure).

NestedPvm (SPI-backed inner PVM)

NestedPvm is a thin wrapper around the machine / pages / poke / invoke / expunge ecallis. It decodes a Standard Program Interface (SPI) blob, allocates the RO / RW / heap / stack / args regions at their Graypaper offsets, initialises the registers, and returns an instance ready to drive.

import { ExitReason, NestedPvm, RefineContext } from "@fluffylabs/as-lan";

export function refine(ptr: u32, len: u32): u64 {
  const ctx = RefineContext.create();
  const args = ctx.parseArgs(ptr, len);

  const vm = ctx.nestedPvmFromSpi(spiBlob, userArgs, /*gas=*/ 1_000_000);
  for (;;) {
    const reason = vm.invoke();
    if (reason === ExitReason.Halt) break;
    if (reason === ExitReason.Host) {
      const index = vm.getExitArg(); // host-call index
      // ...dispatch, then vm.setRegister(7, result); continue;
    } else if (reason === ExitReason.Fault) {
      panic("page fault at " + vm.getExitArg().toString());
    } else if (reason === ExitReason.Panic) {
      panic("inner PVM trapped");
    } else if (reason === ExitReason.Oob) {
      panic("inner PVM OOB");
    }
  }
  const result = vm.getRegister(7);
  vm.expunge();
  return ctx.respond(0);
}

NestedPvm API

  • NestedPvm.fromSpi(blob, args, gas) — Decode SPI blob, create inner PVM, set up memory + registers. Panics on malformed blob, args exceeding SPI_MAX_ARGS_LEN, or invalid entry point. Use this for trusted blobs (embedded at build time, produced by the outer runtime).
  • NestedPvm.fromSpiChecked(blob, args, gas) — Same setup, but returns ResultN<NestedPvm, SpiError> instead of panicking. Use this for blobs loaded from an untrusted source (preimage, peer). SpiError covers MalformedBlob, TrailingBytes, ArgsTooLarge, InvalidEntryPoint.
  • vm.invoke() — Run the inner PVM. Returns an ExitReason. Updates gas and registers in place.
  • vm.getExitArg() — Most recent r8 — host-call index on Host, fault address on Fault, undefined for the other exit reasons.
  • vm.getRegister(i) / vm.setRegister(i, v) — Read / write r0..r12.
  • vm.remainingGas() / vm.setGas(g) — Read / top up the gas budget between invokes.
  • vm.peek(src, dest) / vm.poke(dest, data) — Read / write inner memory outside an invoke. Returns ResultN<bool, OutOfBounds>.
  • vm.expunge() — Destroy the inner machine. Returns the host expunge value (i64).

SPI memory layout constants

Exposed for reference; rarely needed directly.

ConstantValueNotes
SPI_PAGE_SIZE2^12Z_P — 4 KiB
SPI_SEGMENT_SIZE2^16Z_Z — 64 KiB
SPI_MAX_ARGS_LEN2^24Z_I — 16 MiB
SPI_RO_START0x0001_0000Start of read-only data
SPI_STACK_SEGMENT_END0xFEFE_0000Top of stack region
SPI_ARGS_SEGMENT_START0xFEFF_0000Start of args region

Accumulate

Wrappers available during the accumulate entry point.

AccumulateContext

Parses arguments and provides accumulate-specific convenience methods. It also serves as the entry point for creating all accumulate-context helpers via factory methods — prefer ctx.*() over standalone *.create().

import { AccumulateContext, Bytes32, BytesBlob, Memo } from "@fluffylabs/as-lan";

export function accumulate(ptr: u32, len: u32): u64 {
  const ctx = AccumulateContext.create();
  const args = ctx.parseArgs(ptr, len);
  // args.slot, args.serviceId, args.argsLength

  const gasLeft = ctx.remainingGas();    // i64 — ecalli 0
  const gas = ctx.checkpoint();          // i64 — commit state, return remaining gas

  // ecalli 25 — publish the accumulation result hash (side effect, no return).
  ctx.yieldResult(Bytes32.zero());

  // Create helpers via the context
  const fetcher = ctx.fetcher();         // AccumulateFetcher
  const preimages = ctx.preimages();     // AccumulatePreimages
  const storage = ctx.serviceData();     // CurrentServiceData
  const admin = ctx.admin();             // Admin
  const cs = ctx.childServices();        // ChildServices
  const self = ctx.selfService();        // SelfService

  // Schedule a transfer (executes after accumulation completes)
  const r1 = ctx.scheduleTransfer(42, 1000, 100);  // ResultN<bool, TransferError>

  // Transfer with explicit memo
  const memo = Memo.create(BytesBlob.encodeAscii("hello"));
  const r2 = ctx.scheduleTransfer(42, 1000, 100, memo);

  // Encode the entry-point return value: Optional<CodeHash> (null = no upgrade).
  return ctx.yieldHash(null);
}

ctx.remainingGas() — return the remaining gas (ecalli 0).

ctx.fetcher(bufSize?) — create an AccumulateFetcher (fetch kinds 0-1, 14-15).

ctx.preimages(bufSize?) — create an AccumulatePreimages helper (lookup + lifecycle).

ctx.serviceData(bufSize?) — create a CurrentServiceData helper for storage read/write.

ctx.admin() — create an Admin helper for privileged governance.

ctx.childServices() — create a ChildServices helper for child service lifecycle.

ctx.selfService() — create a SelfService helper for self-management.

AccumulateFetcher

Fetches context data (fetch kinds 0-1, 14-15): protocol constants, entropy, and accumulate items (operands and transfers).

const fetcher = ctx.fetcher();
const items = fetcher.allTransfersAndOperands();
const one = fetcher.oneTransferOrOperand(0);  // Optional<AccumulateItem>

AccumulatePreimages

Extends base Preimages with preimage lifecycle management (ecalli 22-26).

const preimages = ctx.preimages();

// Look up
const data = preimages.lookup(hash);  // Optional<BytesBlob>

// Query status of a solicited preimage
const status = preimages.query(hash, 64);  // Optional<PreimageStatus>
if (status.isSome) {
  const s = status.val!;
  if (s.kind === PreimageStatusKind.Available) {
    // s.slot0 = timeslot when it became available
  }
}

// Solicit a preimage (request it be made available)
const r1 = preimages.solicit(hash, 64);  // ResultN<bool, SolicitError>

// Forget a solicitation
const r2 = preimages.forget(hash, 64);   // ResultN<bool, ForgetError>

// Provide a preimage to a service
const r3 = preimages.provide(BytesBlob.wrap(data));  // ResultN<bool, ProvideError>

PreimageStatus — returned by query(). A tagged value with kind and up to 3 timeslot fields:

KindFieldsMeaning
RequestedSolicited but not yet available
Availableslot0Currently available (added at slot0)
Unavailableslot0, slot1Was available, now removed
Reavailableslot0, slot1, slot2Removed then re-added

Admin (Privileged Governance)

High-level wrappers for ecallis 14-16 (bless, assign, designate). Only callable by privileged services (manager, delegator, registrar, core assigners).

const admin = ctx.admin();

// Full bless — only the manager can set all fields
admin.bless(
  managerServiceId,
  [assigner1, assigner2],       // one ServiceId per core
  delegatorServiceId,
  registrarServiceId,
  [AutoAccumulateEntry.create(100, 5000)],
);  // ResultN<bool, BlessError>

// Partial bless — delegator/registrar can transfer their own role
admin.blessDelegator(newDelegatorId);   // ResultN<bool, BlessError>
admin.blessRegistrar(newRegistrarId);   // ResultN<bool, BlessError>

// Assign auth queue for a core (only that core's assigner)
admin.assign(coreIndex, [codeHash1, codeHash2]);  // ResultN<bool, AssignError>

// Transfer assigner permission to another service
admin.assign(coreIndex, authQueue, newAssignerServiceId);

// Designate next epoch validators
const key = ValidatorKey.create(ed25519, bandersnatch, bls, metadata);
admin.designate([key]);  // ResultN<bool, DesignateError>

Child Services

Create and eject child services (ecallis 18, 21).

const cs = ctx.childServices();

// Create a child service
const result = cs.newChild(codeHash, codeLen, gas, allowance);
// ResultN<ServiceId, NewChildError>
if (result.isOkay) {
  const childId = result.okay;  // the new ServiceId
}

// Eject a child service (it must have called requestEjection first)
cs.ejectChild(childServiceId, prevCodeHash);  // ResultN<bool, EjectChildError>

Self-Service

Upgrade the current service’s code or request ejection (ecalli 19).

const self = ctx.selfService();

// Upgrade to new code (ensure preimage is available first!)
self.upgradeCode(newCodeHash, minGas, allowance);

// Request ejection by a parent service
// WARNING: clear all storage before calling this!
self.requestEjection(parentServiceId);

Authorize

Wrappers available during the is_authorized entry point.

AuthorizeContext

Parses the core index from raw arguments. It also serves as the entry point for creating all authorize-context helpers via factory methods — prefer ctx.*() over standalone *.create().

import { AuthorizeContext } from "@fluffylabs/as-lan";

export function is_authorized(ptr: u32, len: u32): u64 {
  const ctx = AuthorizeContext.create();
  const coreIndex = ctx.parseCoreIndex(ptr, len);  // CoreIndex (u16)

  const gasLeft = ctx.remainingGas();  // i64 — ecalli 0

  const fetcher = ctx.fetcher();       // AuthorizeFetcher
  const preimages = ctx.preimages();   // Preimages (lookup only)
  const storage = ctx.serviceData();   // CurrentServiceData
  // ...
}

ctx.remainingGas() — return the remaining gas (ecalli 0).

ctx.fetcher(bufSize?) — create an AuthorizeFetcher (fetch kinds 0, 7-13).

ctx.preimages(bufSize?) — create a Preimages helper (lookup only — no historical or lifecycle ops).

ctx.serviceData(bufSize?) — create a CurrentServiceData helper for storage read/write.

AuthorizeFetcher

Fetches context data (fetch kinds 0, 7-13): protocol constants, work package, auth config, auth token, and work item payloads.

const fetcher = ctx.fetcher();
const config = fetcher.authConfig();
const token = fetcher.authToken();
const wp = fetcher.workPackage();

Self-authorizing dispatch

A single service can handle both is_authorized and refine by detecting the invocation context from input length: is_authorized receives exactly 2 bytes (the u16 core index per GP Appendix B), refine receives 10+ bytes (a full RefineArgs encoding). The SDK exposes isRefineArgs(len) to centralize that discriminant — import it from @fluffylabs/as-lan and wire up a tiny index.ts dispatch:

export { accumulate } from "./accumulate";

import { isRefineArgs } from "@fluffylabs/as-lan";
import { is_authorized } from "./authorize";
import { refine as refine_ } from "./refine";

export function refine(ptr: u32, len: u32): u64 {
  if (isRefineArgs(len)) return refine_(ptr, len);
  return is_authorized(ptr, len);
}

See examples/all-ecalli/, examples/ecalli-test/, and examples/pastebin/ for the full pattern in context.

Utilities

Logger

JIP-1 structured logger. Methods: fatal, warn, info, debug, trace.

debug and trace are compiled out at optimization level 3 (release builds).

import { Logger } from "@fluffylabs/as-lan";

const logger = Logger.create("my-service");
logger.info("processing work item");
logger.debug(`payload length: ${payload.length}`);

Binary size note: Logger accepts string messages, so using template literals (`value: ${n}`) pulls in AssemblyScript’s string concatenation, UTF-8 encoding, and number-to-string machinery. This can add ~1.3 KiB to the WASM output. If binary size is a concern, use LogMsg instead (see below).

LogMsg (lightweight logger)

A buffer-based logger that writes directly to a fixed-size byte buffer, bypassing AssemblyScript’s String machinery entirely. It uses a builder pattern to append text and numbers, then sends the raw bytes to the host.

Using LogMsg instead of Logger can reduce WASM output by 5KB and PVM output by 8KB for a typical service. Note that for large services the trade-off between code size and readability & debuggability might not be worth it.

import { LogMsg } from "@fluffylabs/as-lan";

const logger = LogMsg.create("my-service");
logger.str("processing item ").u32(itemId).info();
logger.str("result: ").u64(value).str(" bytes").debug();

Builder methods (all return LogMsg for chaining):

  • .str(s) — append an ASCII string
  • .u32(v) — append an unsigned 32-bit number as decimal
  • .u64(v) — append an unsigned 64-bit number as decimal
  • .i32(v) — append a signed 32-bit number as decimal
  • .blob(data) — append a BytesBlob as 0x-prefixed hex (no String allocation)

Terminal methods (send the message and reset the buffer):

  • .fatal(), .warn(), .info(), .debug(), .trace()

debug and trace are compiled out at optimization level 3, same as Logger.

ByteBuf (byte-buffer builder)

A lightweight Uint8Array builder that avoids String allocations. Used internally by LogMsg and useful for constructing binary output (e.g. auth traces) from string fragments and raw byte slices.

import { ByteBuf, ptrAndLen } from "@fluffylabs/as-lan";

const result = ByteBuf.create(64)
  .strAscii("Auth=<")
  .bytes(token.raw)
  .strAscii(">")
  .finish();           // → Uint8Array
return ptrAndLen(result);

Static constructors:

  • ByteBuf.create(capacity) — allocate a new buffer with given capacity (default 256)
  • ByteBuf.wrap(data) — wrap an existing Uint8Array; writes go directly into the array

Builder methods (all return ByteBuf for chaining):

  • .strAscii(s) — append an ASCII string (1 byte per char, no UTF-8 overhead)
  • .strUtf8(s) — append a UTF-8 encoded string
  • .bytes(data) — append raw Uint8Array
  • .hex(data) — append Uint8Array as 0x-prefixed hex
  • .u32(v), .u64(v), .i32(v) — append numbers as decimal ASCII

Terminal methods:

  • .finish() — copy buffer into a new Uint8Array and reset
  • .finishBlob() — copy buffer into a new BytesBlob and reset
  • .reset() — discard contents without producing output

The buffer is heap-allocated at a fixed capacity; writes beyond the capacity are silently truncated.

Binary size tip: Prefer .strAscii() over .strUtf8() for ASCII strings (log targets, storage keys, etc.). .strUtf8() pulls in the full UTF-8 machinery (~520 B WASM / ~1.15 KB PVM). See Coding Guidelines.

Decoder

Binary protocol decoder for reading host-provided data.

import { Decoder } from "@fluffylabs/as-lan";

const decoder = Decoder.fromBlob(data);
const value = decoder.varU64();
const hash = decoder.bytes32();
const blob = decoder.bytesVarLen();

Key methods: u8, u16, u32, u64, varU32, varU64, bytes32, bytesFixLen, bytesVarLen, object, optional, sequenceFixLen, sequenceVarLen, skip, isFinished, isError.

Byte Types

  • Bytes32 — Fixed-size 32-byte array with hex string parsing and .ptr() for raw pointer access
  • BytesBlob — Variable-length byte array wrapper with .toPtrAndLen() for returning results and .ptr() for raw pointer access. Factory methods: BytesBlob.wrap(data), BytesBlob.encodeAscii(str), BytesBlob.encodeUtf8(str), BytesBlob.zero(len), BytesBlob.empty()

Host Calls (ecalli)

Declared host functions available to services. Import from "@fluffylabs/as-lan" or from a specific group ("@fluffylabs/as-lan/ecalli/general", .../refine, .../accumulate).

General (available in all contexts)

IDFunctionDescription
0gas()Returns remaining gas
1fetch(dest_ptr, offset, length, kind, param1, param2)Fetch context data
2lookup(service, hash_ptr, out_ptr, offset, length)Look up preimage by hash
3read(service, key_ptr, key_len, out_ptr, offset, length)Read from storage
4write(key_ptr, key_len, value_ptr, value_len)Write to storage
5info(service, out_ptr, offset, length)Get service account info
100log(level, target_ptr, target_len, msg_ptr, msg_len)JIP-1 debug log (prefer Logger)

Refine (available during refinement)

IDFunctionDescription
6historical_lookup(service, hash_ptr, out_ptr, offset, length)Historical preimage lookup
7export_segment(segment_ptr, segment_len)Export a data segment
8machine(code_ptr, code_len, entrypoint)Create inner PVM machine
9peek(machine_id, dest_ptr, source, length)Read inner machine memory
10poke(machine_id, source_ptr, dest, length)Write inner machine memory
11pages(machine_id, start_page, page_count, access_type)Set inner machine page access
12invoke(machine_id, io_ptr, out_r8)Run inner machine (r7=exit reason, r8 written to out_r8)
13expunge(machine_id)Destroy inner machine

Accumulate (available during accumulation)

IDFunctionDescription
14bless(manager, auth_queue_ptr, delegator, registrar, auto_accum_ptr, count)Set privileged config
15assign(core, auth_queue_ptr, assigners)Assign core
16designate(validators_ptr)Set next epoch validators
17checkpoint()Commit state, return remaining gas
18new_service(code_hash_ptr, code_len, gas, allowance, gratis_storage, id)Create service
19upgrade(code_hash_ptr, gas, allowance)Upgrade service code
20transfer(dest, amount, gas_fee, memo_ptr)Transfer funds
21eject(service, prev_code_hash_ptr)Remove service
22query(hash_ptr, length, out_r8)Query preimage status (r8 written to out_r8)
23solicit(hash_ptr, length)Request preimage availability
24forget(hash_ptr, length)Cancel preimage solicitation
25yield_result(hash_ptr)Provide accumulation result hash
26provide(service, preimage_ptr, preimage_len)Supply solicited preimage

Error Sentinels

All functions return i64. Error sentinels are defined in EcalliResult: NONE (-1), WHAT (-2), OOB (-3), WHO (-4), FULL (-5), CORE (-6), CASH (-7), LOW (-8), HUH (-9).

Testing

The SDK provides a test framework for writing and running AssemblyScript tests against your JAM service, with configurable ecalli host call mocks.

Architecture

Testing involves two layers that work together:

┌─────────────────────────────────────┐
│  AssemblyScript test code (WASM)    │
│                                     │
│  ┌───────────┐  ┌────────────────┐  │
│  │ Your test │  │ SDK test utils │  │
│  │ assertions│  │ TestGas, etc.  │  │
│  └───────────┘  └───────┬────────┘  │
│                         │           │
│         @external("ecalli", ...)    │
└─────────────────────────┼───────────┘
                          │ WASM imports
┌─────────────────────────┼───────────┐
│  sdk-ecalli-mocks (Node.js)         │
│                                     │
│  Stub implementations of ecalli     │
│  host calls + configuration state   │
└─────────────────────────────────────┘
  • sdk-ecalli-mocks/ — A TypeScript (Node.js) package that provides stub implementations of all 27 ecalli host calls (general 0-5 + 100, refine 6-13, accumulate 14-26). These stubs satisfy the WASM imports at test time and hold configurable state (gas value, storage map, preimage data, etc.).

  • sdk/test/test-ecalli/ — AssemblyScript wrapper classes (TestGas, TestFetch, TestLookup, TestStorage, TestEcalli) that bridge to the JS-side stubs via @external("ecalli", ...) WASM imports. These give your AS test code a high-level API for configuring stub behavior.

Writing Tests

Test structure

Tests use the test() helper and Assert class from the SDK:

import { Assert, Test, test } from "@fluffylabs/as-lan/test";

export const TESTS: Test[] = [
  test("my feature works", () => {
    const assert = Assert.create();
    assert.isEqual(1 + 1, 2, "basic math");
    return assert;
  }),
];

Each test function returns an Assert instance. Use assert.isEqual(actual, expected, msg) to add assertions — any failure is recorded and reported after the test completes.

Test runner

Each service needs a test-run.ts entry point that registers test suites:

import { TestSuite, runTestSuites } from "@fluffylabs/as-lan/test";
import * as myTests from "./index.test";

export function runAllTests(): void {
  runTestSuites([TestSuite.create(myTests.TESTS, "my-service.ts")]);
}

And a bin/test.js that boots the WASM and runs:

import { setMemory } from "ecalli";
import { memory, runAllTests } from "../build/test.js";

setMemory(memory);
runAllTests();

Build and run

npm test   # compiles test target and runs bin/test.js

This compiles your test-run entry point to WASM (with the test target from asconfig.json), then executes it in Node.js with the ecalli stubs providing host call implementations.

Configuring Ecalli Mocks

By default the stubs provide sensible test values (e.g. gas() returns 1_000_000, lookup() returns "test-preimage", read()/write() use an in-memory Map). You can override these from within your AS test code.

TestGas

Set the value returned by the gas() ecalli:

import { TestGas } from "@fluffylabs/as-lan/test";

TestGas.set(500);  // gas() will now return 500

TestFetch

Set fixed data returned by the fetch() ecalli (overrides the default kind-dependent pattern):

import { TestFetch } from "@fluffylabs/as-lan/test";

const data = new Uint8Array(4);
data[0] = 0xde; data[1] = 0xad; data[2] = 0xbe; data[3] = 0xef;
TestFetch.setData(data);

TestLookup

Set the preimage returned by the lookup() ecalli:

import { TestLookup } from "@fluffylabs/as-lan/test";

const preimage = new Uint8Array(3);
preimage[0] = 1; preimage[1] = 2; preimage[2] = 3;
TestLookup.setPreimage(preimage);

// Make lookup return NONE (preimage not found)
TestLookup.setNone();

Simulating extrinsic-driven preimage delivery

In production, preimages arrive out-of-band via the xtpreimages block extrinsic and CE 142 gossip — a service that only calls solicit() (never provide()) still sees the preimage become available once the network delivers it. To exercise that path in tests without modeling block inclusion, attach the preimage directly to the lookup() mock:

import { Bytes32, BytesBlob } from "@fluffylabs/as-lan";
import { TestLookup } from "@fluffylabs/as-lan/test";

// After this call, any lookup(hash) ecalli returns `preimage`.
TestLookup.setAttachedPreimage(
  Bytes32.wrapUnchecked(hashBytes),
  BytesBlob.wrap(preimageBytes),
);

// Clear all attached preimages (keeps the single-preimage fallback).
TestLookup.clearAttachedPreimages();

Attached entries take precedence over setPreimage / setNone. Both TestEcalli.reset() and any resetPreimages/resetLookup path clear the attached map, so tests starting with TestEcalli.reset() never see leaked attachments from a prior test.

Good reference: the pastebin example’s "paste → solicit → attach → lookup retrieves blob" test exercises the full flow end-to-end.

TestHistoricalLookup

Set the preimage returned by the historical_lookup() ecalli (refine context):

import { TestHistoricalLookup } from "@fluffylabs/as-lan/test";

TestHistoricalLookup.setPreimage(data);

// Make historical_lookup return NONE
TestHistoricalLookup.setNone();

TestPreimages

Configure accumulate-context preimage ecalli stubs (query, solicit, forget, provide):

import { TestPreimages } from "@fluffylabs/as-lan/test";
import { EcalliResult } from "@fluffylabs/as-lan";

// Configure query to return "Available" with slot0=42:
// r7 = (slot0 << 32) | kind, r8 = (slot2 << 32) | slot1
TestPreimages.setQueryResult(i64((u64(42) << 32) | 1), 0);

// Configure query to return NONE (not solicited)
TestPreimages.setQueryResult(-1);

// Configure solicit to return an error
TestPreimages.setSolicitResult(EcalliResult.HUH);

// Configure forget to return OK
TestPreimages.setForgetResult(0);

// Configure provide to return WHO error
TestPreimages.setProvideResult(EcalliResult.WHO);

TestExportSegment

Override the export_segment() ecalli return value (refine context):

import { TestExportSegment } from "@fluffylabs/as-lan/test";
import { EcalliResult } from "@fluffylabs/as-lan";

// Make export_segment return FULL (segment limit reached)
TestExportSegment.setResult(EcalliResult.FULL);

By default, export_segment() returns an auto-incrementing segment index (0, 1, 2, …). Use TestEcalli.reset() to restore the default behavior.

TestMachine

Configure machine ecalli stub return values (refine context, ecalli 8-13):

import { TestMachine } from "@fluffylabs/as-lan/test";
import { EcalliResult } from "@fluffylabs/as-lan";

// Make machine() return HUH (invalid entrypoint)
TestMachine.setMachineResult(EcalliResult.HUH);

// Make peek() return OOB
TestMachine.setPeekResult(EcalliResult.OOB);

// Make poke() return OOB
TestMachine.setPokeResult(EcalliResult.OOB);

// Make invoke() return Host (3) with host call index 12 in r8
TestMachine.setInvokeResult(3, 12);

// Make expunge() return a specific hash
TestMachine.setExpungeResult(0x42);

By default, machine() returns incrementing IDs, invoke() returns HALT, and all other operations return OK. Use TestEcalli.reset() to restore defaults.

TestStorage

Pre-populate or delete entries in the read()/write() stub storage:

import { BytesBlob } from "@fluffylabs/as-lan";
import { TestStorage } from "@fluffylabs/as-lan/test";

// Pre-populate a key
const key = BytesBlob.encodeAscii("counter");
const value = BytesBlob.wrap(new Uint8Array(8));
TestStorage.set(key, value);

// Delete a key
TestStorage.set(key, null);

TestPrivileged

Configure the return values of privileged governance ecallis (bless, assign, designate):

import { TestPrivileged } from "@fluffylabs/as-lan/test";
import { EcalliResult } from "@fluffylabs/as-lan";

TestPrivileged.setBlessResult(EcalliResult.WHO);
TestPrivileged.setAssignResult(EcalliResult.CORE);
TestPrivileged.setDesignateResult(EcalliResult.HUH);

TestServices

Configure the return values of service lifecycle ecallis (new_service, eject):

import { TestServices } from "@fluffylabs/as-lan/test";
import { EcalliResult } from "@fluffylabs/as-lan";

TestServices.setNewServiceResult(EcalliResult.CASH);
TestServices.setEjectResult(EcalliResult.WHO);

By default, new_service() returns auto-incrementing service IDs (256, 257, …). Setting a result overrides this behavior until reset.

TestTransfer

Configure the return value of the transfer() ecalli:

import { TestTransfer } from "@fluffylabs/as-lan/test";
import { EcalliResult } from "@fluffylabs/as-lan";

TestTransfer.setTransferResult(EcalliResult.CASH);

TestEcalli

Reset all configuration to defaults and clear storage:

import { TestEcalli } from "@fluffylabs/as-lan/test";

TestEcalli.reset();

Default Stub Behavior

General (0-5, 100):

EcalliDefault
gas()Returns 1_000_000
fetch()Writes a 16-byte kind-dependent pattern, returns 16
lookup()Writes "test-preimage" (13 bytes), returns 13
read()Reads from in-memory Map; returns NONE (-1) if key missing
write()Writes to in-memory Map; returns previous value length or NONE
info()Returns a 96-byte structure (code_hash=0xAA..., balance=1000)
log()Prints [LEVEL] target: message to console

Refine (6-13):

EcalliDefault
historical_lookup()Writes "test-historical" (15 bytes), returns 15
export_segment()Returns incrementing segment index (0, 1, 2, …)
machine()Returns incrementing machine ID (0, 1, 2, …)
peek()Returns OK (0)
poke()Returns OK (0)
pages()Returns OK (0)
invoke()Returns HALT (0), writes r8 = 0
expunge()Returns OK (0)

Accumulate (14-26):

EcalliDefault
bless()Returns OK (0)
assign()Returns OK (0)
designate()Returns OK (0)
checkpoint()Returns remaining gas (delegates to gas() mock)
new_service()Returns incrementing service ID (256, 257, …)
upgrade()Returns OK (0)
transfer()Returns OK (0)
eject()Returns OK (0)
query()Returns NONE (-1), writes r8 = 0
solicit()Returns OK (0)
forget()Returns OK (0)
yield_result()Returns OK (0)
provide()Returns OK (0)

See the fibonacci and ecalli-test examples for usage examples.

Authoring new test helpers

Sometimes a test needs to reach past the stub ecalli surface — for example, to simulate a block extrinsic (like TestLookup.setAttachedPreimage), seed state that’s not reachable via any host call, or configure mock behavior that spans multiple ecallis. The pattern used across this repo has four layers.

Layer 1 — JS-side mock state

Add the state and the mutator function to the relevant file under sdk-ecalli-mocks/src/ (grouped by ecalli module: general/, refine/, accumulate/). The function is called from WASM via @external, so it must take integer pointers, not Uint8Arrays:

// sdk-ecalli-mocks/src/general/lookup.ts
import { readBytes, writeToMem } from "../memory.js";

const attached: Map<string, Uint8Array> = new Map();

/**
 * Simulate a preimage arriving via the `xtpreimages` extrinsic.
 */
export function setPreimageAttached(
  hash_ptr: number,
  preimage_ptr: number,
  preimage_len: number,
): void {
  const hashBytes = readBytes(hash_ptr, 32);
  if (hashBytes.length !== 32) throw new Error("setPreimageAttached: hash must be 32 bytes");
  const preimage = readBytes(preimage_ptr, preimage_len);
  attached.set(toHex(hashBytes), preimage);
}

// Hook into the existing reset function so TestEcalli.reset() clears it:
export function resetLookup(): void {
  // ... existing resets ...
  attached.clear();
}

Key rules:

  • Pointers, not Uint8Arrays. WASM imports pass integer offsets into WASM memory. Use readBytes(ptr, len) to materialize a Uint8Array.
  • Validate lengths. Throw on wrong-sized inputs — this is a test helper, loud failures are a feature.
  • Hook into reset. Extend the module’s resetXxx() function so TestEcalli.reset() clears the new state automatically.

Layer 2 — JS barrel re-exports

Expose the function at the package root so WASM imports can find it by name. Add it to BOTH the sub-barrel (general/index.ts, accumulate/index.ts, or refine/index.ts) AND the top-level src/index.ts:

// sdk-ecalli-mocks/src/general/index.ts
export {
  lookup, setLookupPreimage, setLookupNone, resetLookup,
  setPreimageAttached, clearPreimageAttachments,
} from "./lookup.js";

// sdk-ecalli-mocks/src/index.ts
export {
  lookup, setLookupPreimage, setLookupNone,
  setPreimageAttached, clearPreimageAttachments,
} from "./general/index.js";

The top-level re-export is what satisfies the WASM imports — the name must match exactly what you declare as @external("ecalli", "<name>") on the AS side.

Layer 3 — AS-side wrapper

Add an @external declaration and a static wrapper class in sdk/test/test-ecalli/ (usually alongside the stub of the ecalli it augments — lookup.ts for lookup-related helpers, preimages.ts for accumulate preimage stubs, etc.):

// sdk/test/test-ecalli/lookup.ts
import { Bytes32, BytesBlob } from "../../core/bytes";

// @ts-expect-error: decorator
@external("ecalli", "setPreimageAttached")
declare function _setPreimageAttached(
  hash_ptr: u32,
  preimage_ptr: u32,
  preimage_len: u32,
): void;

export class TestLookup {
  /**
   * Simulate a preimage arriving via the `xtpreimages` block extrinsic.
   */
  static setAttachedPreimage(hash: Bytes32, preimage: BytesBlob): void {
    _setPreimageAttached(hash.ptr(), preimage.ptr(), preimage.length);
  }
}

Key rules:

  • Static class + static methods. Matches the style of every other Test* helper (TestGas, TestFetch, TestStorage, …).
  • Ergonomic AS types in, pointers to WASM out. Accept Bytes32 / BytesBlob and pass .ptr() + .length to the @external binding.
  • Place where it conceptually belongs. If the helper configures the lookup() mock, put it on TestLookup, not TestPreimages — even if the underlying JS state lives elsewhere.

Layer 4 — documentation

Document the new helper in the relevant Test* subsection of this file above. If it captures a production-only mechanism (extrinsic delivery, gossip, etc.), explain the mechanism in a short paragraph so future readers understand what path the mock is emulating.

End-to-end example

The TestLookup.setAttachedPreimage helper exercises this whole pattern; search git log --all -- sdk-ecalli-mocks/src/general/lookup.ts for the commit that introduced it as a minimal reference.