Skip to content

feat: note constructors (refactor: resolve through entrypoint function reference) - #1262

Draft
greenhat wants to merge 13 commits into
i786-note-constructorfrom
note-constructor-func-ref
Draft

feat: note constructors (refactor: resolve through entrypoint function reference)#1262
greenhat wants to merge 13 commits into
i786-note-constructorfrom
note-constructor-func-ref

Conversation

@greenhat

@greenhat greenhat commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Branched off #1252 to experiment with function references.

Experiment probing whether the funcref-table/procref infrastructure introduced for call_indirect can carry the in-VM note script root, in place of the dedicated hir.procedure_root op.

Two discoveries that made me pause and question this approach:

  1. The root cannot live in the table at runtime. The natural design where init procrefs the wrapper into the slot, the method loads the word is impossible. Every canonical-ABI export wrapper begins with exec init, so the wrapper's MAST root would depend on init's body while init embeds the wrapper's root. The assembler's cycle rejection is a real circular-digest constraint, which is why marked slots are exempted from init and the digest is materialized inline where it's read.
  2. wasip2 is position-independent. A Rust fn pointer compiles to __table_base (an immutable, link-resolved global) plus an offset, not a bare constant — the conversion folds through adds, bitcasts, and immutable-global loads.

@bitwalker I would make the P2idNote::get_entrypoint_root() static method on the note type in #1252 and close this PR. After the lift-time repoint, the referenced function contributes nothing to the digest, while costing PIC folding, forced table lowering on every note, and a zeroed slot that would trap a genuine call_indirect through the entrypoint pointer.

greenhat added 12 commits July 7, 2026 16:27
Rust code using function pointers or trait objects previously hit a todo!() panic in the wasm frontend, since call_indirect had no lowering.

Lower each dispatched-through funcref table to a new builtin.function_table op holding one entry per statically-initialized slot. The linker allocates a word-aligned region for it (one word per slot) in the page following the globals table, moving the dynamic heap base past it; the component init procedure fills each slot with the callee's MAST root via procref + mem_storew_le; and call sites lower through the new hir.exec_indirect op to a table-index bounds check, slot address computation, and dynexec.

Tables are lowered lazily, on first use by a call_indirect: wit-bindgen and rustc routinely emit funcref tables (empty, or holding only artifacts like cabi_realloc) in modules that never perform an indirect call, so eager lowering would burden every compiled program with a useless table and its initialization code. Table-referenced functions are promoted to internal visibility so the init module's procref can resolve them, and the init procedure now attaches its collected invoked set, without which the assembler's linker treats procref-only callees as unreachable.

The wasm-mandated runtime type-signature check is deferred and documented in the known-limitations appendix; only the bounds check traps deterministically, and null slots fail inside dynexec. Covered by a differential test dispatching through a static array of function pointers across two indirect call sites, and a frontend expect-test snapshotting the lowered HIR.
Zero-initialized statics occupy linear memory after the data segments without appearing in the wasm module, and the linker's only allowance for them was a single page of headroom past the last segment. A larger .bss region therefore silently overlapped the compiler-managed memory laid out above it — global variables, function tables, and the dynamic heap — corrupting, for example, the MAST roots the function tables hold.

Record the declared minimum size of the module's linear memory (previously parsed and discarded) as a language-agnostic reserved_memory attribute on builtin.Module, expressed in bytes so no wasm units leak below the frontend, and use it in the linker as an additional floor for the global-table offset; function tables and the heap base are laid out above the globals, so the one attribute lifts all compiler-managed regions past everything the module's producer placed. The one-page heuristic and the 17-page default reservation remain as fallbacks for modules that carry no attribute, such as HIR not produced by the wasm frontend.

Covered by the static_bss differential case: a 136 KiB zero-initialized static filled across the window where a reservation-blind layout would place the function table, dispatching indirect calls through it afterwards.
The known-limitations entry listed the missing Wasm runtime type-signature check as if it were pending work. It is a deliberate design decision: safe Rust cannot produce a signature-mismatched indirect call — reaching one requires code that is already undefined behavior — so the check would only add runtime cost to every indirect call.

