Skip to content

Run the PRT recipes on Dave's contracts, with durable Lua storage - #411

Draft
diegonehab wants to merge 109 commits into
mainfrom
feature/prt
Draft

diegonehab wants to merge 109 commits into
mainfrom
feature/prt

Conversation

@diegonehab

@diegonehab diegonehab commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

This PR runs the documentation's PRT players against Dave's real contracts on a local chain. It adds the Lua storage APIs a player needs to survive crashes, and two hash-tree cache improvements.

Emulator and Lua library

  • Page hash-tree cache. The cache metadata is now contiguous, and new APIs clear the hash-tree cache, with a matching load option. These are independent of the PRT work.
  • dependencies.lock. The kernel and rootfs download URLs are composed once in the lock file instead of being spelled out in tests/Makefile.
  • cartesi.evmu. encode_abi and decode_abi handle selectorless ABI tuples, as used by event data and return values.
  • cartesi.filesystem. The file helpers the tests used become an installed module. A small C core adds what Lua cannot do itself (checking existence, creating and listing directories, cloning and truncating files, temporary files that clean up after themselves, and syncing files and directories with F_FULLFSYNC on macOS).
  • cartesi.util. read_file and write_file are removed. Every caller now uses cartesi.filesystem, with the same argument order.
  • cartesi.hash-tree. frontier_forest_store and frontier_forest_load keep a frontier forest in a directory, one file per tree named by its hash. Snapshots of a growing forest share their trees. Writes go through synced temporary files, and the frontier file is the commit point, so a crash leaves the previous store intact. frontier_store and frontier_load do the same for a plain frontier, and the CLI uses them.
  • CLI. Its constructors are named new_.

Documentation recipes

  • The recipes and the README use cartesi.filesystem.
  • With HOST=yes, the recipe tests run and the README renders against the local emulator build instead of the docs image. This is what lets stored machines use the host's reflinks.
  • Game deadlines are exclusive, and the PRT referee asks for a timeout win or an elimination only when its window opens. Handlers answer with a payload, "nothing", or "done".
  • The PRT player can chain epochs through checkpoints. Given an epoch directory, it runs on stored machines, with state.json as its only commit point, and reopens after being killed at any point. A uarch bundle is now one mcycle.
  • A new on-chain recipe deploys Dave v3.0.0-alpha.6's contracts on Anvil and plays two epochs of the rolling calculator. One honest player and seven scripted adversaries each run in their own process, through a Lua bridge that turns contract events into player calls and player answers into signed transactions. doc/recipes/prt-lua-bridge.md describes the design and its current limits.
  • The README gains the permissionless refereed tournament section and a note on stored frontier forests.

Testing

Outside doc/

make -C tests test-lua test-cm-cli test-evmu

Recipes (host mode, after eval $(make env))

make -C doc HOST=yes test-prt-protocol test-vg test-prt-game test-prt-repeat

On-chain recipe

make -C doc build-prt-image
make -C doc test-prt-bridge-unit test-prt-transactions test-prt-proofs
make -C doc test-prt-bridge

PRT_TEST_RESTART=yes kills and relaunches the honest player mid-epoch and mid-dispute, and PRT_TEST_REORG=yes reorganizes the chain during a dispute.

@diegonehab diegonehab added this to the v0.23.0 milestone Oct 25, 2026
@diegonehab diegonehab self-assigned this Oct 25, 2026
@diegonehab diegonehab changed the title Feature/prt Run the PRT recipes on Dave's contracts, with durable Lua storage Oct 25, 2026
Separate LRU hooks, page addresses, borrowed flags, and payload indices
from cached pages and hashes within the existing single mapping. Load
validation and map reconstruction now read only compact metadata, and
conditional eviction exchanges payload indices instead of copying data.
Use entry_metadata and entry_payload to name the two parts of each entry.
Align the payload array and every payload to 4 KiB machine pages.

Check capacity arithmetic, exact LRU node boundaries, bounded traversal,
backlinks, active-entry membership, and unique in-range payload indices
before using persisted metadata. Retain Boost intrusive lists and lazy
payload access. Bump the stored-machine archive version to 9 and reject
the previous PHTC layout tag.

