Skip to content

fix(page): private-input PAGE OFFSET is unconstrained — forgeable memory contents (two invariants, both with exploits) - #904

Merged
MauroToscano merged 7 commits into
mainfrom
fix/page-offset-binding
Aug 7, 2026
Merged

MauroToscano merged 7 commits into
mainfrom
fix/page-offset-binding

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Private-input PAGE and GLOBAL_MEMORY tables are built without a preprocessed commitment, so
their OFFSET column is a free, prover-chosen main-trace column. Since the memory-bus token address
is address_lo = page_base_lo + OFFSET, a malicious prover can point a page row at an address of
its choosing and forge that address's genesis value.

This is a memory-soundness break, and therefore a VM-soundness break. A proof that
verifies against the unmodified ELF can attest a public_output the program cannot produce.
It is demonstrated end to end in this branch, under the production proof options.

The spec got this right — this is an implementation deviation, not a spec gap.
spec/src/page.toml declares offset as type = "RowIndex", which spec/src/config.toml defines as
"A preprocessed column holding the row index (zero-indexed)" with preprocessed = true. Both
properties this bug breaks are therefore mandated by the landed spec, and page.toml never
contemplates a private-page exception. See The spec already requires this.

Closing it needs two invariants, not one — the first fix here restores one of them and a second
working forgery survives it:

invariant how it was broken fix
one row per address, within a page free OFFSET on private pages af33896d — preprocess OFFSET
one page per address runtime_page_ranges never deduped 2bdc8968 — reject duplicate bases

Route 1 — free OFFSET on private pages

The only mechanism that binds a PAGE's OFFSET/INIT is in the verifier
(crypto/stark/src/verifier.rs:1184-1213):

if air.is_preprocessed() {
    let expected = air.precomputed_commitment();
    // reject unless the proof's precomputed root == expected
    transcript.append_bytes(&expected);                            // FS-bound to the CORRECT columns
    transcript.append_bytes(proof.lde_trace_main_merkle_root());
} else {
    transcript.append_bytes(proof.lde_trace_main_merkle_root());   // nothing pinned
}

is_preprocessed() is true only when with_preprocessed(...) was called — and prover/src/lib.rs
deliberately skipped it for config.is_private_input, as did global_memory_air in
prover/src/continuation.rs. There is no backstop: PAGE uses EmptyConstraints,
NullBoundaryConstraintBuilder returns vec![], no PageConstraints type exists, and no constraint
anywhere referenced cols::OFFSET.

Nothing external pins it either. prover/src/statement.rs absorbs the ELF digest, public output,
table counts and num_private_input_pages — a count — but no digest of the private input's
contents or layout, and verify(vm_proof, elf_bytes) is never given the private input. That is by
design (the input is private), which means soundness rested entirely on address confinement —
exactly what a free OFFSET removes.

The spec already requires this

spec/src/page.toml declares the column as

[[variables.input]]
name = "offset"
type = "RowIndex"
desc = "The offset from the page base address."

and spec/src/config.toml defines that type as

[[variables.types]]
label = "RowIndex"
subtypes = ["Word"]
desc = "A preprocessed column holding the row index (zero-indexed)."
preprocessed = true

So the spec mandates both properties this bug broke: offset must be preprocessed, and it must
hold the row index. page.toml also never mentions private-input pages — the is_private_input
bypass has no basis in the spec at all. This is an implementation deviation from the landed spec, not
a gap in it, and af33896d restores what type = "RowIndex" already specified.

Root cause in one sentence: Blum-style offline memory checking is fork-resistant only because
the init/final sets hold exactly one entry per address, and it is the preprocessed dense OFFSET
enumeration
that supplied that invariant.

prover/src/tables/page.rs also documented the false assumption this grew from — that all columns
"including OFFSET and INIT" are "constrained via the memory bus". The bus constrains the values a
token carries against its counterpart; it never constrains which address a row names.

The forgery

Displacement, one row, for a target address A read at time t with real byte r:

  • the honest page row provides (A, 0, r) and is set to consume (A, t, X)
  • the repointed private row (OFFSET := A − page_base, INIT := X, FINI := r, TIMESTAMP := 0)
    provides (A, 0, X) and consumes (A, 0, r)
  • MEMW consumes (A, 0, X) and provides (A, t, X)