Also note the containment property that makes this acceptable: dynexec resolves the table slot as a MAST root digest, so a corrupted or mismatched slot either matches a procedure already compiled into the program or fails execution.
Table entries were collected as an append-only list with ref.null element entries skipped, which is only correct for slots that were already null: a whole-table (ref.func ..) default, or an earlier segment write followed by a later ref.null, left the earlier function in place, so an in-bounds call_indirect could dispatch to a stale function instead of trapping on a null slot. The TableInitialValue::Null { precomputed } arm had the inverse latent bug, passing reserved (null) indices through to a panicking map lookup.

Compute a final per-slot image instead: apply the initial value and the active element segments in order, with later writes - including explicit ref.null entries - replacing earlier ones, and emit one table entry per final non-null slot. This also collapses duplicate slot writes into a single startup store and removes the write-ordering subtlety the init code previously relied on.

Also reject pathological tables (more than 2^20 slots) up front instead of exhausting memory materializing per-slot IR, replace the remaining todo!()/bare-Report paths in the same file with proper diagnostics (core function imports, inlined-intrinsic table elements, now named by function), and enable the reference-types feature so expression-style element segments - the encoding ref.null clears arrive in - validate; every construct it admits is either handled or rejected with a clean error. Covered by a module translation test where a ref.null entry clears a previously initialized slot, and one rejecting an oversized table.
The reserved_memory module attribute is the input that decides where the linker places compiler-managed memory, but builtin.module's custom printer and parser handled only visibility, name, and body: printed HIR silently dropped the attribute, so re-ingesting a dump (HIR files are a first-class compiler input) reinstated the heuristic layout floor - the exact silent-corruption bug the attribute exists to prevent - and no dump ever showed the reservation. Similarly, hir.exec_indirect printed a custom form with no matching parser, making any IR containing an indirect call one-way.