Cover malformed metadata, overflowing capacities, file-size mismatches,
eviction without payload writes, and payload alignment across metadata
page boundaries.

Validated with backing-store, hash-tree, machine API, and C API suites,
plus toolchain formatting and lint checks. A local APFS benchmark
with a full 4096-entry cache (25 samples) reduced median fresh-clone loads
from 125.64 to 1.50 ms with SHARING_NONE and 124.77 to 1.92 ms with
SHARING_ALL. Warm loads measured 0.49 and 0.64 ms respectively.
There was no API to discard persisted hash caches and rebuild them from
machine memory. Add clear_hash_tree across the machine APIs and CLI,
resetting cached hashes and marking pages dirty for lazy rebuilding.
Clearing preserves machine state, mappings and statistics. The CLI
clears once by default, with an option to clear on subsequent loads too.
The Linux image and guest tools rootfs URLs were spelled out in
tests/Makefile. Compose both once in the lock file from its tags.
The docs' host mode links the installed command names cartesi-machine
and cartesi-machine-stored-hash to their scripts in the source tree so
the recipes can run from the build tree.
encode_abi and decode_abi handle ABI tuples without a function
selector, as used by event data and return values. Values follow the
same conventions as encode_calldata, and decoded integers retain all
256 bits.
Move the test filesystem helpers into the installed native module
cartesi.filesystem. Implement file operations and temporary-file ownership
in os-filesystem, expose them through cm-filesystem.h and cm-filesystem.cpp,
and keep the Lua binding thin. Use the existing C API argument checks,
function-scope try blocks, error codes, and thread-local diagnostics so
Lua raises errors only after the C++ exception has been handled.

Package libcartesi_filesystem and libluacartesi_filesystem, including
cartesi/filesystem.so, following the JSON-RPC library targets and dependency
layout. Install cm-filesystem.h; no filesystem.lua wrapper is needed.

Preserve binary reads, overwriting writes, replacement renames, and explicit
sync semantics. write_durable_file writes and syncs a temporary file before
renaming it; the caller syncs its directory. Reads do not lock backing files
that may already belong to a running machine. write_file reports close errors.

Temporary-file owners remove their path on close or collection unless renamed.
Their Lua file view keeps the owner alive and updates it when closed, avoiding
double closes. Expose the existing file, path, close, and rename interface.

Directory listings use JSON arrays of Base64 names and the existing Lua
Base64Array schema to preserve filename bytes. write_file takes data before
its filename, matching util.write_file, and callers are updated accordingly.

Cover C API validation and ownership, filename conversion, temporary-file
lifetimes, binary operations, and exception lifetime across Lua pcall.
Move every caller of util.read_file and util.write_file to the same
functions in cartesi.filesystem, and remove them from cartesi.util. The
argument order is unchanged.
A forest can now keep its trees in a directory, one JSON file per tree
named by its hash. A fixed-point tree file holds only its base values
and a mixed tree file its two child hashes, so loading rebuilds each
tree and checks its hash and height, failing on any mismatch.

frontier_forest_store flushes pending values, stores the trees not yet
in the directory, and writes the frontier to a separate file, so
snapshots of a growing forest share their trees. frontier_forest_load
reopens a forest from that file. Queries and expansions load a stored
tree in place of a raw hash when they reach it, and an expansion of a
stored forest also stores its new trees. Forests without a directory
behave as before.

Forest levels and mixed tree children now hold raw hashes directly, as
fixed-point base values already did, instead of wrapping each one in a
single-value fixed-point tree. A value is loaded and expanded in place
the same way wherever it sits, so a store reaches every expansion.

A stored or loaded forest cannot be a value of another forest, because
some of its trees may exist only as files in its own directory, where
the other forest would never find them.

Trees with equal hashes can describe the same subtree differently and
know different parts of it. A store that finds a file holding another
representation merges into the tree what that file knows below the
hash, opening the tree's opaque values with the file's subtrees, and
replaces the file only after the files below it are durable.