The multisets are equal, so the Memory bus balances. AreBytes[INIT, FINI] is satisfied because X
is a byte; the only extra cost is moving one MU_ARE_BYTES unit between two BITWISE rows.

TIMESTAMP = 0 is available because PAGE's TIMESTAMP columns are unconstrained, and PAGE-C3
hardcodes ts = 0, which is what a first access needs.

It needs no private input, and no guest cooperation

num_private_input_pages is a VmProof field. verify_proof_parts bounds it only against
max_private_input_pages() (2049) and then appends that many non-preprocessed pages. The verifier
has no private input to compare against. Even without touching the wire format,
build_initial_image plants the bytes whenever private_input is non-empty, so one dummy byte
through the public prove API creates the page
— for any program.

Reach: address_hi is a per-page constant and private bases give address_hi ∈ {0, 1}, i.e. the
whole low 4 GiB — .text/.rodata/.data/.bss and the heap. The stack is out of reach
(STACK_TOP = 0xFFFF_FFFF_FFFF_FFF0 ⇒ address_hi = 0xFFFF_FFFF). Instructions come from the
separately-committed DECODE table, so this is data forgery, not code injection — but for any
program whose result depends on its own constants, static data or zero-initialised memory, it yields
an arbitrary accepted result.

The workloads that matter are the exposed ones: the ethrex block guest reads its entire
ProgramInput via get_private_input(), and the recursion verifier's whole blob (inner ELF + inner
proof) lives in private-input pages.

Fix

Preprocess OFFSET alone for private pages, leaving INIT in the main trace. OFFSET is a
dense 0..page_size−1 enumeration — entirely content-independent — so committing it publishes
nothing about the private input. cols::OFFSET = 0 in both PAGE and GLOBAL_MEMORY and
with_preprocessed's contract is "columns 0..n are precomputed", so n = 1 isolates it with no
column reordering.

This is not a new mechanism. The verifier already enforces exactly this binding, and ELF-data and
zero-init pages already depend on it. Private pages bypassed it; the fix stops bypassing it for the
one column that is public. Zero added constraint degree; one extra Merkle root per private page.

Static constants for blowup 2/4/8 are generated with the existing compute_static_commitments
binary, with the standard recompute fallback. private_page_static_matches_recompute_for_all_blowups
also asserts the OFFSET-only root differs from the OFFSET+INIT root — equal bytes would
mean a call site is committing the wrong column count, which is the regression that would silently
reopen this.

Route 2 — duplicate page coverage (survives the route-1 fix)

Pinning OFFSET restores "one row per address within a page". It does not restore "one page per
address", and the same fork is reachable without any free column.

page_configs_from_elf_and_runtime appended one PageConfig::zero_init per runtime_page_ranges
entry and sorted by base — a Vec, never deduped — while verify_proof_parts validated only
table_counts and num_private_input_pages, passing runtime_page_ranges through untouched with a
free u64 base.

So a prover declares RuntimePageRange { base: <a real ELF .data page base>, count: 1 }. That
address is then covered by two PAGE tables, and both carry correct, verifier-recomputed
preprocessed commitments
— the injected one is an ordinary zero-init page whose OFFSET and
INIT match the shipped static_zero_page_commitment. Nothing is forged at the commitment layer,
which is precisely why the route-1 fix does not touch it. Two genesis tokens at one address; the two
rows swap which token each consumes.

Severity is lower than route 1 — the injected value is always 0, since zero-init is the only page
kind conjurable at a chosen base, so the primitive is "force a chosen address to read 0 at
genesis" rather than "arbitrary byte at arbitrary address". No route back to arbitrary values was
found. Still a forged execution: zeroing a length, a bound, a chain-id byte or a root byte is
enough. And it is independent of the private-input feature entirely.

Fix

3ee45cb5 validates runtime_page_ranges (alignment, zero count, address-space overflow) and
bounds the total page count before materialising configs; 2bdc8968 rejects duplicate bases via
a single adjacent-equality scan after the existing sort, which covers the ELF, runtime and private
sources at once.

Two design notes:

  • page_configs_from_elf_and_runtime is now fallible and takes a max_pages cap, rather than
    adding a separate validator the verifier must remember to call. It is the only production caller,
    so putting enforcement inside the reconstruction means a future caller cannot skip it.
  • The cap is proofs.len(), not a new policy constant. Every page config needs its own sub-proof, so
    a layout wanting more pages than the proof carries can never verify — the bound is exact and
    provably cannot reject honest output.

⚠️ The two commits are not independent in one direction. The alignment check in 3ee45cb5 is
what makes 2bdc8968's equality check a complete overlap check: page-aligned pages of one size
either share a base or are disjoint, so there is no partial-overlap case. Reverting 3ee45cb5 alone
would silently weaken 2bdc8968. Noted in both commit messages.

3ee45cb5 also closes a verifier DoS: the old for i in 0..count loop ran with an unbounded
u64 from the proof before the expected_proof_count check that would have rejected it, so
RuntimePageRange { base: 0, count: u64::MAX } made the verifier allocate until it died.

Tests

b6101a68 carries end-to-end forged-proof regression tests for both routes, under
GoldilocksCubicProofOptions::with_blowup(2) — what public verify uses — through the production
multi_prove / verify_with_options path.

dup_duplicate_page_forgery_is_rejected                             ... ok
dup_negative_control_without_compensating_row_fails                ... ok
dup_structural_duplicate_page_coverage_is_rejected                 ... ok
forged_private_page_offset_is_rejected                             ... ok
poc_control_honest_harness_verifies                                ... ok
poc_negative_control_direct_init_tamper_on_preprocessed_page_fails ... ok
poc_negative_control_forged_run_without_repointed_row_fails        ... ok
poc_real_ethrex_inputs_produce_private_input_pages                 ... ok

Both forged_private_page_offset_is_rejected and dup_duplicate_page_forgery_is_rejected assert
rejection of proofs that were accepted on origin/main, so each is a genuine pass→fail hinge on
its fix.

The controls are what make the set non-vacuous, and they earned their place:

  • poc_control_honest_harness_verifies — honest use of the same harness must still produce a valid
    proof. This caught a real bug in the first draft of the DoS guard, which bounded the range's
    exclusive end; the stack's top page legitimately sits at 0xfffffffffffc0000, where that end is
    exactly 2^64, so every honest proof was rejected. With only "the forgery is rejected" criteria
    that would have read as success. Fixed to bound the last byte, pinned by
    the_top_page_of_the_address_space_is_accepted.
  • poc_negative_control_direct_init_tamper_on_preprocessed_page_fails — the same forged execution,
    but INIT overwritten directly on the target's own ELF-data page. The bus balances perfectly, so
    the only possible rejector is that page's preprocessed commitment — and it rejects. That isolates
    the mechanism: route 1 was precisely its absence on private pages, not a flaw in it.
  • poc_negative_control_forged_run_without_repointed_row_fails and
    dup_negative_control_without_compensating_row_fails — the forged execution without its
    compensating row is rejected, so the harness discriminates rather than accepting everything.

Plus page_layout_tests (10 tests) covering ELF-page aliasing, identical and overlapping ranges,
unaligned bases, u64::MAX / u64::MAX/2 / 2^40 counts, one-over-the-cap, zero count, and two
overflow shapes.

Full sweep: 539 passed (-p lambda-vm-prover), executor lib 29, crypto/ethrex-crypto 14,
make lint exit 0. The 5 remaining failures are the known missing
program_artifacts/recursion/*.elf set. Every continuation test passes, including
test_continuation_multipage_private_input, test_split_verify_rejects_{inflated,deflated,oversized}_num_private_input_pages
and test_commit_across_epochs_verifies; and both honest paths most exposed to the new layout
validation run green — test_prove_ef_io_demo_concatenates (private input) and
test_deep_stack_runtime_pages_roundtrip (runtime pages).

Scope of the class

Seven tables use EmptyConstraints: bitwise, bytewise, decode, halt, page, register, keccak_rc. All
receive with_preprocessed in VmAirs::new except bytewise and halt, and both are benign
here:

  • HALT's Memory-bus tokens use BusValue::constant(1) for is_register and a constant address — it
    cannot name an attacker-chosen address.
  • BYTEWISE is not on the Memory bus at all, and its columns are pinned by lookups into the
    preprocessed BYTE_ALU table.

So PAGE and GLOBAL_MEMORY were the only instances.

Two follow-ups, documented but not fixed here

  • minimal_bitwise has a non-preprocessed branch, currently reachable only from tests because
    all three production call sites pass false. A non-preprocessed BITWISE AIR would be a broader
    hole than this one: BITWISE backs AreBytes, so an unpinned table would let a witness prove an
    arbitrary field element is a byte. A comment at the branch now spells this out, and that a fourth
    caller passing true must give the minimal table its own commitment first.
  • The prover's precomputed-tree cache is keyed by the expected root and skips the rebuild check
    on a hit (crypto/stark/src/prover.rs:1161-1170). So a cold cache makes the prover refuse a
    tampered trace while a warm cache silently substitutes the correct tree and leaves rejection to the
    verifier. Soundness is unaffected — the verifier catches it either way — but "the prover would
    refuse" is not a safe assumption, and tests relying on it are order-dependent.

A private-input PAGE (and its continuation analogue GLOBAL_MEMORY) skipped
`with_preprocessed` entirely, so every column was prover-chosen main trace.
PAGE carries `EmptyConstraints` and no constraint anywhere references
`cols::OFFSET`, so nothing pinned it — and the Memory-bus address is
`address_lo = page_base_lo + OFFSET`. A witness could therefore point a row
at any address sharing the page's high limb and mint a second, forged
history for it, breaking the one-entry-per-address property the offline
memory-checking argument rests on. Reproduced end to end; see below.

INIT must stay main-trace — it is the private input, and the verifier must
not be able to recompute it. OFFSET has no such constraint: it is the dense
`0..page_size-1` enumeration, byte-identical for every page regardless of
program or input. Committing it alone binds exactly the column that must not
be prover-chosen and publishes nothing.

Approach: preprocess OFFSET only, rather than adding AIR constraints
(`OFFSET[0] = 0` plus `OFFSET[i+1] = OFFSET[i] + 1`). The constraint route
needs a real boundary constraint, and every VM table in this tree is built
with `NullBoundaryConstraintBuilder` — there is no boundary machinery to
follow, so that route means new infrastructure in the STARK layer. The
preprocessed route instead reuses the mechanism that already runs on every
proof for ELF-data and zero-init pages, and which `verifier.rs:1184-1213`
already enforces. The bug was that private pages bypassed that check; the
fix is to stop bypassing it for the one column that is public. It also costs
no constraint degree and no constraint-evaluation time.

Because OFFSET depends on neither program nor input, one commitment per
blowup factor covers every private page, and the same value serves
GLOBAL_MEMORY, whose OFFSET column is identical. Static constants follow the
existing `static_zero_page_commitment` pattern (generated by
`compute_static_commitments`, pinned by a drift test) with the same
recompute fallback off the standard coset.

Acceptance (full log in fix-acceptance.log):

  poc_control_honest_harness_verifies                         ... ok
  poc_negative_control_forged_run_without_repointed_row_fails ... ok
  poc_private_page_offset_forges_memory_contents              ... FAILED
    panicked: SOUNDNESS HOLE NOT REPRODUCED: verifier rejected the forged proof

The third failing is the point: that test asserts the forgery is ACCEPTED,
and it passed on origin/main. The first passing is what shows the fix is not
over-broad — honest proving still verifies. The PoC is converted into a
regression test in the follow-up commit.
Inverts the PoC's central assertion now that the fix is in: the forged
proof must be REJECTED. Renamed `poc_private_page_offset_forges_memory_contents`
-> `forged_private_page_offset_is_rejected`, and rewrote the module doc, which
still described the hole in the present tense.

The two controls are unchanged and are what stop this becoming a test that
passes for the wrong reason: `poc_control_honest_harness_verifies` fails if
the fix breaks honest proving (a verifier that rejects everything would
otherwise satisfy the assertion above), and
`poc_negative_control_forged_run_without_repointed_row_fails` fails if the
harness stops discriminating.

Also drops two imports the fix made unused.
`runtime_page_ranges` is a prover-chosen `VmProof` field with a free `u64`
base and count, and `page_configs_from_elf_and_runtime` expanded it with a
plain `for i in 0..count` push loop having validated nothing. The
`expected_proof_count` cross-check that would reject a wrong page count runs
*after* that loop, so it never got the chance:
`RuntimePageRange { base: 0, count: u64::MAX }` made the verifier allocate
`PageConfig`s until the process died — a verifier DoS on untrusted input.

The function is now fallible and takes a `max_pages` cap enforced before and
during expansion. The verifier passes `proofs.len()`: every page config needs
its own sub-proof, so a layout wanting more pages than the proof carries can
never verify. That makes the bound exact, needing no invented policy constant,
and unable to reject anything an honest prover produces.

Also validated up front, since all of it is attacker-controlled:
- `count == 0`, which the honest run-length encoding never emits;
- unaligned bases — which additionally keeps "same base" equivalent to
  "overlapping" for the duplicate check in the follow-up commit;
- ranges running off the end of the address space, which the push loop would
  otherwise wrap in release.

The overflow guard bounds the range's LAST BYTE, not its exclusive end. The
stack's top page legitimately sits at the very top of the address space
(`0xfffffffffffc0000`), where the exclusive end is exactly 2^64 and only the
last byte is representable — bounding the end instead rejects every honest
proof. A draft of this commit did exactly that; the PoC harness's honest
control caught it, and `the_top_page_of_the_address_space_is_accepted` now
pins it.

New `Error::MalformedPageLayout`. Test call sites pass `usize::MAX` — they
build layouts from honest data, not from a proof.
Second route to the violation the OFFSET binding closed, and this one needs no
private input and no free column.

`page_configs_from_elf_and_runtime` built a `Vec`, sorted it, and never
deduped. So a prover declares `RuntimePageRange { base: <a real ELF .data
page>, count: 1 }` and that address gets two PAGE tables: the ELF-data page
with the real INIT, and a duplicate zero-init page. Both carry correct,
verifier-recomputed preprocessed commitments — the duplicate matches the
shipped `static_zero_page_commitment` exactly — so nothing is forged at the
commitment layer, which is why pinning OFFSET does not touch it.

Two genesis tokens then exist for every address in that page. The offline
memory-checking argument needs the init set to hold exactly one entry per
address; with two, the real page's row consumes the duplicate's token and the
duplicate's row consumes the real one, and the bus balances while a value the
program never wrote reaches a load. Every other row of the duplicate page
self-cancels for free. `FINI`/`TIMESTAMP` are main-trace on every page, not
just private ones, which is what lets the two rows swap which token each
consumes.

Reject rather than dedupe silently: a duplicate is never legitimate — the
honest builder derives ELF pages from a `BTreeSet` and run-length-encodes the
rest — so silent dedup would mask a prover bug instead of surfacing it. The
check is a single adjacent-equality scan after the sort that already existed,
which covers all three config sources at once (ELF, runtime, private) and so
cannot be bypassed by adding a fourth. It relies on the alignment check from
the previous commit to be a complete *overlap* check and not merely an
equality one.

Severity note: the OFFSET fix does limit this. The injected value is always
`0`, since zero-init is the only page type a prover can conjure at an
arbitrary base — so it forces a chosen address to read `0` at genesis instead
of its real ELF byte. Still a forged execution (zeroing a length, a bound, a
chain-id or a root byte suffices), but not an arbitrary byte at an arbitrary
address.

The framing: pinning `OFFSET` restores one row per address *within* a page;
this restores one page per address. Both are needed.
Adopts the prosecutor's PoC harness (branch `poc/page-duplication`, 1bc1def6)
wholesale rather than keeping my thinner copy, and inverts the assertions the
way the OFFSET one was inverted. Their version is strictly better: it runs
under PRODUCTION proof options (`GoldilocksCubicProofOptions::with_blowup(2)`,
what public `verify` uses) instead of `default_test_options()`, and it carries
two controls mine lacked.

Eight tests, all passing, 24s:

- `poc_control_honest_harness_verifies` — non-vacuity. The one that catches an
  over-broad fix; it already caught one (see the `runtime_page_ranges` commit).
- `forged_private_page_offset_is_rejected` — route 1. Accepts refusal at either
  layer: `commit_main_trace` caches precomputed trees keyed by the expected
  root and skips the re-check on a hit, so a cold cache makes the prover refuse
  while a warm one leaves it to the verifier. Asserting one would be
  order-dependent.
- `poc_negative_control_forged_run_without_repointed_row_fails` — the forged
  run without the compensating row must fail, so the harness discriminates.
- `poc_negative_control_direct_init_tamper_on_preprocessed_page_fails` —
  rewrites INIT directly on the target's own ELF-data page. The bus balances
  perfectly, so the only possible rejector is that page's preprocessed
  commitment. It rejects: the mechanism works on ELF pages, and its absence on
  private ones was the whole of route 1.
- `poc_real_ethrex_inputs_produce_private_input_pages` — reachability on the
  workload that matters.
- `dup_structural_duplicate_page_coverage_is_rejected` — route 2's invariant in
  isolation: honest execution, every injected row self-cancelling, only the
  layout malformed. This is the one that flips pass→fail if the duplicate-base
  check is removed, and it cannot be satisfied by something incidental the way
  a forgery test might.
- `dup_negative_control_without_compensating_row_fails`
- `dup_duplicate_page_forgery_is_rejected` — route 2 end to end: ELF `.data`
  byte 0x11 read as 0x00, which was ACCEPTED against the unmodified ELF even
  after the OFFSET fix.

A rejection now arrives in two shapes — `Ok(false)` from inside STARK
verification, and `Err(MalformedPageLayout)` when the layout is refused before
any proof is checked — so `verifier_accepts` collapses both and the tests do
not have to care which fired. `craft_proof_with_duplicate_page` asserts the
layout rebuild fails on duplicate coverage specifically, then still runs the
full prove→verify path so the test stays end-to-end rather than degenerating
into a unit test of the check.

Also documents the test-only `minimal_bitwise` branch in `VmAirs::new`. That
BITWISE AIR has no preprocessed commitment, so its lookup table would be
prover-chosen — and since BITWISE backs `AreBytes`, an unpinned table would let
a witness prove an arbitrary field element is a byte. It is safe only because
all three production callers pass `false`; a fourth passing `true` would
reintroduce the hole silently.

The reconstruction-level tests in `page_layout_tests` stay: they cover shapes
these do not (overflow, unaligned bases, count bounds, the top-of-address-space
page).
CI failed on `poc_negative_control_direct_init_tamper_on_preprocessed_page_fails`:

    panicked at page_offset_forgery_poc.rs:455:
      this tamper leaves OFFSET alone, so the prover still builds it:
      PrecomputedCommitmentMismatch

The `.expect` message was wrong on its own terms. The tamper does leave OFFSET
alone, but it rewrites INIT on an ELF-data page — where the preprocessed columns
are OFFSET *and* INIT (`NUM_PREPROCESSED_COLS = 2`). So it touches a
preprocessed column after all, and `commit_main_trace` can reject it before a
proof exists.

Which layer fires is not deterministic. That function caches precomputed Merkle
trees keyed by *the expected root* and skips the rebuild check on a hit
(`crypto/stark/src/prover.rs:1161-1170`). A cold cache — a fresh CI runner —
rebuilds from the tampered column and refuses; a warm cache — a local run that
already proved something honest — substitutes the correct cached tree and lets
the verifier do the rejecting. Local runs were warm, CI is cold.

Both outcomes are rejections, so the test now accepts either via a shared
`proof_or_prover_refusal`, which still requires an `Err` to be specifically
`PrecomputedCommitmentMismatch` rather than any proving error. The test's
meaning is unchanged: it pins that the preprocessed commitment rejects a direct
INIT rewrite, which is what shows route 1 was that mechanism's *absence* on
private pages rather than a flaw in it. `forged_private_page_offset_is_rejected`
now shares the same helper instead of its own inline match.

Swept the rest of the file for the same assumption. The rule, now documented on
`Tamper`: a tamper touching a PREPROCESSED column may be refused at prove time
and must go through the helper; one touching only main-trace columns cannot be
and may keep `.expect(..)`. By that rule the three remaining `.expect`s are
sound, and each now says why rather than asserting it:
- the honest control — no tamper at all;
- the uncompensated forged run — the forged execution moves FINI/TIMESTAMP
  (main trace) while OFFSET/INIT still come from the honest ELF;
- duplicate-page injection — writes FINI only.

Verified both orderings: 8/8 serial (warm cache, verifier path exercised), and
each rejection test passing alone in a fresh process (cold cache, the CI path).
* drop the accidentally committed fix-acceptance.log'

* docs(page): fix a doc comment on the wrong fn
@MauroToscano
MauroToscano added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 483dc6e Aug 7, 2026
18 of 26 checks passed
@MauroToscano
MauroToscano deleted the fix/page-offset-binding branch August 7, 2026 18:37
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.

3 participants