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:
- Create a
my-service/directory with a git repo - Add the as-lan SDK as a git submodule
- Download template files from the fibonacci example and patch paths
- 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:
- Compiles AssemblyScript to WASM (debug + release targets)
- Converts the release WASM to a JAM PVM binary (
.pvm) usingwasm-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:
-
Add the SDK as a git submodule:
git submodule add https://github.com/tomusdrw/as-lan.git sdk -
Add dependencies to
package.json:{ "devDependencies": { "@fluffylabs/as-lan": "file:./sdk", "assemblyscript": "^0.28.9", "ecalli": "file:./sdk/sdk-ecalli-mocks" } } -
Build the ecalli mocks before first use:
cd sdk/sdk-ecalli-mocks && npm install && npm run build && cd ../.. -
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_authorizedservice 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
xtpreimagesblock extrinsic — accumulate never callsprovide. 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. ReturnsResultN<Machine, InvalidEntryPoint>.machine.peek(source, dest)— Read from inner machine memory. ReturnsResultN<bool, OutOfBounds>.machine.poke(dest, data)— Write to inner machine memory. ReturnsResultN<bool, OutOfBounds>.machine.pages(startPage, pageCount, access)— Set page access permissions. Panics on invalid state.machine.invoke(io)— Run the machine. ReturnsInvokeOutcomewith.reason,.r8,.io.machine.expunge()— Destroy the machine. Returnsi64result.
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 exceedingSPI_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 returnsResultN<NestedPvm, SpiError>instead of panicking. Use this for blobs loaded from an untrusted source (preimage, peer).SpiErrorcoversMalformedBlob,TrailingBytes,ArgsTooLarge,InvalidEntryPoint.vm.invoke()— Run the inner PVM. Returns anExitReason. Updates gas and registers in place.vm.getExitArg()— Most recentr8— host-call index onHost, fault address onFault, 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. ReturnsResultN<bool, OutOfBounds>.vm.expunge()— Destroy the inner machine. Returns the host expunge value (i64).
SPI memory layout constants
Exposed for reference; rarely needed directly.
| Constant | Value | Notes |
|---|---|---|
SPI_PAGE_SIZE | 2^12 | Z_P — 4 KiB |
SPI_SEGMENT_SIZE | 2^16 | Z_Z — 64 KiB |
SPI_MAX_ARGS_LEN | 2^24 | Z_I — 16 MiB |
SPI_RO_START | 0x0001_0000 | Start of read-only data |
SPI_STACK_SEGMENT_END | 0xFEFE_0000 | Top of stack region |
SPI_ARGS_SEGMENT_START | 0xFEFF_0000 | Start 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:
| Kind | Fields | Meaning |
|---|---|---|
Requested | — | Solicited but not yet available |
Available | slot0 | Currently available (added at slot0) |
Unavailable | slot0, slot1 | Was available, now removed |
Reavailable | slot0, slot1, slot2 | Removed 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:
Loggeracceptsstringmessages, 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, useLogMsginstead (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 aBytesBlobas0x-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 existingUint8Array; 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 rawUint8Array.hex(data)— appendUint8Arrayas0x-prefixed hex.u32(v),.u64(v),.i32(v)— append numbers as decimal ASCII
Terminal methods:
.finish()— copy buffer into a newUint8Arrayand reset.finishBlob()— copy buffer into a newBytesBloband 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 accessBytesBlob— 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)
| ID | Function | Description |
|---|---|---|
| 0 | gas() | Returns remaining gas |
| 1 | fetch(dest_ptr, offset, length, kind, param1, param2) | Fetch context data |
| 2 | lookup(service, hash_ptr, out_ptr, offset, length) | Look up preimage by hash |
| 3 | read(service, key_ptr, key_len, out_ptr, offset, length) | Read from storage |
| 4 | write(key_ptr, key_len, value_ptr, value_len) | Write to storage |
| 5 | info(service, out_ptr, offset, length) | Get service account info |
| 100 | log(level, target_ptr, target_len, msg_ptr, msg_len) | JIP-1 debug log (prefer Logger) |
Refine (available during refinement)
| ID | Function | Description |
|---|---|---|
| 6 | historical_lookup(service, hash_ptr, out_ptr, offset, length) | Historical preimage lookup |
| 7 | export_segment(segment_ptr, segment_len) | Export a data segment |
| 8 | machine(code_ptr, code_len, entrypoint) | Create inner PVM machine |
| 9 | peek(machine_id, dest_ptr, source, length) | Read inner machine memory |
| 10 | poke(machine_id, source_ptr, dest, length) | Write inner machine memory |
| 11 | pages(machine_id, start_page, page_count, access_type) | Set inner machine page access |
| 12 | invoke(machine_id, io_ptr, out_r8) | Run inner machine (r7=exit reason, r8 written to out_r8) |
| 13 | expunge(machine_id) | Destroy inner machine |
Accumulate (available during accumulation)
| ID | Function | Description |
|---|---|---|
| 14 | bless(manager, auth_queue_ptr, delegator, registrar, auto_accum_ptr, count) | Set privileged config |
| 15 | assign(core, auth_queue_ptr, assigners) | Assign core |
| 16 | designate(validators_ptr) | Set next epoch validators |
| 17 | checkpoint() | Commit state, return remaining gas |
| 18 | new_service(code_hash_ptr, code_len, gas, allowance, gratis_storage, id) | Create service |
| 19 | upgrade(code_hash_ptr, gas, allowance) | Upgrade service code |
| 20 | transfer(dest, amount, gas_fee, memo_ptr) | Transfer funds |
| 21 | eject(service, prev_code_hash_ptr) | Remove service |
| 22 | query(hash_ptr, length, out_r8) | Query preimage status (r8 written to out_r8) |
| 23 | solicit(hash_ptr, length) | Request preimage availability |
| 24 | forget(hash_ptr, length) | Cancel preimage solicitation |
| 25 | yield_result(hash_ptr) | Provide accumulation result hash |
| 26 | provide(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):
| Ecalli | Default |
|---|---|
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):
| Ecalli | Default |
|---|---|
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):
| Ecalli | Default |
|---|---|
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 aUint8Array. - 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 soTestEcalli.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/BytesBloband pass.ptr()+.lengthto the@externalbinding. - Place where it conceptually belongs. If the helper configures the
lookup()mock, put it onTestLookup, notTestPreimages— 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.