Each tree and frontier file is written to a uniquely named temporary
file in its own directory, synced through the open file, and renamed
into place. A store syncs its directory once before writing and syncing
the frontier file, which therefore commits the store: a crash leaves
the previous frontier intact. An expansion's files are synced before
its top file, which makes it reachable, is written. A file left by an
interrupted store may lack its children's files, so the forest records
the trees a completed sync confirmed and those it loaded, and a store
rewrites the missing files of every other tree.

frontier_store and frontier_load do the same for a plain frontier, and
the CLI uses them for its epoch frontier files.
The CLI constructors used make_ while other constructors use new_.
Rename the revert, runner and computation builder constructors and their
callers to follow that convention.
util.read_file and util.write_file are gone from cartesi.util. The
recipes and the README call the same functions in cartesi.filesystem,
with the same argument order.
The docs image supplies the cartesi-machine command, the default kernel
and rootfs images, and GNU tools. With HOST=yes and the environment from
make env, doc/Makefile provides them from the local emulator build and
doc/build instead, so the recipe tests run without Docker and on
filesystems with reflinks. doc/Makefile also adds its Lua paths to
LUA_PATH_5_4, which make env sets and Lua 5.4 prefers over LUA_PATH.

Rendering the README with HOST=yes puts GNU tools first on PATH, prints
timestamps in UTC, and uses a hash of the emulator build, the lock
file, and the rootfs in place of the docs image ID. The README takes
the default images directory from pandoc metadata instead of naming
/usr/share/cartesi-machine/images, and the proof_module include reaches
cartesi.hash-tree through a link to wherever the environment's Lua
finds it. A change of environment regenerates template.d.

The docs image takes the kernel URL from dependencies.lock as a build
argument. Server waits use netstat -ant, which also lists listening
sockets on macOS. replace.lua creates directories through pandoc.system
instead of the shell. The shell tests remove the exact names they
create from their temporary directories and then rmdir them, so a
leftover file fails the test. The fixture script removes the old
template with remove_stored, and the clean rule removes the replace
cache by its literal path.
The PRT and VG constructors are named new_, as in the CLI. What the
machine caches hold are entries, so checkpoint is free for the state
a sealed epoch leaves for the next one.
A wait for block D now settles block D - 1 and returns, so a request
made then is answered at D. wait_until(D) has the same meaning. The
referee's validators already reject answers received at a deadline, so
the extra block only delayed the next request.

The PRT referee used to send the timeout-win and elimination requests
at the start of each turn, and honest players scheduled their answers
for the deadlines those requests carried. The referee now sends each
request only once its window opens: the timeout win when the on-turn
request expires, and the elimination when the timeout win or the uarch
proof expires. Players answer both at once, so their handlers become
plain queries and no longer schedule responses. The events are renamed
win_match_by_timeout and eliminate_match and no longer carry a
deadline.

No recipe waits with a lower bound anymore, so wait_at_least is gone
from the transport and wait_at_most(FOREVER) suffices.

The deadline tests check that each request is made exactly when its
window opens, and how many are made in each scenario. Tests that pin
absolute blocks follow, and tests of scheduled-response mechanics use
test-local events.
A player's handler returns the payload of its answer, "nothing" when it
has no answer to give, or "done" when it leaves the game. The transport
sends both as the reply's value, in place of the skip and done flags,
and "done" ends the client loop. Eliminating a match and asking for a
missing output have nothing to answer. The quitter leaves at the first
event after its claim, and the phase closer leaves once it closes the
phase.
A player can continue the output history of previous epochs and, once
its epoch is sealed, store a checkpoint (the final machine, its outputs
frontier, and epoch.json) from which the next epoch's players start.
Output proofs are computed on demand from the sealed epoch instead of
at the seal.

Given an epoch directory, the player runs on stored machines instead of
forked servers. Its epoch pair snapshots by cloning the machine's
directory, as the CLI's --revert-mode=stored does, and its machine
cache keeps entries as stored machines. Inputs, outputs, claim forests,
and the cache record live in the directory, and state.json, written
last at each input boundary, at the seal, and for each uarch claim, is
the only commit point. Reopening an existing directory brings the
epoch machine back to the recorded boundary, removes whatever
state.json does not reach, and restores an open epoch's builder or a
sealed epoch's mcycle and uarch claims, so a player can be killed at
any point and resume. remove_epoch_directory removes only the names
the layout creates.