Print and parse an optional attributes dictionary on builtin.module (attribute values round-trip fully typed, e.g. #builtin.u64<..>), and add an OpParser for hir.exec_indirect mirroring hir.exec (table symbol, bracketed u32 index operand, argument list, signature), along with the attribute-dictionary tail its printer was missing. Covered by a print/parse round-trip test asserting the typed attribute and table survive, and a lit test round-tripping the full new textual surface.

Also hardens the new table IR against non-frontend producers: ModuleBuilder::append_function_table_entry now rejects out-of-bounds slot indices (instead of deferring to a codegen panic), creates the entries block on demand for tables not built by define_function_table, and documents that later duplicate-slot entries win; the FunctionTable size attribute is renamed to num_slots to make its unit unambiguous; the stale commented-out CallIndirect skeleton - which embodied the obsolete design this branch replaced - is dropped; and builtin.module's rustdoc now lists FunctionTable among the entities a module can contain.
Several failure paths in the new indirect-call machinery aborted the compiler instead of producing a diagnostic, and two were reachable from validator-accepted Wasm: a module declaring the wasm32 maximum memory (65536 pages) panicked converting the reserved-memory floor to u32, and a sufficiently large declared reservation survived the cast only to silently corrupt every global variable offset through the wrapping i32 casts in update_global_table_offset. The linker now returns a LayoutOverflow error for reservations, data-segment ends, and function tables that leave no room in the 32-bit address space, and the offset-rebasing arithmetic is done in 64 bits with checked conversions. Function-table init emission likewise returns diagnostics instead of panicking on malformed entries or unresolvable callees.

The private-to-internal visibility promotion of table-referenced callees moves from the wasm frontend into the init emission: the promotion exists only because codegen places init in the root component module and takes MAST roots via cross-module procref, so the constraint is now enforced where it originates, and other producers of function-table IR get it for free.

Also folds the slot geometry into FunctionTableLayout (SLOT_SIZE_BYTES/SLOT_SIZE_ELEMENTS constants and an element_addr_of helper that centralizes the byte-to-element conversion and word-alignment check), resolves the exec_indirect lowering's table through nearest_symbol_table like hir.exec instead of assuming a builtin.module ancestor, renames the bounds-check trap message to the frontend-agnostic "indirect call: function table index out of bounds", and replaces raw FunctionTableRef::from_raw uses with the as_function_table_ref helper.
The bounds check is the most safety-relevant code the indirect-call lowering emits, yet no test executed it: the differential case masks its index, and a wrong comparison direction or off-by-one would have passed the whole suite. Out-of-bounds and null-slot dispatch also cannot be differential cases, because both are undefined behavior when the same program runs natively.

Add an emitter unit test pinning the exact instruction sequence and stack effect of exec_indirect (bounds check, in-place index-to-address rewrite, frame-traced dynexec), and an execution test that transmutes an input into a function pointer - at the Wasm level a function pointer is its table index - and asserts that an in-bounds slot dispatches successfully, an out-of-bounds index traps with the documented bounds-check message, and the null slot fails on its zero MAST root.
The appendix docs predated the memory-based dynexec and the call_indirect implementation on this branch: the 'Dynamic procedure invocation' section still claimed dynexec support was unimplemented and described the obsolete hash-on-stack design with per-callee stubs, and calling-conventions.md derived a 12-element argument limit from that design, contradicting the actual 15-plus-address limit and wrongly claiming indirect-call arguments spill.

Rewrite the dynamic-invocation section around the implemented mechanism (same-context dynexec dispatch through a memory-resident MAST root, no stubs needed; dyncall still pending on cross-context invocation), fix the argument-limit and spill claims, and align the smaller details: the function-call-indirection section now states that table lowering is lazy and lists the empty-table compile-time rejection, the stale milestone reference is dropped, the wasm frontend README gains the table support/limitation matrix, and the println_expr comment no longer cites call_indirect as the reason for avoiding core::fmt (it is supported now; the remaining reason is VM cost).
…names

A call_indirect through a table with no statically-initialized entries was rejected at compile time, although the runtime representation already handles null slots (a zero word that dynexec fails on), so valid trap-at-runtime Wasm failed to compile. Lower such tables with their declared slot count and no entries instead; every dispatch through them now fails at runtime like any other null slot, matching Wasm's uninitialized-element trap. Lazy lowering still ignores tables that no call_indirect dispatches through.

Also stop reusing a table's Wasm export name as its HIR symbol: export names are arbitrary strings independent of the module's internal namespace, so a colliding name (e.g. a table exported as 'add' alongside a function @add) rejected valid modules. Every lowered table now gets the generated __indirect_function_table_{index} name; the symbol is compiler-internal, so nothing user-visible changes.
The layout-overflow hardening left three spots that could still wrap silently in release builds for layouts approaching the 32-bit address limit: global variable placement (alignment and size accumulation in GlobalVariableLayout::insert) and the page rounding in next_page_boundary and heap_base. All three now use checked arithmetic that fails loudly with a descriptive message, consistent with the offset-rebasing fix.

Also make builtin.function_table_entry dynamically legal only inside a builtin.function_table, so a misplaced entry is reported as a legalization diagnostic instead of reaching the MASM module builder's panic, and clean up a duplicated doc summary plus two inline TODOs that are really design notes.
Creating an output note from a transaction script requires the note script root, which is only known once the note is compiled; until now scripts had to receive a host-precomputed recipient digest as advice input (#786).

Compile notes as libraries exporting both the entrypoint and constructors:

- `note::get_entrypoint_root()` returns the MAST root of the crate's `#[note_script]` export. The intrinsic's linker stub lowers to a `hir.procedure_root` op marked `note_script_root`, initially targeting the stub itself; export lifting repoints marked ops at the lifted `note_script` export, and codegen refuses to lower a marked op whose callee is not that export before emitting a MASM `procref`. The digest is thus resolved by the assembler, and a missed retarget is a compile error rather than a silently wrong root.
- `#[note_constructor]` methods are exported through the note's WIT interface (also written to `target/generated-wit/`), so a transaction script can declare the note package as a Miden dependency and create the note through the ordinary cross-context call. `#[note]` structs additionally implement `ToFeltRepr`, letting constructors serialize the note inputs the recipient commits to.

Because the constructor and the `procref` are compiled into the note package itself, the recipient commits to the standalone package's script root, so note creation and consumption agree by construction. The new `examples/p2id-tx-script` exercises the flow against the `create` constructor of `examples/p2id-note`, covered end-to-end on the mock chain and by a VM-level test pinning `get_entrypoint_root()` to `NoteScript::from_package(..).root()`.
A free function is easy to miss next to the note type it describes, and calling it in a crate without a `#[note_script]` entrypoint only fails once the compiler frontend runs.

`#[note]` impl blocks now generate an associated `get_entrypoint_root()` method on the note type, so the API is discoverable on the note itself and exists exactly when an entrypoint exists — misuse becomes an ordinary rustc error instead of a frontend diagnostic. The public `note::get_entrypoint_root()` free function becomes hidden macro plumbing (`__entrypoint_root`); it stays in the SDK because the underlying weak extern requires `feature(linkage)`, which user crates do not enable.

The generated method is `#[inline(always)]`, keeping compiled packages identical to calling the plumbing directly, and the compiler transport (intrinsic stub, `hir.procedure_root`, export-lift retargeting, `procref` lowering) is unchanged.
@greenhat
greenhat force-pushed the i786-note-constructor branch from 98b3c62 to 416329a Compare July 7, 2026 14:00
… reference

Experiment probing whether the funcref-table/procref infrastructure introduced for
`call_indirect` can carry the in-VM note script root, in place of the dedicated
`hir.procedure_root` op. The observable behavior is unchanged: notes compile to
packages exporting the entrypoint and constructors, a transaction script creates the
note via the ordinary cross-context call, and the recipient commits to the package's
own script root.

The generated `get_entrypoint_root()` method now obtains the root through a
function reference: its body coerces the `#[note_script]` entrypoint method to a
`fn` pointer and passes it to the reworked `intrinsics::note::script_root`
intrinsic (the hidden SDK plumbing gains the reference parameter accordingly). The
frontend converts the call inline at call sites: it folds the pointer to its
funcref-table slot — on position-independent wasm the pointer is the link-resolved
`__table_base` global plus an offset, so the folder looks through adds and
immutable-global reads — validates the slot against the element segments, and marks
the slot's `builtin.function_table_entry`. Export lifting repoints marked entries
at the lifted `note_script` export, and the new `hir.function_table_root` op
resolves the entry to a MASM `procref`, refusing marked slots whose callee is not
the note-script export so a missed retarget is a compile error.
`hir.procedure_root`, its `ModuleContextStub` conversion path, the op
retargeting, and the codegen verification are removed.

The digest is materialized inline rather than read from the table because it cannot
live in `procref`-initialized memory: canonical-ABI export wrappers begin with
`exec init`, so a `procref` of the note-script wrapper inside `init` would make
the two MAST roots circularly dependent. Marked slots are therefore exempt from
startup initialization and their in-memory word stays zero. Note packages now lower
their funcref table unconditionally, which shows up in the updated package-size and
cycle-count expectations (consumption pays for populating the remaining slots).
@greenhat
greenhat force-pushed the note-constructor-func-ref branch from 868b32f to 5c622fb Compare July 7, 2026 14:12
@greenhat
greenhat force-pushed the i786-note-constructor branch from 6a9f987 to 62524ea Compare July 13, 2026 13:57
@greenhat
greenhat force-pushed the i786-note-constructor branch 3 times, most recently from 7c48171 to 03f5410 Compare August 7, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant