Skip to content

L5: JavaScript Smart Contract Runtime

Build a JavaScript smart contract runtime using QuickJS compiled to WebAssembly.

What You'll Build

A JavaScript execution device with these endpoints:

GET  /~aojs@1.0/info        Device metadata
POST /~aojs@1.0/init        Initialize the runtime
POST /~aojs@1.0/compute     Execute a handler for a message
POST /~aojs@1.0/snapshot    Save runtime state
POST /~aojs@1.0/normalize   Restore runtime state

What You'll Learn

ConceptPurpose
dev_stackCompose multiple devices
dev_wasmWebAssembly execution
hb_beamrWASM runtime interface
hb_privatePrefixed state management
Process integrationFull scheduler workflow

Prerequisites


Part 0: Building the QuickJS WASM Runtime

Before implementing the device, we need to compile QuickJS to WebAssembly. This creates the JavaScript engine that runs inside HyperBEAM.

Required Tools

Install the WASI SDK (WebAssembly System Interface):

cd ~
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-24/wasi-sdk-24.0-x86_64-linux.tar.gz
tar -xzf wasi-sdk-24.0-x86_64-linux.tar.gz

Download QuickJS source:

cd ~
curl -LO https://bellard.org/quickjs/quickjs-2024-01-13.tar.xz
tar -xJf quickjs-2024-01-13.tar.xz

Create Build Directory

Create the aojs directory in your HyperBEAM folder:

mkdir -p ~/HyperBEAM/aojs
cd ~/HyperBEAM/aojs

WASI Stub Header

Create wasi_stubs.h to provide missing WASI functions:

#ifndef WASI_STUBS_H
#define WASI_STUBS_H
#include <stddef.h>
 
// Floating-point environment stubs (WASI doesn't support fenv.h)
#define FE_DOWNWARD 0x400
#define FE_UPWARD 0x800
#define FE_TOWARDZERO 0xC00
static inline int fesetround(int r) { (void)r; return 0; }
static inline int fegetround(void) { return 0; }
 
// Memory introspection stub (not available in WASI)
static inline size_t malloc_usable_size(void *p) { (void)p; return 0; }
 
#endif

AO Runtime JavaScript

Create ao-runtime.js - the JavaScript runtime that provides the AO handler framework:

// AO Runtime for HyperBEAM JavaScript smart contracts
// DETERMINISTIC: All non-deterministic functions are stubbed
 
const _outbox = [];
globalThis.state = {};
globalThis.msg = {};
globalThis.env = {};
globalThis._getOutbox = () => JSON.stringify(_outbox);
globalThis._clearOutbox = () => { _outbox.length = 0; };
 
// Deterministic PRNG (xorshift128+) seeded from message
let _seed = [1, 2];
globalThis._setSeed = (s1, s2) => { _seed = [s1 >>> 0, s2 >>> 0]; };
const _xorshift = () => {
    let s1 = _seed[0], s2 = _seed[1];
    _seed[0] = s2;
    s1 ^= s1 << 23;
    s1 ^= s1 >>> 17;
    s1 ^= s2;
    s1 ^= s2 >>> 26;
    _seed[1] = s1;
    return ((_seed[0] + _seed[1]) >>> 0) / 4294967296;
};
 
// Override Math.random with deterministic PRNG
Math.random = _xorshift;
 
// Override Date to use block timestamp from env
const _OriginalDate = Date;
globalThis.Date = function(...args) {
    if (args.length === 0) {
        // new Date() uses block timestamp
        const ts = (env && env.Timestamp) || 0;
        return new _OriginalDate(ts);
    }
    return new _OriginalDate(...args);
};
globalThis.Date.now = () => (env && env.Timestamp) || 0;
globalThis.Date.parse = _OriginalDate.parse;
globalThis.Date.UTC = _OriginalDate.UTC;
 
globalThis.Handlers = {
    _h: {},
    add: (n, f) => { Handlers._h[n] = f; },
    remove: (n) => { delete Handlers._h[n]; },
    list: () => Object.keys(Handlers._h),
    handle: (m) => {
        const h = Handlers._h[m.Action || m.action] || Handlers._h['default'];
        return h ? h(m) : { error: 'No handler: ' + (m.Action || m.action) };
    }
};
 
globalThis.ao = {
    send: (m) => { if (m && m.Target) _outbox.push(JSON.parse(JSON.stringify(m))); },
    log: () => {}
};
Determinism guarantees:
  • Math.random() → Seeded PRNG (xorshift128+), call _setSeed(s1, s2) with message hash
  • Date.now() → Returns env.Timestamp (block timestamp)
  • new Date() → Uses env.Timestamp when called without args

C Runtime Wrapper

Create ao_runtime.c - the C interface between WASM and QuickJS:

#include "quickjs.h"
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
 