A uarch bundle is now one whole mcycle, so a uarch claim stores one
hash per mcycle instead of one per 2^16 uarch transitions. The tests
cover crash recovery at every recorded cut point (on filesystems with
reflinks), reopening sealed epochs and their disputes, and comparison
with fork mode.
Dockerfile.prt builds an image with Anvil and Dave v3.0.0-alpha.6's
contracts, patched for the recipe geometry. The demo deploys them,
plays two epochs of the rolling calculator, and settles their
disputes on chain. The chain runs in the container, and deployment,
mining, narration, and every player run on the host, whose filesystem
has reflinks.

Each player runs in its own process with a bridge of its own. The
bridge reads Dave's published tournament events, fetching the log
history incrementally and paged to the provider's limits, and drives
the player only through its event handlers. It waits for the
configured confirmations, derives claim bindings from the chain, and
attempts due jobs in order of expiry. Transactions are signed with
cast, tracked until confirmed, replaced with bumped fees when stuck,
and recorded in a pruned, durable journal per player. Every player
starts each epoch from the checkpoint of the previous one, and the
honest player runs in stored mode. PRT_TEST_RESTART=yes restarts it
mid-epoch and mid-dispute, and PRT_TEST_REORG=yes reorganizes the
chain during epoch 1's dispute.

The bridge, transaction, and proof tests run in the PRT image. The
proofs are checked on a chain of their own.
prt-lua-bridge.md explains how to run the on-chain PRT demo, and how
its pieces fit: one process per player, the image and deployment,
epochs and their checkpoints, how the bridge observes the dispute and
turns contract events into calls on the player, proofs, transactions,
the demo harness, validation, and the current limits.
The README described forests only in memory. Document storing and
loading them, loading trees on demand, and why a forest with its own
storage directory cannot be used as a value in another forest.
A new section after the verification game explains how PRT settles a
dispute among many claims in a number of rounds logarithmic in their
count, and runs prt.lua's model of Dave's contracts on the same epoch
the verification game settled. It shows how the referee settles a
missed turn and how a player reveals a bisection, quoting both from
prt.lua. The hash-view section links to it.

README.md is rendered from the template as it stands after this and
the preceding changes.
The unreleased changelog omitted the new APIs and recipes. Record cache
clearing, filesystem operations, forest persistence, ABI conversion and
PRT support, along with the cache layout and archive version changes.
A computation hash is the root of the hash tree over periodically
spaced state hashes, and a claim is a computation hash with its final
state hash and, at the referee, its clock. prt.lua, its tests, and the
README no longer speak of claim trees or computation trees, and call
the descent of a match a bisection rather than a walk.
build_uarch_claim becomes build_uarch_cycle_computation_hash, since it
builds only the computation hash, and recover_epoch_machine becomes
revert_interrupted_input.

The comments above prt.lua's functions are shorter, and the story
calls inside excerpted functions are hidden from the README, as in
vg.lua. README.md is updated to match.
new_forked_player runs on forked servers and keeps its inputs and
outputs in memory. new_stored_player runs on stored machines in its
epoch directory, and its metatable overrides the methods that store
inputs and outputs and commit the epoch state, so the event handlers
no longer test for an epoch directory. Every player ignores events it
already applied. A stored player created with a checkpoint directory
stores the next epoch's checkpoint at the epoch seal, so epoch_sealed
no longer takes one. new_machine_pair and new_machine_cache become
new_forked_machine_pair and new_forked_machine_cache, and the
checkpoint functions become store_first_epoch_checkpoint and
load_epoch_checkpoint.

The proof tests now run stored players. The README describes the two
modes and why its demonstration runs in forked mode.
The uarch reset proof replaces the entire uarch state hash tree node with
the pristine uarch state hash, which assumes the node holds only the
shadow uarch state and the uarch RAM. Config validation only checked
addressability and overlap, so a flash drive or nvram with an explicit
start in the gap between them was accepted. Once it held non-zero data,
reset_uarch and collect_uarch_cycle_root_hashes kept it, while the
verifier wipes it, so the honest uarch reset could not be proven.

