From 00764be531850c05cc8e703e5c69607ef21247dd Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 30 Jul 2026 11:51:01 -0600 Subject: [PATCH 1/2] update docs --- CLAUDE.md | 5 +++-- libs/sdk/README.md | 37 +++++++++++++++++++++++++++++++++++++ libs/sdk/src/fmt_podlang.rs | 30 ++++++++++++++++-------------- libs/sdk/src/lib.rs | 23 +++++++++++++---------- 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa74029e..f205a535 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,12 +154,13 @@ The driver does not cache compiled modules between calls — every `Driver::exec - On `action`: `input(class)`, `output(class)`, `mutate(class)`, `subaction(name)`, `random()`, `st_gt(a,b)`, `st_sum(a,b,c)`, `intro_vdf(iters, obj)`, `intro_lt_eq_u256(obj, target)`, `pow_obj_grind(obj, target)`, `top_limb_u256(n)`. - On object handles: `set([[k,v],...])` (initializer for literals), `update(k,v)` (writes a witness-derived value), `get(k)`, indexer `obj.`. +- In scope as a constant: `state_header` — the grounding state root as a record (`block_number`, `block_timestamp`, `block_hash`, `created`, `nullifiers`, `prior_state_history`). Field reads (`state_header.block_timestamp`) emit anchored statements against the action's public `state_header` arg, which txlib pins from `TxFinalized` down through guards and bridges. Note it describes the (recent) grounding root, not the inclusion block — see `libs/sdk/README.md` for the timing caveat. **Constraint:** the event tree must be the same shape every run. Branching that emits _different events_ on different inputs is unsupported. Branching on wildcard values inside `unsafe { ... }` is fine. -**Records-form rendering** (`libs/sdk/src/fmt_podlang.rs`): to keep predicate arity small, the formatter coalesces all same-role objects of an action into one record arg instead of N loose wildcards. The public records are `in In` / `out Out` (one entry per input/output object; a Mutate contributes to both); the private `Initials` record holds one entry per output object's pre-identity (script-final) dict. The related `chain_steps`/`Chain` packing does the same for intermediate chain states, but only past a threshold (`CHAIN_PACK_MIN_TS = 3`); the in/out/initials records have no threshold and are the canonical form. +**Records-form rendering** (`libs/sdk/src/fmt_podlang.rs`): to keep predicate arity small, the formatter coalesces all of an action's objects into one record arg instead of N loose wildcards. The single public record is `io IO`, with one `in_` entry per input followed by one `out_` entry per output (a Mutate contributes one entry to each side); the private `Initials` record holds one entry per output object's pre-identity (script-final) dict. Every action signature also carries a public `state_header StateHeader` record arg, passed through sub-action calls and bridges. The related `chain_steps`/`Chain` packing does the same for intermediate chain states, but only past a threshold (`CHAIN_PACK_MIN_TS = 3`); the io/initials records have no threshold and are the canonical form. -**`Side` vs `Collapse`** (same file): these are two distinct axes, do not conflate them. `Side { In, Out }` is the strictly-binary public I/O role — it drives `dispatch_side` and the public signature. `Collapse { Side(Side), Initials }` is the record namespace a _collapsed_ object dict pins to when rendered (`in.` / `out.` / `initials.`) or anchored; it is what `collapsed_at` / `collapses_at` return. `Initials` lives only on `Collapse` (private-only, never a public record arg or a dispatch target) and is deliberately **not** a `Side`. +**`Side` vs `Collapse`** (same file): these are two distinct axes, do not conflate them. `Side { In, Out }` is the strictly-binary I/O role — it drives `dispatch_side` and which half of the `io` record an entry sits in (`in_` vs `out_`). `Collapse { IO(Side), Initials }` is the record namespace a _collapsed_ object dict pins to when rendered (`io.in_` / `io.out_` / `initials.`) or anchored; it is what `collapsed_at` / `collapses_at` return. `Initials` lives only on `Collapse` (private-only, never a public record arg or a dispatch target) and is deliberately **not** a `Side`. ## Coding style diff --git a/libs/sdk/README.md b/libs/sdk/README.md index bf0ba108..a28e1d8f 100644 --- a/libs/sdk/README.md +++ b/libs/sdk/README.md @@ -57,6 +57,11 @@ because we want to calculate the value of a `var` as a witness to some statement. The generation of constraining statements can be disabled by using an `unsafe` block. +The integer operators `+` and `-` on `var` values are only available inside +`unsafe` blocks: they compute a witness value and emit nothing. Pair the +result with an explicit statement (`action.st_sum`, `action.st_gt`, ...) +afterward, or a malicious prover can substitute any value. + ## Type checking The scripting language has dynamic types so we do type checking at runtime. @@ -85,6 +90,38 @@ action.intro_lt_eq_u256(wood, target); The emitted podlang embeds `target` as a hex `Raw(0x00…)` literal. +## Reading the state header + +Every action scope has a `state_header` constant: the `StateHeader` record of +the state root the transaction grounds against. Its entries are +`block_number`, `block_timestamp`, `block_hash`, `created`, `nullifiers` and +`prior_state_history`. A field access like `state_header.block_timestamp` +behaves like a `var` entry ref: using it in a statement or an object write +emits an anchored statement against the action's public `state_header` +argument, which txlib pins to the grounded state root all the way up from +`TxFinalized` — a prover cannot substitute its own values. + +```rhai +fn Tick(action) { + var ticker = action.mutate("Ticker"); + var min_ts = unsafe { ticker.ts + 3600 }; + action.st_sum(ticker.ts, 3600, min_ts); + action.st_gt(state_header.block_timestamp, min_ts); + ticker.update("ts", state_header.block_timestamp); +} +``` + +Timing caveat: the header describes the _grounding_ state root, not the block +that will include the transaction. The synchronizer accepts grounding roots up +to `MAX_STATE_ROOT_AGE_BLOCKS` (300 blocks, roughly an hour) old, so a prover +may legitimately ground against the oldest accepted root: `block_timestamp` +can trail wall-clock time by up to that window, and the transaction lands on +chain later still. Treat time locks written against it as coarse-grained. + +`block_hash` is a lossy repacking of the execution block hash (each 8-byte +limb reduced into a field element, see `pod2utils::b256_to_hash`) — suitable +as opaque entropy, not for byte-exact comparison with the L1 hash. + # Missing features - [ ] Literal Array diff --git a/libs/sdk/src/fmt_podlang.rs b/libs/sdk/src/fmt_podlang.rs index be336d65..ef43be4b 100644 --- a/libs/sdk/src/fmt_podlang.rs +++ b/libs/sdk/src/fmt_podlang.rs @@ -1,9 +1,11 @@ //! Functions used to format to podlang source code. //! //! This is the records-form emitter. Every action becomes -//! `Action(in In, out Out, chain0, chain, ...)` where the -//! `in`/`out` typed wildcards are pod2 records carrying one entry per -//! Object inst on that side. Each (action, object) tuple gets a bridge +//! `Action(io IO, state_header StateHeader, chain0, chain, ...)` +//! where `io` is a pod2 record carrying one `in_` entry per Input +//! and one `out_` entry per Output (a Mutate contributes one of +//! each) and `state_header` is the grounding state root record threaded +//! down from `TxFinalized`. Each (action, object) tuple gets a bridge //! predicate that pins the focused entry via `ArrayContains` and defers //! to the action; the IsX OR is over those bridge predicates. @@ -132,9 +134,9 @@ impl<'a> fmt::Display for VarNameFmt<'a> { } } -/// Which public record an Object inst belongs to: `in In` -/// and `out Out` each carry one entry per Object inst on that -/// side (Mutate contributes to both). +/// Which side of the `IO` record an Object inst's entry sits +/// on: `in_` entries come first, then `out_` entries (Mutate +/// contributes one entry to each side). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Side { In, @@ -307,11 +309,11 @@ fn collect_sub_action_calls(action: &ActionContext) -> Vec { /// Emit one action predicate. For each Object inst, sides whose /// `needs_wildcard` is set get a leading `ArrayContains` clause + a /// private wildcard; collapsed sides drop both and let body refs render -/// as `in.` / `out.` anchored refs. Witness vars (e.g., -/// values passed to `obj.update(k, v)`) appear as plain private -/// wildcards. Sub-action calls are emitted with synthesized typed- -/// private wildcards `__in_` / `__out_` matching the -/// sub's record schemas. +/// as `io.in_` / `io.out_` anchored refs. Witness vars +/// (e.g., values passed to `obj.update(k, v)`) appear as plain private +/// wildcards. Sub-action calls are emitted with a synthesized typed- +/// private wildcard `__io_` matching the sub's record schema, +/// and pass the parent's `state_header` through. fn fmt_action(action: &ActionContext, loader: &Loader, w: &mut dyn fmt::Write) -> fmt::Result { let meta = loader .actions_meta @@ -390,7 +392,7 @@ fn fmt_action(action: &ActionContext, loader: &Loader, w: &mut dyn fmt::Write) - // Per-var rendering state for body emission. Each var holds a // back-reference to `meta` so `VarNameFmt::collapses_at` can - // resolve whether it renders as `in.` / `out.` / + // resolve whether it renders as `io.in_` / `io.out_` / // `initials.` at a given ts. let mut vars: HashMap<&str, VarNameFmt> = action .vars @@ -519,7 +521,7 @@ fn fmt_action(action: &ActionContext, loader: &Loader, w: &mut dyn fmt::Write) - // extra ts that `output_max_ts` reserves) is the post-identity `new` // dict TxInsert produces. The post-identity form is the object's // output state: it is what the transaction inserts, and it collapses - // to `out.` to bind the `Out` record entry. + // to `io.out_` to bind the `IO` record entry. for o in &meta.object_refs { let chain = vars["chain"]; let chain_next = chain.next(); @@ -577,7 +579,7 @@ fn fmt_bridges(loader: &Loader, w: &mut dyn fmt::Write) -> fmt::Result { let bridge_name = bridge_predicate_name(&o.class, &meta.name, &o.varname, multi); // Bridge predicate signature: state, state_header, chain0, chain (public); - // in , out private as needed. + // io private when the action has any entries. write!(w, "{bridge_name}(state, state_header, chain0, chain")?; let mut priv_parts: Vec = Vec::new(); if !meta.in_entries.is_empty() || !meta.out_entries.is_empty() { diff --git a/libs/sdk/src/lib.rs b/libs/sdk/src/lib.rs index 566a92aa..186cb2d6 100644 --- a/libs/sdk/src/lib.rs +++ b/libs/sdk/src/lib.rs @@ -1559,8 +1559,8 @@ fn try_value_from_dynamic(v: Dynamic) -> RuntimeResult { /// One object reference in an action, in declaration order. Only /// `class` is exposed; `io` is internal, used by the /// `local_inputs()` / `local_outputs()` filters. `varname` is the -/// script-side variable name; it doubles as the entry name in the -/// records-form `In` / `Out` schemas. +/// script-side variable name; side-prefixed (`in_` / `out_`) +/// it forms the entry names in the records-form `IO` schema. #[derive(Debug, Clone)] pub struct ActionObjectRef { pub(crate) io: ObjectIO, @@ -1568,12 +1568,12 @@ pub struct ActionObjectRef { pub(crate) varname: String, } -/// One slot in an action's `In` or `Out` record. A -/// Mutate Object contributes one `EntryShape` to each side; Input/ -/// Output Objects contribute one to their side only. `needs_wildcard` -/// drives whether the slot is referenced directly (collapsed to -/// `.` anchored refs) or via a private wildcard pinned -/// by an `ArrayContains` clause. +/// One slot in an action's `IO` record (in-entries first, +/// then out-entries). A Mutate Object contributes one `EntryShape` to +/// each side; Input/Output Objects contribute one to their side only. +/// `needs_wildcard` drives whether the slot is referenced directly +/// (collapsed to `io._` anchored refs) or via a private +/// wildcard pinned by an `ArrayContains` clause. #[derive(Debug, Clone)] pub(crate) struct EntryShape { pub varname: String, @@ -1651,8 +1651,8 @@ impl ActionMeta { .position(|name| name == varname) } - /// Find this Object's `in` entry. Returns its slot in the - /// `In` record and the entry shape. + /// Find this Object's in-side entry. Returns its slot in the + /// `IO` record and the entry shape. pub(crate) fn in_entry(&self, varname: &str) -> Option<(usize, &EntryShape)> { self.in_entries .iter() @@ -1660,6 +1660,9 @@ impl ActionMeta { .find(|(_, e)| e.varname == varname) } + /// Find this Object's out-side entry. Returns its slot in the + /// `IO` record (offset past the in-entries) and the entry + /// shape. pub(crate) fn out_entry(&self, varname: &str) -> Option<(usize, &EntryShape)> { self.out_entries .iter() From 6058575b6b1e8722b52a3f14f7761f01905ad449 Mon Sep 17 00:00:00 2001 From: Dhvani Patel Date: Thu, 30 Jul 2026 12:00:11 -0600 Subject: [PATCH 2/2] fix pod2 container rendering --- interfaces/gui/src/shared/pod2utils.ts | 87 +++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 7 deletions(-) diff --git a/interfaces/gui/src/shared/pod2utils.ts b/interfaces/gui/src/shared/pod2utils.ts index c7084762..008af2fd 100644 --- a/interfaces/gui/src/shared/pod2utils.ts +++ b/interfaces/gui/src/shared/pod2utils.ts @@ -9,7 +9,8 @@ function isPod2IntWrapper(value: unknown): value is { Int: string | number } { function parsePod2IntWrapper(value: unknown): number | null { if (!isPod2IntWrapper(value)) return null; const raw = value.Int; - const parsed = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10); + const parsed = + typeof raw === "number" ? raw : Number.parseInt(String(raw), 10); return Number.isFinite(parsed) ? parsed : null; } @@ -38,7 +39,10 @@ function normalizePod2ContainerInner(value: unknown): unknown { ) ) { return Object.fromEntries( - tupleEntries.map(([key, entry]) => [key as string, normalizePod2Value(entry)]), + tupleEntries.map(([key, entry]) => [ + key as string, + normalizePod2Value(entry), + ]), ); } @@ -48,10 +52,10 @@ function normalizePod2ContainerInner(value: unknown): unknown { ) ) { return tupleEntries - .map(([index, entry]) => [ - parsePod2IntWrapper(index) ?? 0, - normalizePod2Value(entry), - ] as const) + .map( + ([index, entry]) => + [parsePod2IntWrapper(index) ?? 0, normalizePod2Value(entry)] as const, + ) .sort((left, right) => left[0] - right[0]) .map(([, entry]) => entry); } @@ -61,6 +65,68 @@ function normalizePod2ContainerInner(value: unknown): unknown { ); } +const CONTAINER_KIND_PATTERN = /^[d-][s-][a-]$/; + +function isPod2Container( + value: Record, +): value is { kind: string; kvs: unknown[] } { + return ( + Object.keys(value).length === 2 && + typeof value.kind === "string" && + CONTAINER_KIND_PATTERN.test(value.kind) && + Array.isArray(value.kvs) + ); +} + +/** + * pod2 containers serialize as `{ kind, kvs }`: `kind` is a three-flag + * string ("d--" dictionary, "-s-" set, "--a" array; flags can combine when + * one root has been used as several types) and `kvs` holds `[key, value]` + * entries, collapsed to `[value]` when the key equals the value (always the + * case for sets). Unwrap by the claimed kind; a kvs whose entries do not + * match it falls back to the shape heuristics of + * `normalizePod2ContainerInner`. + */ +function normalizePod2Container(kind: string, kvs: unknown[]): unknown { + const entryPair = (entry: unknown): [unknown, unknown] | null => { + if (!Array.isArray(entry) || entry.length < 1 || entry.length > 2) { + return null; + } + return [entry[0], entry[entry.length - 1]]; + }; + const pairs = kvs.map(entryPair); + + if (kind[0] === "d") { + if (pairs.every((pair) => pair !== null && typeof pair[0] === "string")) { + return Object.fromEntries( + (pairs as [string, unknown][]).map(([key, entry]) => [ + key, + normalizePod2Value(entry), + ]), + ); + } + } else if (kind[1] === "s") { + if (pairs.every((pair) => pair !== null)) { + return (pairs as [unknown, unknown][]).map(([, entry]) => + normalizePod2Value(entry), + ); + } + } else if (kind[2] === "a") { + const slots = pairs.map((pair) => { + if (!pair) return null; + const index = parsePod2IntWrapper(pair[0]); + if (index === null) return null; + return [index, normalizePod2Value(pair[1])] as const; + }); + if (slots.every((slot) => slot !== null)) { + return (slots as (readonly [number, unknown])[]) + .sort((left, right) => left[0] - right[0]) + .map(([, entry]) => entry); + } + } + return normalizePod2ContainerInner(kvs); +} + function normalizePod2Value(value: unknown): unknown { if (typeof value === "string") { return value @@ -86,6 +152,10 @@ function normalizePod2Value(value: unknown): unknown { return value; } + if (isPod2Container(value)) { + return normalizePod2Container(value.kind, value.kvs); + } + const keys = Object.keys(value); if (keys.length === 1) { const key = keys[0]; @@ -122,7 +192,10 @@ function normalizePod2Value(value: unknown): unknown { } return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, normalizePod2Value(entry)]), + Object.entries(value).map(([key, entry]) => [ + key, + normalizePod2Value(entry), + ]), ); }