// Pthread stubs (WASI has no threads)
#define STUB(ret, name, args) ret name args { return 0; }
STUB(int, pthread_mutex_init, (pthread_mutex_t *m, const pthread_mutexattr_t *a))
STUB(int, pthread_mutex_destroy, (pthread_mutex_t *m))
STUB(int, pthread_mutex_lock, (pthread_mutex_t *m))
STUB(int, pthread_mutex_unlock, (pthread_mutex_t *m))
STUB(int, pthread_mutex_trylock, (pthread_mutex_t *m))
STUB(int, pthread_cond_init, (pthread_cond_t *c, const pthread_condattr_t *a))
STUB(int, pthread_cond_destroy, (pthread_cond_t *c))
STUB(int, pthread_cond_signal, (pthread_cond_t *c))
STUB(int, pthread_cond_broadcast, (pthread_cond_t *c))
STUB(int, pthread_cond_wait, (pthread_cond_t *c, pthread_mutex_t *m))
STUB(int, pthread_cond_timedwait, (pthread_cond_t *c, pthread_mutex_t *m, const struct timespec *t))
 
// Global QuickJS runtime and context
static JSRuntime *rt;
static JSContext *ctx;
 
// Initialize the QuickJS runtime
int qjs_init(void) {
    if (rt) return 0;  // Already initialized
    if (!(rt = JS_NewRuntime())) return -1;
    JS_SetMemoryLimit(rt, 16*1024*1024);   // 16MB memory limit
    JS_SetMaxStackSize(rt, 256*1024);       // 256KB stack limit
    if (!(ctx = JS_NewContext(rt))) return -2;
    return 0;
}
 
// Evaluate JavaScript code and return the result as a string
int qjs_eval(const char *code, int len, char *out, int out_size) {
    if (!ctx) return -1;
    JSValue r = JS_Eval(ctx, code, len, "<eval>", JS_EVAL_TYPE_GLOBAL);
    if (JS_IsException(r)) {
        JSValue e = JS_GetException(ctx);
        const char *s = JS_ToCString(ctx, e);
        int n = snprintf(out, out_size, "{\"error\":\"%s\"}", s ? s : "?");
        if (s) JS_FreeCString(ctx, s);
        JS_FreeValue(ctx, e);
        JS_FreeValue(ctx, r);
        return n;
    }
    int n = 0;
    if (!JS_IsUndefined(r)) {
        const char *s = JS_ToCString(ctx, r);
        if (s) { n = snprintf(out, out_size, "%s", s); JS_FreeCString(ctx, s); }
    }
    JS_FreeValue(ctx, r);
    return n;
}
 
// Entry point - initialize runtime when WASM starts
int main(void) { return qjs_init(); }

Makefile

Create the Makefile for building:

# AOJS WASM Build
WASI_SDK_VERSION = 24.0
WASI_SDK = $(HOME)/wasi-sdk-$(WASI_SDK_VERSION)-x86_64-linux
SYSROOT = $(WASI_SDK)/share/wasi-sysroot
CC = $(WASI_SDK)/bin/clang
 
QJS_VERSION = 2024-01-13
QJS = $(HOME)/quickjs-$(QJS_VERSION)
 
CFLAGS = \
    --sysroot=$(SYSROOT) \
    --target=wasm32-wasi \
    -O1 \
    -DCONFIG_VERSION=\"$(QJS_VERSION)\" \
    -D_WASI_EMULATED_SIGNAL \
    -D_WASI_EMULATED_PROCESS_CLOCKS \
    -DCONFIG_BIGNUM \
    -include wasi_stubs.h
 
LDFLAGS = \
    --sysroot=$(SYSROOT) \
    --target=wasm32-wasi \
    -Wl,-z,stack-size=1048576 \
    -Wl,--initial-memory=16777216 \
    -Wl,--max-memory=67108864 \
    -Wl,--export=malloc \
    -Wl,--export=free \
    -Wl,--export=qjs_init \
    -Wl,--export=qjs_eval \
    -lwasi-emulated-signal \
    -lwasi-emulated-process-clocks
 
OBJS = ao_runtime.o quickjs.o cutils.o libbf.o libregexp.o libunicode.o
 
all: aojs.wasm
 
aojs.wasm: $(OBJS)
	$(CC) $(LDFLAGS) $^ -o $@
 
ao_runtime.o: ao_runtime.c
	$(CC) $(CFLAGS) -I$(QJS) -c lt; -o $@
 
quickjs.o: $(QJS)/quickjs.c
	$(CC) $(CFLAGS) -I$(QJS) -c lt; -o $@
 
cutils.o: $(QJS)/cutils.c
	$(CC) $(CFLAGS) -I$(QJS) -c lt; -o $@
 
libbf.o: $(QJS)/libbf.c
	$(CC) $(CFLAGS) -I$(QJS) -c lt; -o $@
 
libregexp.o: $(QJS)/libregexp.c
	$(CC) $(CFLAGS) -I$(QJS) -c lt; -o $@
 
libunicode.o: $(QJS)/libunicode.c
	$(CC) $(CFLAGS) -I$(QJS) -c lt; -o $@
 
clean:
	rm -f *.o aojs.wasm

Build the WASM Binary

cd ~/HyperBEAM/aojs
make

This produces aojs.wasm (~1MB) - a complete JavaScript runtime compiled to WebAssembly.

Create Module Directory

mkdir -p ~/HyperBEAM/aojs/aojs-modules

This directory will hold JavaScript smart contract modules.

Example Modules

Create two example JavaScript modules to test the runtime.

Counter Module

Create ~/HyperBEAM/aojs/aojs-modules/counter.js:

// aojs/aojs-modules/counter.js
state.count = state.count || 0;
 
Handlers.add('Increment', () => {
    state.count += 1;
    return { count: state.count };
});
 
Handlers.add('Decrement', () => {
    state.count -= 1;
    return { count: state.count };
});
 
Handlers.add('GetCount', () => ({ count: state.count }));
 
Handlers.add('Reset', () => {
    state.count = 0;
    return { count: 0 };
});

Token Module

Create ~/HyperBEAM/aojs/aojs-modules/token.js:

// aojs/aojs-modules/token.js
state.balances = state.balances || {};
state.name = state.name || 'TestToken';
state.ticker = state.ticker || 'TST';
state.totalSupply = state.totalSupply || 0;
 
Handlers.add('Info', () => ({
    name: state.name,
    ticker: state.ticker,
    totalSupply: state.totalSupply
}));
 
Handlers.add('Balance', (msg) => {
    const target = (msg.Tags && msg.Tags.Target) || msg.From || 'unknown';
    return { balance: state.balances[target] || 0 };
});
 
Handlers.add('Mint', (msg) => {
    const qty = parseInt((msg.Tags && msg.Tags.Quantity) || '0');
    const recipient = msg.From || 'unknown';
    state.balances[recipient] = (state.balances[recipient] || 0) + qty;
    state.totalSupply += qty;
    return { success: true, balance: state.balances[recipient] };
});
 
Handlers.add('Transfer', (msg) => {
    const from = msg.From || 'unknown';
    const to = msg.Tags && msg.Tags.Recipient;
    const qty = parseInt((msg.Tags && msg.Tags.Quantity) || '0');
 
    if (!to || qty <= 0) {
        return { error: 'Invalid transfer' };
    }
 
    const fromBalance = state.balances[from] || 0;
    if (fromBalance < qty) {
        return { error: 'Insufficient balance' };
    }
 
    state.balances[from] = fromBalance - qty;
    state.balances[to] = (state.balances[to] || 0) + qty;
 
    // Send notification to recipient
    ao.send({
        Target: to,
        Action: 'Credit-Notice',
        Quantity: String(qty),
        Sender: from
    });
 
    return { success: true };
});

These modules demonstrate:

  • Counter: Simple state management with increment/decrement
  • Token: Complex state (balances map), message parsing, and ao.send() for notifications

Part 1: Device Structure

Create HyperBEAM/src/dev_aojs.erl:

%%%-------------------------------------------------------------------
%%% @doc JavaScript Smart Contract Runtime Device
%%%
%%% Executes JavaScript smart contracts using QuickJS compiled to WASM.
%%% This device interfaces with hb_beamr to run JavaScript code in a
%%% sandboxed WebAssembly environment.
%%%
%%% API:
%%%   init/3     - Initialize the runtime
%%%   compute/3  - Execute a handler for a message
%%%   snapshot/3 - Save runtime state
%%%   normalize/3 - Restore runtime state
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(dev_aojs).
-export([info/3, init/3, compute/3, snapshot/3, normalize/3]).
-export([load_js_module/2]).
 
-include("include/hb.hrl").
 
-define(RESULT_BUF_SIZE, 65536).

Part 2: Device Info

%%% ============================================================
%%% Device Info
%%% ============================================================
 
info(_M1, _M2, _Opts) ->
    {ok, #{
        <<"name">> => <<"aojs@1.0">>,
        <<"description">> => <<"JavaScript Smart Contract Runtime">>,
        <<"exports">> => [<<"init">>, <<"compute">>, <<"snapshot">>, <<"normalize">>]
    }}.

Part 3: Device Stack Composition

This device sits on top of dev_wasm in a device stack. The stack provides the WASM instance.

Understanding Device Stacks

┌─────────────────────┐
│    dev_aojs         │  ← JavaScript logic
├─────────────────────┤
│    dev_wasm         │  ← WASM execution
└─────────────────────┘

Prefixed Keys

When devices stack, each layer gets a prefix to avoid key collisions:

prefixed_key(<<>>, Key) -> Key;
prefixed_key(Prefix, Key) -> <<Prefix/binary, "/", Key/binary>>.

Initialization

%%% ============================================================
%%% Initialize
%%% ============================================================
 
init(M1, _M2, Opts) ->
    Prefix = dev_stack:prefix(M1, #{}, Opts),
 
    %% Check if already initialized
    case hb_private:get(prefixed_key(Prefix, <<"initialized">>), M1, not_found, Opts) of
        true ->
            {ok, M1};
        _ ->
            do_init(M1, Prefix, Opts)
    end.
 
do_init(M1, Prefix, Opts) ->
    %% Get WASM instance (created by dev_wasm below us in the stack)
    Instance = hb_private:get(prefixed_key(Prefix, <<"instance">>), M1, not_found, Opts),
 
    case Instance of
        not_found ->
            {error, #{<<"error">> => <<"wasm_instance_not_found">>}};
        _ ->
            {ok, hb_private:set(M1, prefixed_key(Prefix, <<"ready">>), true, Opts)}
    end.

Part 4: Loading JavaScript Modules

The runtime can load JavaScript modules from the message or from files.

%%% ============================================================
%%% Load JavaScript Module
%%% ============================================================
 
load_js_module(M1, Opts) ->
    Prefix = dev_stack:prefix(M1, #{}, Opts),
    Instance = hb_private:get(prefixed_key(Prefix, <<"instance">>), M1, not_found, Opts),
 
    %% Inject AO runtime if needed
    case needs_runtime_injection(M1, Instance, Opts) of
        true ->
            inject_ao_runtime(Instance, Opts);
        false ->
            ok
    end,
 
    %% Get and load the JavaScript module source
    case find_module_source(M1, Opts) of
        not_found ->
            {ok, M1};
        Source when is_binary(Source) ->
            case eval_js(Instance, Source, Opts) of
                {ok, _} -> {ok, M1};
                {error, Error} -> {error, #{<<"error">> => Error}}
            end
    end.
 
needs_runtime_injection(_M1, Instance, Opts) ->
    case eval_js(Instance, <<"typeof Handlers">>, Opts) of
        {ok, <<"object">>} -> false;
        _ -> true
    end.
 
inject_ao_runtime(Instance, Opts) ->
    Runtime = ao_runtime_js(),
    eval_js(Instance, Runtime, Opts).
 
ao_runtime_js() ->
    %% Read from aojs/ao-runtime.js
    {ok, Content} = file:read_file("aojs/ao-runtime.js"),
    Content.
 
find_module_source(M1, Opts) ->
    %% Check for module in message or load from file
    case hb_maps:get(<<"aojs-module">>, M1, not_found, Opts) of
        not_found ->
            case hb_maps:get(<<"module-id">>, M1, not_found, Opts) of
                not_found -> not_found;
                ModuleId ->
                    Path = <<"aojs/aojs-modules/", ModuleId/binary, ".js">>,
                    case file:read_file(Path) of
                        {ok, Content} -> Content;
                        _ -> not_found
                    end
            end;
        Source ->
            Source
    end.

Part 5: Computing - Message Handling

The compute/3 function executes JavaScript handlers for incoming messages.

%%% ============================================================
%%% Compute - Handle Messages
%%% ============================================================
 
compute(M1, M2, Opts) ->
    %% Ensure module is loaded
    {ok, M1Loaded} = load_js_module(M1, Opts),
    execute_handler(M1Loaded, M2, Opts).
 
execute_handler(M1, M2, Opts) ->
    Prefix = dev_stack:prefix(M1, M2, Opts),
    Instance = hb_private:get(prefixed_key(Prefix, <<"instance">>), M1, not_found, Opts),
 
    %% Get message and process info
    Message = hb_maps:get(<<"body">>, M2, #{}, Opts),
    Process = hb_maps:get(<<"process">>, M1, #{}, Opts),
 
    %% Build JSON for JavaScript
    MsgJson = build_msg_json(Message, M2, Opts),
    EnvJson = build_env_json(Process, Opts),
 
    %% Execute handler
    JsCode = iolist_to_binary([
        <<"_clearOutbox();globalThis.msg=">>, MsgJson,
        <<";globalThis.env=">>, EnvJson,
        <<";JSON.stringify(Handlers.handle(msg))">>
    ]),
 
    case eval_js(Instance, JsCode, Opts) of
        {ok, ResultJson} ->
            OutboxJson = read_outbox(Instance, Opts),
            process_results(M1, ResultJson, OutboxJson, Opts);
        {error, Error} ->
            {error, #{<<"error">> => Error}}
    end.

Building Message JSON

build_msg_json(Message, M2, Opts) ->
    Id = hb_maps:get(<<"id">>, Message, <<>>, Opts),
    From = hb_maps:get(<<"from-process">>, Message, <<>>, Opts),
    Action = hb_maps:get(<<"action">>, Message, <<"default">>, Opts),
    Data = hb_maps:get(<<"data">>, Message, <<>>, Opts),
    BlockHeight = hb_maps:get(<<"block-height">>, M2, 0, Opts),
 
    MsgMap = #{
        <<"Id">> => Id,
        <<"From">> => From,
        <<"Action">> => Action,
        <<"Data">> => Data,
        <<"Tags">> => build_tags(Message, Opts),
        <<"Block-Height">> => BlockHeight
    },
    hb_json:encode(MsgMap).
 
build_env_json(Process, Opts) ->
    ProcId = hb_maps:get(<<"id">>, Process, <<>>, Opts),
    EnvMap = #{
        <<"Process">> => #{<<"Id">> => ProcId}
    },
    hb_json:encode(EnvMap).
 
build_tags(Message, _Opts) when is_map(Message) ->
    ExcludeKeys = [<<"id">>, <<"from">>, <<"action">>, <<"data">>, <<"priv">>],
    maps:fold(fun(Key, Value, Acc) ->
        case lists:member(hb_ao:normalize_key(Key), ExcludeKeys) of
            true -> Acc;
            false when is_binary(Value); is_integer(Value) ->
                Acc#{Key => Value};
            false -> Acc
        end
    end, #{}, Message);
build_tags(_, _) -> #{}.

Part 6: WASM Interface

The eval_js/3 function interfaces with the QuickJS WASM module.

%%% ============================================================
%%% JavaScript Evaluation (WASM Interface)
%%% ============================================================
 
eval_js(Instance, Code, _Opts) ->
    %% Write code to WASM memory
    case hb_beamr_io:write_string(Instance, Code) of
        {ok, CodePtr} ->
            CodeLen = byte_size(Code),
            %% Allocate result buffer
            case hb_beamr_io:malloc(Instance, ?RESULT_BUF_SIZE) of
                {ok, ResultPtr} ->
                    %% Call qjs_eval(code, len, out, out_size)
                    Result = hb_beamr:call(Instance, "qjs_eval",
                        [CodePtr, CodeLen, ResultPtr, ?RESULT_BUF_SIZE]),
 
                    EvalResult = case Result of
                        {ok, [Length]} when Length >= 0 ->
                            {ok, ResultBin} = hb_beamr_io:read(Instance, ResultPtr, max(1, Length)),
                            {ok, ResultBin};
                        {ok, [Length]} when Length < 0 ->
                            {ok, ErrorBin} = hb_beamr_io:read_string(Instance, ResultPtr),
                            {error, ErrorBin};
                        {error, E} ->
                            {error, E}
                    end,
 
                    %% Free memory
                    hb_beamr_io:free(Instance, CodePtr),
                    hb_beamr_io:free(Instance, ResultPtr),
                    EvalResult;
                {error, E} ->
                    hb_beamr_io:free(Instance, CodePtr),
                    {error, E}
            end;
        {error, E} ->
            {error, E}
    end.
 
read_outbox(Instance, Opts) ->
    case eval_js(Instance, <<"_getOutbox()">>, Opts) of
        {ok, OutboxJson} -> OutboxJson;
        _ -> <<"[]">>
    end.
 
process_results(M1, ResultJson, OutboxJson, _Opts) ->
    Result = hb_json:decode(ResultJson),
    Outbox = hb_json:decode(OutboxJson),
    {ok, M1#{
        <<"results">> => #{
            <<"data">> => Result,
            <<"outbox">> => Outbox
        }
    }}.

Part 7: State Persistence

Snapshot

Save the WASM runtime state:

%%% ============================================================
%%% Snapshot and Normalize (State Persistence)
%%% ============================================================
 
snapshot(M1, _M2, Opts) ->
    Prefix = dev_stack:prefix(M1, #{}, Opts),
    Instance = hb_private:get(prefixed_key(Prefix, <<"instance">>), M1, not_found, Opts),
    case Instance of
        not_found ->
            {error, <<"no_wasm_instance">>};
        _ ->
            case hb_beamr:serialize(Instance) of
                {ok, Snapshot} -> {ok, M1#{<<"snapshot">> => Snapshot}};
                {error, E} -> {error, E}
            end
    end.

Normalize

Restore the WASM runtime state:

normalize(M1, _M2, Opts) ->
    case hb_maps:get(<<"snapshot">>, M1, not_found, Opts) of
        not_found ->
            {ok, M1};
        Snapshot ->
            Prefix = dev_stack:prefix(M1, #{}, Opts),
            Instance = hb_private:get(prefixed_key(Prefix, <<"instance">>), M1, not_found, Opts),
            case hb_beamr:deserialize(Instance, Snapshot) of
                ok -> {ok, maps:remove(<<"snapshot">>, M1)};
                {error, E} -> {error, E}
            end
    end.

Part 8: Testing

Test Helpers

-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
 
%% Test helper - start environment
start() ->
    application:ensure_all_started(hb),
    <<I1:32, I2:32, I3:32>> = crypto:strong_rand_bytes(12),
    rand:seed(exsplus, {I1, I2, I3}),
    ok.
 
%% Test helper - setup store
setup_test_env() ->
    start(),
    Store = hb_test_utils:test_store(hb_store_fs),
    #{store => [Store]}.

Test Device Info

%% Test device info via hb_ao:resolve with {as, dev_aojs, Msg}
info_test() ->
    start(),
    %% Use {as, dev_aojs, Msg} to resolve without device registration
    {ok, Info} = hb_ao:resolve(
        {as, dev_aojs, #{}},
        #{<<"path">> => <<"info">>},
        #{}
    ),
    ?assertEqual(<<"aojs@1.0">>, maps:get(<<"name">>, Info)),
    ?assertEqual(<<"JavaScript Smart Contract Runtime">>, maps:get(<<"description">>, Info)).

Test WASM Stack Initialization

%% Test WASM-based AOJS device stack initialization
aojs_wasm_init_test_() ->
    {timeout, 60, fun aojs_wasm_init/0}.
 
aojs_wasm_init() ->
    Opts = setup_test_env(),
 
    %% Cache the WASM image
    #{<<"image">> := WASMImageID} = dev_wasm:cache_wasm_image("aojs/aojs.wasm", Opts),
 
    %% Create a device stack: wasm-64 at the bottom
    StackMsg = #{
        <<"device">> => <<"stack@1.0">>,
        <<"device-stack">> => [<<"wasm-64@1.0">>],
        <<"stack-keys">> => [<<"init">>, <<"compute">>],
        <<"image">> => WASMImageID
    },
 
    %% Initialize the stack - this loads the WASM
    {ok, M1} = hb_ao:resolve(StackMsg, #{<<"path">> => <<"init">>}, Opts),
 
    %% Verify we got a valid message back
    ?assertMatch(#{}, M1),
 
    %% Try calling qjs_init via compute
    {ok, InitResult} = hb_ao:resolve(
        M1,
        #{
            <<"path">> => <<"compute">>,
            <<"body">> => #{
                <<"function">> => <<"qjs_init">>,
                <<"parameters">> => []
            }
        },
        Opts
    ),
 
    %% qjs_init returns 0 on success
    Output = hb_ao:get(<<"results/output">>, InitResult, Opts),
    ?assertEqual([0], Output),
 
    ok.

Test Process Creation

%% Helper to create AOJS process with WASM
test_aojs_process(Opts) ->
    Wallet = hb_opts:get(priv_wallet, hb:wallet(), Opts),
    Address = hb_util:human_id(ar_wallet:to_address(Wallet)),
    #{<<"image">> := WASMImageID} = dev_wasm:cache_wasm_image("aojs/aojs.wasm", Opts),
    hb_message:commit(
        #{
            <<"device">> => <<"process@1.0">>,
            <<"scheduler-device">> => <<"scheduler@1.0">>,
            <<"scheduler-location">> => Address,
            <<"type">> => <<"Process">>,
            <<"test-random-seed">> => rand:uniform(1337),
            <<"execution-device">> => <<"stack@1.0">>,
            <<"device-stack">> => [<<"wasm-64@1.0">>],
            <<"stack-keys">> => [<<"init">>, <<"compute">>],
            <<"image">> => WASMImageID,
            <<"scheduler">> => Address,
            <<"authority">> => Address
        },
        Opts#{priv_wallet => Wallet}
    ).
 
%% Test process message creation
process_msg_creation_test_() ->
    {timeout, 60, fun process_msg_creation/0}.
 
process_msg_creation() ->
    start(),
    Opts = #{
        priv_wallet => hb:wallet(),
        store => hb_opts:get(store)
    },
 
    %% Create aojs process message
    Msg1 = test_aojs_process(Opts),
 
    %% Verify message structure
    ?assertEqual(<<"process@1.0">>, maps:get(<<"device">>, Msg1)),
    ?assertEqual(<<"stack@1.0">>, maps:get(<<"execution-device">>, Msg1)),
    ?assertEqual(<<"scheduler@1.0">>, maps:get(<<"scheduler-device">>, Msg1)),
    ?assertEqual(<<"Process">>, maps:get(<<"type">>, Msg1)),
 
    %% Verify WASM image is set
    ?assertMatch(<<_/binary>>, maps:get(<<"image">>, Msg1)),
 
    ok.

Test Scheduler Integration

%% Helper to schedule a test message to a process
schedule_test_message(Msg1, Text, Opts) ->
    schedule_test_message(Msg1, Text, #{}, Opts).
schedule_test_message(Msg1, Text, MsgBase, Opts) ->
    Wallet = hb:wallet(),
    UncommittedBase = hb_message:uncommitted(MsgBase, Opts),
    Msg2 =
        hb_message:commit(#{
                <<"path">> => <<"schedule">>,
                <<"method">> => <<"POST">>,
                <<"body">> =>
                    hb_message:commit(
                        UncommittedBase#{
                            <<"type">> => <<"Message">>,
                            <<"test-label">> => Text
                        },
                        Opts#{ priv_wallet => Wallet }
                    )
            },
            Opts#{ priv_wallet => Wallet }
        ),
    {ok, _} = hb_ao:resolve(Msg1, Msg2, Opts).
 
%% Test full process lifecycle with scheduler
process_scheduler_integration_test_() ->
    {timeout, 60, fun process_scheduler_integration/0}.
 
process_scheduler_integration() ->
    Opts = setup_test_env(),
 
    %% Create AOJS process
    Msg1 = test_aojs_process(Opts),
 
    %% Schedule test messages
    schedule_test_message(Msg1, <<"TEST TEXT 1">>, Opts),
    schedule_test_message(Msg1, <<"TEST TEXT 2">>, Opts),
 
    %% Get scheduler status to verify messages are scheduled
    {ok, SchedulerRes} =
        hb_ao:resolve(Msg1, #{
            <<"method">> => <<"GET">>,
            <<"path">> => <<"schedule">>
        }, Opts),
 
    %% Verify test messages are scheduled (slot 0 and 1)
    ?assertMatch(
        <<"TEST TEXT 1">>,
        hb_ao:get(<<"assignments/0/body/test-label">>, SchedulerRes, Opts)
    ),
    ?assertMatch(
        <<"TEST TEXT 2">>,
        hb_ao:get(<<"assignments/1/body/test-label">>, SchedulerRes, Opts)
    ),
 
    ok.

Test JavaScript Modules

These tests verify the counter.js and token.js modules work correctly.

%% Helper to setup WASM stack and get instance for JS testing
setup_js_runtime(Opts) ->
    #{<<"image">> := WASMImageID} = dev_wasm:cache_wasm_image("aojs/aojs.wasm", Opts),
    StackMsg = #{
        <<"device">> => <<"stack@1.0">>,
        <<"device-stack">> => [<<"wasm-64@1.0">>],
        <<"stack-keys">> => [<<"init">>, <<"compute">>],
        <<"image">> => WASMImageID
    },
    {ok, M1} = hb_ao:resolve(StackMsg, #{<<"path">> => <<"init">>}, Opts),
    {ok, _} = hb_ao:resolve(M1, #{
        <<"path">> => <<"compute">>,
        <<"body">> => #{<<"function">> => <<"qjs_init">>, <<"parameters">> => []}
    }, Opts),
    Instance = dev_wasm:instance(M1, #{}, Opts),
    {M1, Instance}.
 
%% Test basic JavaScript evaluation
js_basic_eval_test_() ->
    {timeout, 60, fun js_basic_eval/0}.
 
js_basic_eval() ->
    Opts = setup_test_env(),
    {_M1, Instance} = setup_js_runtime(Opts),
 
    %% Test simple arithmetic
    {ok, Result1} = eval_js(Instance, <<"1 + 2">>, Opts),
    ?assertEqual(<<"3">>, Result1),
 
    %% Test JSON.stringify
    {ok, Result2} = eval_js(Instance, <<"JSON.stringify({a: 1, b: 2})">>, Opts),
    ?assertEqual(<<"{\"a\":1,\"b\":2}">>, Result2),
    ok.
 
%% Test counter.js module
js_counter_module_test_() ->
    {timeout, 60, fun js_counter_module/0}.
 
js_counter_module() ->
    Opts = setup_test_env(),
    {_M1, Instance} = setup_js_runtime(Opts),
 
    %% Initialize state and Handlers
    {ok, _} = eval_js(Instance, <<"var state = {}; var Handlers = {_handlers: [], add: function(name, fn) { this._handlers.push({name: name, fn: fn}); }, call: function(name) { for(var i=0; i<this._handlers.length; i++) { if(this._handlers[i].name === name) return this._handlers[i].fn({}); } } };">>, Opts),
 
    %% Load counter module
    {ok, CounterCode} = file:read_file("aojs/aojs-modules/counter.js"),
    {ok, _} = eval_js(Instance, CounterCode, Opts),
 
    %% Test GetCount (should be 0 initially)
    {ok, Count0} = eval_js(Instance, <<"JSON.stringify(Handlers.call('GetCount'))">>, Opts),
    ?assertEqual(<<"{\"count\":0}">>, Count0),
 
    %% Test Increment twice
    {ok, _} = eval_js(Instance, <<"Handlers.call('Increment')">>, Opts),
    {ok, Count2} = eval_js(Instance, <<"JSON.stringify(Handlers.call('Increment'))">>, Opts),
    ?assertEqual(<<"{\"count\":2}">>, Count2),
 
    %% Test Decrement
    {ok, Count1} = eval_js(Instance, <<"JSON.stringify(Handlers.call('Decrement'))">>, Opts),
    ?assertEqual(<<"{\"count\":1}">>, Count1),
 
    %% Test Reset
    {ok, Count0Again} = eval_js(Instance, <<"JSON.stringify(Handlers.call('Reset'))">>, Opts),
    ?assertEqual(<<"{\"count\":0}">>, Count0Again),
    ok.
 
%% Test token.js module
js_token_module_test_() ->
    {timeout, 60, fun js_token_module/0}.
 
js_token_module() ->
    Opts = setup_test_env(),
    {_M1, Instance} = setup_js_runtime(Opts),
 
    %% Initialize state, Handlers, and ao.send
    {ok, _} = eval_js(Instance, <<"var state = {}; var ao = {send: function(m) { ao._outbox.push(m); }, _outbox: []}; var Handlers = {_handlers: [], add: function(name, fn) { this._handlers.push({name: name, fn: fn}); }, call: function(name, msg) { for(var i=0; i<this._handlers.length; i++) { if(this._handlers[i].name === name) return this._handlers[i].fn(msg || {}); } } };">>, Opts),
 
    %% Load token module
    {ok, TokenCode} = file:read_file("aojs/aojs-modules/token.js"),
    {ok, _} = eval_js(Instance, TokenCode, Opts),
 
    %% Test Info
    {ok, Info} = eval_js(Instance, <<"JSON.stringify(Handlers.call('Info'))">>, Opts),
    ?assertMatch(<<"{\"name\":\"TestToken\"", _/binary>>, Info),
 
    %% Test initial Balance (should be 0)
    {ok, Balance0} = eval_js(Instance, <<"JSON.stringify(Handlers.call('Balance', {From: 'alice'}))">>, Opts),
    ?assertEqual(<<"{\"balance\":0}">>, Balance0),
 
    %% Test Mint
    {ok, MintResult} = eval_js(Instance, <<"JSON.stringify(Handlers.call('Mint', {From: 'alice', Tags: {Quantity: '100'}}))">>, Opts),
    ?assertEqual(<<"{\"success\":true,\"balance\":100}">>, MintResult),
 
    %% Verify balance after mint
    {ok, Balance100} = eval_js(Instance, <<"JSON.stringify(Handlers.call('Balance', {From: 'alice'}))">>, Opts),
    ?assertEqual(<<"{\"balance\":100}">>, Balance100),
    ok.
 
-endif. %% TEST

Run Tests

rebar3 eunit --module=dev_aojs

Part 9: Device Registration

Register the aojs device to use with the ~device@version URL syntax.

Add to sys.config

{hb, [
    {preloaded_devices, [
        %% ... existing devices ...
        #{name => <<"aojs@1.0">>, module => dev_aojs}
    ]}
]}

Or Register at Runtime

hb:init(#{
    preloaded_devices => [
        #{name => <<"aojs@1.0">>, module => dev_aojs}
    ]
}).

Verify Registration

GET  http://localhost:8734/~aojs@1.0/info
POST http://localhost:8734/~aojs@1.0/init
POST http://localhost:8734/~aojs@1.0/compute

Key Concepts

ConceptImplementation
Device stackingdev_stack:prefix/3 for key prefixing
WASM integrationhb_beamr for WASM calls
Memory managementhb_beamr_io:write_string/malloc/free
JS evaluationqjs_eval exported from QuickJS WASM
State serializationhb_beamr:serialize/deserialize

The AO Runtime

The JavaScript runtime provides:

// Global Handlers object for message routing
Handlers = {
    handlers: [],
    add: function(name, pattern, fn) { ... },
    handle: function(msg) { ... }
};
 
// Outbox for sending messages
_outbox = [];
ao.send = function(msg) { _outbox.push(msg); };
_getOutbox = function() { return JSON.stringify(_outbox); };
_clearOutbox = function() { _outbox = []; };

Process Architecture

┌─────────────────────────────────────────────┐
│              process@1.0                    │
│  ┌─────────────────────────────────────────┐│
│  │         scheduler@1.0                   ││
│  │  ┌───────────────────────────────────┐  ││
│  │  │     execution: stack@1.0          │  ││
│  │  │  ┌─────────────────────────────┐  │  ││
│  │  │  │     dev_aojs (optional)     │  │  ││
│  │  │  ├─────────────────────────────┤  │  ││
│  │  │  │      wasm-64@1.0            │  │  ││
│  │  │  │      (QuickJS WASM)         │  │  ││
│  │  │  └─────────────────────────────┘  │  ││
│  │  └───────────────────────────────────┘  ││
│  └─────────────────────────────────────────┘│
└─────────────────────────────────────────────┘

Resources