machine_address_ranges::check() now rejects ranges that intersect the
gap between the shadow uarch state and the uarch RAM, or the gap
between the uarch RAM and the end of the node. Each check applies only
when its gap is not empty. Ranges that hit the shadow uarch state or
the uarch RAM are rejected by the existing overlap check.
…tree as misses

The page hash-tree cache is persisted separately from the memory ranges
it describes. When a range and the cache were persisted differently (a
SHARING_CONFIG load of a machine whose shared flags differ between its
ranges and its hash tree, or replace_memory_range on a machine with a
shared hash tree), the stored cache could keep an entry for a page whose
stored dirty-page tree said clean while its contents had changed. Later
loads trusted that entry, so get_proof, get_node_hash and
collect_uarch_cycle_root_hashes returned wrong hashes while the root
hash and verify_hash_tree looked correct.

Wherever a cached entry is used without first comparing it with memory
(get_page_proof, get_dense_node_hash and update_words), it is now lent
as a hit only when its root hash equals the page hash in the range's
dense hash tree. Otherwise it is treated as a miss and refreshed from
memory, which is what any caller already does when the entry has been
evicted.

This makes the sharing-based eviction at load and the eviction of
clean pages on replace_memory_range unnecessary, so both are removed,
together with page_hash_tree_cache::evict_entries_if.
frontier_next_proofs accepted more new leaves than the tree could hold
and attempted to build proofs for them. Check the remaining capacity
with frontier_padding_fits before allocating or constructing the proofs.
frontier_node obscured a simple lookup among an active range and its two
outside values. Rename it get_or_outside and put the values, base and
index first, updating the proof construction callers without changing
behavior.
… trees taller than 64

Node indices in a tree of height 64 can set the sign bit, so signed
comparisons selected the wrong sibling hashes. Compare indices as
unsigned and reject taller trees, whose proof addresses cannot fit in 64
bits.
Lua longjmp from a C++ catch leaves the caught exception alive after pcall
returns. Use block-level catches in the existing Lua binding functions,
copy the exception message into a 1024-byte stack buffer with snprintf,
and raise the Lua error only after the catch has finished. Messages longer
than 1023 bytes are truncated and null-terminated.

The diagnostic copy requires no userdata or C++ heap allocation and does
not touch the C API's thread-local error storage. Actual C API calls keep
reporting errors through cm_get_last_error_message. Preserve the existing
error prefixes and the unknown-register-exception fallback.

Create Lua cleanup owners before allocating C++ buffers and objects, and
populate managed strings and JSON values after installing their owners.
Remove the redundant catch around cm_new, which already handles exceptions.

Add a native Lua regression covering repeated conversion, schema, register,
and impossible-buffer-size failures, followed by successful conversions.
Check that no caught C++ exception remains, existing C API diagnostics are
preserved, and messages truncate correctly at the buffer boundary. Link
the test to the Lua and C libraries together to exercise the same error
storage. Register it with misc and parallel test targets.

Validation: make; build-tests-misc; native exception and filesystem tests;
JSON, hash, filesystem, and console specs; local machine-bind suite;
toolchain formatting and clang-tidy on changed binding sources.
JSON keys were passed through terminated strings, truncating embedded
NUL bytes. Input parsing could also accept only the prefix before a raw
NUL. Preserve explicit string lengths when converting keys and reject
raw NUL bytes before parsing Lua JSON or server requests.
ABI decoding added offsets and rounded lengths before checking bounds.
Large values could wrap and pass those checks. Compare each offset,
length and array head size with the remaining input before arithmetic,
and derive tuple ends from the data actually decoded.
ArrayIndex conversion could overflow when subtracting one from a Lua
index or adding one to a JSON index. Check the signed limits and reject
unsigned JSON values that cannot become a Lua index before converting
them.
Without a schema, sparse Lua arrays became JSON arrays containing
invented null entries. Mixed keys could also reach object conversion,
where numeric keys were coerced to strings during iteration. Require
consecutive indices starting at 1 without a schema and accept only
string keys in objects.
An empty Lua table always became a JSON array, even under an object
schema. Let the schema select an empty object, and treat empty schema
dictionaries and type definitions as objects while preserving empty
tuple definitions.
Machine configuration conversion lacked schemas for containers, so empty
configuration objects were serialized as arrays. Define the
configuration container schemas and use them in the machine bindings and
CLI configuration import and export.
Stored epoch state did not declare the type of uarch_claims, leaving an
empty list ambiguous during JSON conversion. Add an array schema for
that field so epochs with no uarch claims can be stored explicitly as an
empty array.
The game transport decoded responses without their schema and encoded
them again before delivery. That lost container distinctions and applied
unnecessary conversions to payloads the transport did not inspect. Keep
each payload as JSON encoded with its event schema, then decode it with
that schema at delivery.
An empty Lua table has no keys to distinguish a JSON object from an
array, but conversion silently chose an array. Require an explicit
container schema for empty tables, including nested ones. Complete the
access log and event argument schemas and parse schema definitions
according to their own container rules.
JSON string conversion treated any matching schema field as an enum
entry, so ordinary strings could become field schemas or other values.
Translate a matching entry only when its schema value is an integer.
NaN and infinity were silently serialized as JSON null, changing the
value and potentially removing an entry when decoded into Lua. Reject
numbers that are not finite before assigning them to JSON.
Lua does not remove string keys from weak tables, so the signature cache
retained every parsed signature. Use weak values and cache the parsed
signature itself, with its selector and canonical name, so it can be
collected once callers release it.
ABI array encoding relied on # and ipairs, which can ignore string keys
or stop at gaps and silently omit values. Validate every key and require
consecutive indices starting at 1 in a shared helper. Use the checked
count for fixed sizes, encoded lengths and dynamic offsets.
The ABI parser consumed memory, calldata or storage at the start of a
parameter name as a location keyword, changing the name. Require an
identifier boundary after those keywords so names such as memorySize
remain intact.
Frontier reconstruction trusted a supplied proof without checking that
it authenticated a leaf in the tree. Validate its shape, hashes and
address before reconstruction. Bound the scan for the lowest complete
level so a proof of the last leaf in a tree of height 64 cannot loop
forever when its count wraps to zero.
Frontier operations accepted invalid subtree heights, allowing bad
indexing or partial mutation, and constructors accepted arbitrary
cartesi members as hash functions. Check subtree heights before use and
resolve hash names only from the supported Keccak and SHA functions.
Independent PRT claim forests shared files named only by hash. Different
tree representations could collide, and a new store could replace files
already used by a committed claim. Give each claim its own tree
directory, update loading and cleanup, and bump the epoch state version
to reject the old layout.
Temporary files were opened before FD_CLOEXEC was set, leaving a window
in which another thread could spawn a child that inherited the
descriptor. Use mkostemp with O_CLOEXEC on POSIX so the descriptor is
protected from creation.
ABI decoding silently discarded nonzero upper bits of addresses and
nonzero padding in fixed byte values. Check those bytes before returning
the decoded value so malformed encodings are rejected.
The ABI hex decoder passed each pair to tonumber, which accepts signs
and therefore treated pairs such as +1 as valid hex bytes. Require two
hexadecimal digits before converting each pair.
Lua callers obtained a temporary pathname by creating and removing a
file, then reused the name later. Another process could claim it in
between. Create private temporary directories atomically and place new
paths inside them, with scoped cleanup for tests and the existing owned
file helpers for file data.
temp_pathname returned an unreserved name after deleting its temporary
file, so callers could not safely create an object there. Remove the API
from C++, C and Lua now that its callers use private temporary
directories or owned temporary files.
The first padding pass could skip empty frontier slots until its shift
reached 64, where the computed subtree size became zero. It could then
merge occupied slots without consuming the count. Stop this scan at the
count width and let the second pass install the remaining bits and
propagate carries.
Oversized ABI integer strings could wrap during bint conversion and pass
the later type range check as a different value. Bound the significant
digit count for decimal, hexadecimal and binary strings before
conversion, ignoring the sign, prefix and leading zeros. Keep the
existing parser and exact type range checks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant