Skip to content

feat(midnight-js): add ledger-8 engine core with envelope, down-convert and instance guards - #1164

Open
sp-io wants to merge 44 commits into
feat/1004-v8-subpath-loadv8from
feat/1004-engine-core
Open

feat(midnight-js): add ledger-8 engine core with envelope, down-convert and instance guards#1164
sp-io wants to merge 44 commits into
feat/1004-v8-subpath-loadv8from
feat/1004-engine-core

Conversation

@sp-io

@sp-io sp-io commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Overview

PR 1004-D of the MJS-01 series (#1004) - adds the ledger-8 engine core to @midnight-ntwrk/midnight-js-protocol. Stacked on #1159 (HF fixtures), which stacks on #1156 <- #1155. Merge order: #1155 -> #1156 -> #1159 -> this.

Now also carries #1186 (golden-fixture assertions), merged into this branch rather than landing as a separate PR.

Summary

  • src/lib/engine/envelope.ts - version-aware extractEncodedStateValue(raw, version, ledger8ContractState): decodes v8-era envelopes (pre-fork contract-state[v6]) and post-fork v9 envelopes into the shared EncodedStateValue POJO. version is validated against the decoder table before dispatch; an unknown value throws the coded UnknownLedgerVersionError rather than resolving through the prototype chain. Any decode failure is a typed DownConvertFailedError (MIDNIGHT_JS_P_DOWN_CONVERT_FAILED), never a silently empty state.
  • src/lib/engine/down-convert.ts - downConvertForExecution(state, ledger8Runtime): v9->v8 state bridge over the WASM packages' own encode()/decode() POJO round-trip. Structural integrity is checked by re-encoding and deep-comparing against the source. assertMerkleTreesRehashed walks the state and asserts every bounded Merkle tree has a readable root via checkRoot, throwing MerkleNotRehashedError (MIDNIGHT_JS_P_MERKLE_NOT_REHASHED); an unrecognised StateValue variant is a hard failure, not a silent skip.
  • src/lib/engine/instance-guard.ts - dual-WASM fail-fast: assertSharedLedger8Instance(axis, probeA, probeB) compares two class bindings obtained from two different acquisition paths (a namespace object is not a valid probe - a re-export produces a fresh one over a single physical copy). Rejects nullish probes; throws Ledger8InstanceMismatchError. The single genuine axis is onchain-runtime-v3.
  • src/errors.ts - four new error classes on the registry: Ledger8InstanceMismatchError, DownConvertFailedError, MerkleNotRehashedError, UnknownLedgerVersionError. Each error's code field is typed as its own literal, so instanceof and code cannot drift and consumers can switch exhaustively.
  • Golden fixtures (from test(midnight-js): assert the engine against the hard-fork goldens #1186) - src/test/engine-golden-fixtures.test.ts asserts the bridge against the committed hard-fork goldens: a real migrated on-chain state down-converts byte-identically to its pre-migration v8 form, a golden Merkle state comes out with a readable root, and every tampered golden fails closed without leaking bytes. packages/protocol/turbo.json declares the fixture directory as a test input so editing a golden invalidates the test cache.
  • Coverage - per-glob 100/100/100/100 thresholds for src/version.ts and all engine modules; package totals stay 100 and always-on.
  • Deps - @midnight-ntwrk/onchain-runtime-v3@3.1.0 as a protocol dependency, now also pinned in the root resolutions alongside every other vendor runtime. The devDep npm alias onchain-runtime-v3-alt gives the dual-instance negatives a real second physical WASM copy.

Engine modules are intentionally not exported from the root entry or ./v8 - the root stays v9-static; export surfacing lands with the execution legs (PR 1004-E) as a single lazy facade, loadLedger8Engine().

Review fixes applied on this branch

A multi-agent review of the original diff surfaced these; all are fixed here.

  1. Published error message named one npm scope twice. The dual-publish (.github/scripts/publish-public-npm.mjs) rewrites the old scope to the new one inside built .js/.d.ts files, not only in package.json. AXIS_PACKAGE_NAMES held both scoped names as literals, so the public-npm artifact collapsed them into one - and no test could see it, since the suite runs against unrewritten source. Scopes are now held apart from the / and joined at use; a test asserts the module carries no scoped literal at all.
  2. ENVELOPE_DECODERS was fail-open. A plain object literal indexed by version resolved unexpected keys through Object.prototype: 'constructor' handed the caller's own raw bytes back as an EncodedStateValue, 'toString' returned '[object Object]' - neither throwing, both reachable from untyped JS. The table is now null-prototype and frozen, and version is validated before dispatch. Validating first also keeps stage inside its closed union.
  3. The Merkle walk silently skipped unknown variants. The switch had no default and returns void, so a variant outside the five it names fell through having checked nothing and recursed into nothing. The compile-time exhaustiveness guard is now folded into the switch as a never default that also throws at runtime - the pinned .d.ts cannot vouch for a caller-injected runtime's WASM.
  4. checkRoot mishandled a fallible vendor binding. The wasm-bindgen shim for root() rethrows a Rust Err; that throw escaped uncoded and was relabelled DOWN_CONVERT_FAILED, pointing the caller at its envelope bytes when the fix is to rehash. A throw and a null are now both reported as MERKLE_NOT_REHASHED, carrying the cause.
  5. structurallyEqual compared key counts, not key sets - two objects sharing no key compared equal whenever the diverging key's value was undefined. Unreachable in today's algebra; guarded so it stays that way.
  6. Instance-guard contract narrowed to a class binding. A re-export - the case the error message names first - produces a fresh namespace object over one physical copy, so a namespace comparison would report a mismatch on a healthy install.
  7. Tests were single-codec where production is cross-codec. Every container test built both sides of the re-encode comparison with onchain-runtime-v3; the only genuinely cross-codec case used a bare cell. Added v9-native multi-entry-map and nested-array cases, the v8 decoder's truncated/trailing-bytes paths, the v9 extraction stage, and an explicit pin on Map order-sensitivity. test(midnight-js): assert the engine against the hard-fork goldens #1186's goldens close the rest.
  8. Two comments claimed more than they delivered - the dist-laziness onchain-runtime-v3 case passes today because no build entry reaches the engine at all, and the vendor drift detectors are checked by the pre-push hook only, never by CI. Both now say so.

Second review round (VanessaPC), fixes applied

Five of the eight inline comments are fixed in fc8f22d; 20 tests red first, all green after. Package stays 239/239, coverage 100/100/100/100, lint clean.

  1. downConvertForExecution validated nothing about ledger8Runtime. A runtime missing a binding this function dereferences raised a bare TypeError that the catch relabelled DOWN_CONVERT_FAILED - an error whose message sends the caller to audit input bytes that are fine. It now throws LEDGER8_RUNTIME_INVALID naming the absent member, matching the check the envelope seam already had. Only the two members it actually calls are checked; ContractState is on the interface to pin the era and is never dereferenced here.
  2. Ledger8RuntimeInvalidError's remediation pointed at the wrong package. It told callers to pass the ContractState that loadLedger8() exposes. loadLedger8() resolves @midnightntwrk/ledger-v8, which (verified against the published 8.1.1 tarball: zero dependencies, ledger-v8.d.ts:765) exports its own ContractState with a matching static deserialize on a separate WASM instance. Following the message therefore yielded a class that passes the duck-typed guard and decodes on the wrong copy. The message now names onchain-runtime-v3 and says plainly that this package exposes no accessor for it.
  3. UnknownLedgerVersionError shipped undocumented. Its doc block sat immediately before a second doc block, so it was discarded and the class emitted with no typings comment. Moved down to the class it documents; confirmed in dist/errors.d.ts.
  4. A nullish instance probe was diagnosed as a dual-instantiation. A caller who omitted a probe was told two physical copies exist and sent to npm why after a duplicate that is not there. Nullish probes now throw LEDGER8_RUNTIME_INVALID - the same code the envelope seam uses for a binding handed over incomplete - and the mismatch error is reserved for an actual reference-equality failure.
  5. The axis package-name table was fail-open. It kept its prototype and axis was never validated, so a non-axis string rendered an Object.prototype member as the npm package to trace (@scope/function Object() { [native code] }). The table is now null-prototype and frozen, and axis is validated against a closed, satisfies-checked set in instance-guard.ts before any probe is compared, throwing the new UnknownLedger8AxisError (MIDNIGHT_JS_P_UNKNOWN_LEDGER8_AXIS). The axis set lives in the engine module rather than in errors.ts, which is a build entry - nothing outside needs to test an axis, and satisfies fails the build if the union grows.

Not applied:

  • vitest.config.ts:52 - the comment is correct as written. The claim that a glob-matched file leaves the global aggregate does not hold for the pinned Vitest 4.1.7: resolveThresholds adds every file to the global map, under its own comment // Global threshold is for all files, even if they are included by glob patterns. That was the behaviour in older majors, not this one, so the per-glob entries are an extra gate rather than a replacement - which is what the comment says.
  • down-convert.ts:212/215/219 (the ! assertions) - the consistency argument holds, but each new guard needs a fake-runtime test to keep the per-file 100% branch floor, so it is a follow-up rather than part of this round.
  • envelope.ts:115 (v9 requiring the pre-fork runtime) - kept as it is, docblock corrected in 64a5068. The comment defended the unconditional requirement by the cost to a v9-only consumer; there is no such consumer. extractEncodedStateValue has one call site in the whole stack - createLedger8Engine() in feat(midnight-js): add ledger-8 circuit execution and keep-state wrap behind the loadLedger8Engine facade #1168/feat(midnight-js): add v8-native tx composition incl. deploy machinery #1165 - and it has already awaited onchain-runtime-v3 two lines earlier to assemble the runtime for downConvertForExecution. The v9 decoder is there so the bridge can read a post-fork envelope before down-converting it; a caller with no pre-fork runtime reads ledger-v9 directly and never reaches this function. Note also that overloads (or a discriminated parameter object) would not compile against that call site, which passes version as a LedgerVersion union value rather than a literal. The docblock now gives the real reason and records the condition that flips it: if this seam is ever surfaced beyond the engine, make the parameter optional and move the check into the 'v8' decoder.

Downstream: assertSharedLedger8Instance and downConvertForExecution change only which coded error a misuse produces, and no signature moved, so #1168 and #1165 need a merge-forward but no code change.

Dependent PRs: already synced

The stack has been merged forward, so #1168 and #1165 already carry these fixes.

Both call extractEncodedStateValue(raw, version, ocrt3.ContractState) and assertSharedLedger8Instance('onchain-runtime-v3', ocrt3.ChargedState, glue.ChargedState) - class bindings, matching the narrowed contract - so no signature changes were needed downstream.

Also worth knowing for 1004-E: onchain-runtime-v3 stays a runtime dependency (not a devDependency), because #1168 imports it dynamically in production.

Test plan

Submission Checklist

  • Useful pull request description
  • Tests are provided (if possible)
  • Key commits have useful messages
  • All check jobs of the CI have succeeded
  • Self-reviewed the diff
  • Reviewer requested
  • Update README.md file (if relevant) - n/a
  • Update documentation (if relevant) - n/a
  • No new todos introduced

Links

sp-io added 11 commits August 18, 2026 10:57
Needed by the OQ9 hard-fork fixture generators (task 0.2) to mint and
verify contract-state fixtures across the v8/v9 protocol boundary.
Add testkit-js/testkit-js/src/fixtures/hf/: nine named contract-state
hex fixtures spanning the ledger v8/v9 protocol boundary, a v9-era
compiled twin of the spike's counter contract, generator scripts to
mint/derive the fixtures, and a README documenting provenance and the
mint-path decisions per fixture.

Two fixtures (state-v8-v6-envelope.hex, state-migrated-v9.hex) are
golden copies ported verbatim from spike-dapp-hf/island-3 (identical
across islands 1-3 and ts-downcast/); the rest are minted or derived
from those goldens via @midnightntwrk/ledger-v8 and ledger-v9's public
APIs, with every deserialize outcome verified against the real
packages before being documented.
Asserts every fixture exists and parses as hex, the v8/v9 goldens and
the synthetic merkle fixture deserialize with their intended ledger,
and every tampered/foreign fixture is rejected by both ledger-v8 and
ledger-v9.
Adds a narrow no-restricted-imports exception for the hard-fork
fixture smoke test (it must verify both raw ledger-v8 and ledger-v9
directly, which the protocol package's ledger re-export cannot do
since it only re-exports v9), and declares the Node globals the .mjs
fixture generator scripts use (they are plain scripts, not covered by
the existing testkit-js *.ts lint block).
state-co-v2-only-foreign.hex was a bare ContractOperation, which fails
at the envelope-tag check before reaching the mis-dispatch path it was
meant to test. Now a well-formed ContractState (deserializes cleanly
on ledger-v9) whose increment slot carries the golden migrated
state's real, foreign post operation, so the failure only surfaces at
execution.
Adds explicit protocolVersion assertions per fixture (incl. the null
on state-both-keys.hex) and strict disk/manifest set equality, so
drift between fixtures.json and the files is caught. Also updates the
state-co-v2-only-foreign.hex assertions for its reworked shape: it
must now deserialize cleanly on ledger-v9 and expose a foreign key,
not throw.
… (D11)

Adds the first ledger-8 engine modules to packages/protocol: version-aware
contract-state envelope extraction (v8/v6 and migrated-v9) and the v9->v8
state down-convert with contract-agnostic bounded-Merkle-tree rehashing,
productionizing the HF spike's downcast/rehash algorithm against
@midnight-ntwrk/onchain-runtime-v3 directly.

Adds DownConvertFailedError and MerkleNotRehashedError to errors.ts for the
previously-registered DOWN_CONVERT_FAILED / MERKLE_NOT_REHASHED codes. The
new engine modules stay internal (not wired into index.ts/v8.ts); the root
entry point remains v9-static.
The array/map "rehash recursion" tests routed nested trees through an
encode()/decode() round trip before calling downConvertForExecution, on the
mistaken premise that the round trip strips a tree's cached hashes. On the
pinned onchain-runtime-v3/ledger-v9 versions it doesn't: encode() already
materializes node hashes regardless of prior rehash state, so checkRoot
passed even with a broken (non-recursing) array/map case in rehashStateValue.

Exports rehashStateValue for direct testing and rewrites both tests to
assert a genuine before/after transition (checkRoot throws pre-call, passes
post-call) against trees that have never been serialized, so a broken
recursion is caught regardless of runtime-version hash-materialization
behaviour. Verified via mutation: reverting the array/map cases to a
passthrough fails exactly these two tests.
Add assertSharedLedger8Instances (constructor-reference-equality probes
on the 0.16 runtime and ledger-v9 axes) and assertLedger8RuntimePresent
(probes the retained v8 stack resolves before any fetch/proving starts)
to the engine module, plus Ledger8InstanceMismatchError. Dual-instance
negatives use aliased devDependencies (onchain-runtime-v3-alt,
ledger-v9-alt) that are real second physical copies at the pinned
versions. Adds per-glob coverage thresholds for version.ts and the
engine files.
… gaps

Reject a nullish value on either side of each dual-instantiation axis in
assertSharedLedger8Instances before the reference-equality check, closing
a vacuous-pass on undefined/null probes. Also drops a stale workspace-file
reference from a test comment, covers the documented wrong-version-envelope
DownConvertFailedError behavior with the purpose-built fixtures, and adds
the missing construction-contract test for Ledger8InstanceMismatchError.
@sp-io
sp-io requested a review from a team as a code owner August 18, 2026 14:42
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor
Title Lines Statements Branches Functions
contracts Coverage: 94%
93.96% (545/580) 86.34% (234/271) 94.26% (115/122)
dapp-connector-proof-provider Coverage: 100%
100% (11/11) 100% (4/4) 100% (5/5)
fetch-zk-config-provider Coverage: 100%
100% (54/54) 93.54% (29/31) 100% (13/13)
http-client-proof-provider Coverage: 91%
91.42% (64/70) 91.66% (33/36) 78.57% (11/14)
indexer-public-data-provider Coverage: 80%
80.43% (403/501) 77.44% (206/266) 68.32% (110/161)
level-private-state-provider Coverage: 93%
93.04% (589/633) 82.82% (246/297) 100% (86/86)
logger-provider Coverage: 100%
100% (15/15) 100% (0/0) 100% (8/8)
midnight-js Coverage: 100%
100% (0/0) 100% (0/0) 100% (0/0)
node-zk-config-provider Coverage: 97%
97.18% (69/71) 88.88% (32/36) 100% (17/17)
utils Coverage: 98%
98.08% (307/313) 95.63% (197/206) 96.36% (53/55)

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Unit Test Results - MidnightJS

   28 files  ±  0    170 suites  +6   5m 12s ⏱️ +4s
1 397 tests +121  1 395 ✅ +121  2 💤 ±0  0 ❌ ±0 
2 808 runs  +242  2 804 ✅ +242  4 💤 ±0  0 ❌ ±0 

Results for commit ee118d5. ± Comparison against base commit 15bf019.

♻️ This comment has been updated with latest results.

…olchain version

CompactRuntime016 -> Ledger8CompactRuntime (+StateValue), runtime016 ->
ledger8Runtime. The retained toolchain versions (compact-runtime 0.16.x,
onchain-runtime-v3) can bump within the ledger-8 era; the era tag is the
stable key, matching the existing Ledger8* error family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MidnightCI

MidnightCI commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

E2E Tests Results

e2e_tests_reports: Run #4902

Tests 📝 Passed ✅ Failed ❌ Skipped ⏭️ Pending ⏳ Other ❓ Flaky 🍂 Duration ⏱️
145 140 0 5 0 0 0 1h 23m

🎉 All tests passed!

Suites

140 passed, 0 failed, and 5 other

Suite Passed Failed Other Duration
✅ testkit-js/testkit-js-e2e/test/contracts.blocktime.it.test.ts
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should succeed when both device time and node time are less than future time
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should fail immediately on device when device time is already past the check time
        ⏭️ Block Time Contract Tests 1 > blockTimeLt tests > should succeed on device but fail on node when submission is delayed
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should succeed when both device time and node time are greater than past time
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should fail immediately on device when device time is less than check time
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should succeed even with submission delay when checking past time
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should succeed when both device time and node time are greater than past time
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should fail when device time is not greater than check time
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should succeed when both device time and node time are less than or equal to future time
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should fail immediately on device when device time exceeds check time
        ⏭️ Block Time Contract Tests 1 > blockTimeLt tests > should succeed on device but fail on node when submission delay causes time to exceed threshold
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should demonstrate different failure points for Lt check > Immediate past time - fails on device
        ⏭️ Block Time Contract Tests 1 > blockTimeLt tests > should demonstrate different failure points for Lt check > Near future time with delay - succeeds on device, fails on node
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should demonstrate different failure points for Lt check > Far future time - succeeds on both device and node
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should handle maximum time values
        ✅ Block Time Contract Tests 1 > blockTimeLt tests > should handle zero time value
✅ 13 ❌ 0 ⏭️ 3 2m 47s
✅ testkit-js/testkit-js-e2e/test/contracts.blocktime2.it.test.ts
        ✅ Block Time Contract Tests 2 > blockTimeLt tests > should demonstrate different failure points for Lt check > Immediate past time - fails on device
        ⏭️ Block Time Contract Tests 2 > blockTimeLt tests > should demonstrate different failure points for Lt check > Near future time with delay - succeeds on device, fails on node
        ✅ Block Time Contract Tests 2 > blockTimeLt tests > should demonstrate different failure points for Lt check > Far future time - succeeds on both device and node
        ✅ Block Time Contract Tests 2 > blockTimeLt tests > should handle maximum time values
        ✅ Block Time Contract Tests 2 > blockTimeLt tests > should handle zero time value
✅ 4 ❌ 0 ⏭️ 1 1m 11s
✅ testkit-js/testkit-js-e2e/test/contracts.events.provider.it.test.ts
        ✅ Contract events — provider read surface (E2E) > query filters > omitted types returns the full corpus
        ✅ Contract events — provider read surface (E2E) > query filters > a single-type filter returns exactly that type
        ✅ Contract events — provider read surface (E2E) > query filters > a multi-type filter returns exactly the requested types
        ✅ Contract events — provider read surface (E2E) > query filters > a matching fieldPrefix returns the event with the full field value
        ✅ Contract events — provider read surface (E2E) > query filters > a non-matching fieldPrefix returns an empty array, not an error
        ✅ Contract events — provider read surface (E2E) > query filters > an empty-string prefix matches every event of the filtered type
        ✅ Contract events — provider read surface (E2E) > query filters > toBlock before the first emission returns an empty array
        ✅ Contract events — provider read surface (E2E) > query filters > fromBlock excludes events emitted in earlier blocks
        ✅ Contract events — provider read surface (E2E) > query filters > an inclusive fromBlock/toBlock window returns exactly the events inside it
        ✅ Contract events — provider read surface (E2E) > subscription > replays historical events in emission order
        ✅ Contract events — provider read surface (E2E) > subscription > delivers an event emitted after the subscription opened
        ✅ Contract events — provider read surface (E2E) > subscription > startAt { fromId } resumes inclusively at that event
        ✅ Contract events — provider read surface (E2E) > subscription > toBlock bounds the stream and completes it server-side
        ⏭️ Contract events — provider read surface (E2E) > emits a Misc event with the full structure and emitted values
✅ 13 ❌ 0 ⏭️ 1 24.1s
✅ testkit-js/testkit-js-e2e/test/contracts.events.unshielded.it.test.ts
        ✅ Contract events — Unshielded* (E2E) > emits an UnshieldedSpend event with sender, token type and amount
        ✅ Contract events — Unshielded* (E2E) > emits an UnshieldedReceive event with recipient, token type and amount
        ✅ Contract events — Unshielded* (E2E) > emits an UnshieldedBurn event with sender, token type and amount
✅ 3 ❌ 0 ⏭️ 0 1m 11s
✅ testkit-js/testkit-js-e2e/test/contracts.it.test.ts
        ✅ Contracts API > should create unproven call and deploy transactions for contract with private state
        ✅ Contracts API > should deploy contract on the chain [@slow]
        ✅ Contracts API > should return deployed contract if it exists on specific address
        ✅ Contracts API > should return deployed contract if it exists on specific address without initialPrivateState
        ✅ Contracts API > should throw error if contract address has wrong format - length
        ✅ Contracts API > should return deployed contract if it exists on specific address with initialPrivateState and empty local private state store
        ✅ Contracts API > should return deployed contract if it exists on specific address with different initialPrivateState
        ✅ Contracts API > should wait indefinitely until contract exists on specific address [@slow]
        ✅ Contracts API > should throw for incompatible contract types that differ by circuit ids
        ✅ Contracts API > should throw for incompatible contract types with same shape but different verifier keys
        ✅ Contracts API > should return contract interface and execute circuit operations [@slow]
        ✅ Contracts API > should throw error on undefined public state at wrong address
        ✅ Contracts API > should submit a deploy transaction [@slow]
        ✅ Contracts API > should submit transaction that calls circuit in contract [@slow]
        ✅ Contracts API > should throw error if private state is undefined
        ✅ Contracts API > should throw error if public state is undefined
        ✅ Contracts API > should throw error if contract address has wrong format - not hex
        ✅ Contracts API > should return the latest observed state of a deployed contract and is independent of the chain state
        ✅ Contracts API > should wait indefinitely until state change, if stopped returns last contract state [@slow]
✅ 19 ❌ 0 ⏭️ 0 3m 23s
✅ testkit-js/testkit-js-e2e/test/contracts.scopedtx.it.test.ts
        ✅ Scoped Transaction Contract Tests > should submit scoped transaction that calls circuit in contract [@slow]
        ✅ Scoped Transaction Contract Tests > should submit scoped transaction that calls 2 different circuits in contract [@slow]
        ✅ Scoped Transaction Contract Tests > should submit scoped transaction that calls 2 circuits in contract and DOES NOT preserve execution order [@slow]
        ✅ Scoped Transaction Contract Tests > should not submit scoped transaction when one circuit call fails [@slow]
✅ 4 ❌ 0 ⏭️ 0 53.7s
✅ testkit-js/testkit-js-e2e/test/contracts.signing-key.it.test.ts
        ✅ Contract maintenance authority signing keys > should replace the contract maintenance authority across schnorr and ecdsa signing keys [@slow]
        ✅ Contract maintenance authority signing keys > should replace the contract maintenance authority using ecdsa signing keys [@slow]
        ✅ Contract maintenance authority signing keys > should remove and re-insert a verifier key authorized by an ecdsa signing key [@slow]
✅ 3 ❌ 0 ⏭️ 0 2m 25s
✅ testkit-js/testkit-js-e2e/test/contracts.singlecontract.nostate.it.test.ts
        ✅ Contracts API > should deploy and find contracts with no private state [@slow]
        ✅ Contracts API > should create unproven call and deploy transactions for contract with no private state
        ✅ Contracts API > should submit deploy and call transactions for contracts with no private state [@slow]
✅ 3 ❌ 0 ⏭️ 0 1m 32s
✅ testkit-js/testkit-js-e2e/test/contracts.snarkupgrade.it.test.ts
        ✅ Contracts API Snark Upgrade [dedicated contract] [@slow] > should successfully remove verifier key using submitRemoveVerifierKeyTx
        ✅ Contracts API Snark Upgrade [dedicated contract] [@slow] > should successfully remove verifier key using createContractMaintenanceTxInterface
        ✅ Contracts API Snark Upgrade [dedicated contract] [@slow] > should successfully remove verifier key and disable circuit operation
        ✅ Contracts API Snark Upgrade [dedicated contract] [@slow] > should succeed on verifier key insertion retry after removal
        ✅ Contracts API Snark Upgrade [dedicated contract] [@slow] > should fail when inserting verifier key for wrong circuit after removal
✅ 5 ❌ 0 ⏭️ 0 3m 21s
✅ testkit-js/testkit-js-e2e/test/contracts.snarkupgrade.singlecontract.it.test.ts
        ✅ Contracts API Snark Upgrade [single contract] > submitReplaceAuthorityTx - successful replace authority with new key[@slow]
        ✅ Contracts API Snark Upgrade [single contract] > submitReplaceAuthorityTx - successful replace authority with same key [@slow]
        ✅ Contracts API Snark Upgrade [single contract] > submitReplaceAuthorityTx - should fail on replace contract that is not deployed to contract address
        ✅ Contracts API Snark Upgrade [single contract] > submitReplaceAuthorityTx - should fail when signing key for contract address does not exist
        ✅ Contracts API Snark Upgrade [single contract] > submitInsertVerifierKeyTx - should fail on invalid verifier key
        ✅ Contracts API Snark Upgrade [single contract] > submitInsertVerifierKeyTx - successful insert on not present circuitId [@slow]
        ✅ Contracts API Snark Upgrade [single contract] > submitInsertVerifierKeyTx - should fail on contract not present on contract address
        ✅ Contracts API Snark Upgrade [single contract] > submitInsertVerifierKeyTx - should fail on providers for different contract with different API
        ✅ Contracts API Snark Upgrade [single contract] > submitRemoveVerifierKeyTx - should fail on not present circuitId
        ✅ Contracts API Snark Upgrade [single contract] > submitRemoveVerifierKeyTx - should fail on contract not present on contract address
        ✅ Contracts API Snark Upgrade [single contract] > submitRemoveVerifierKeyTx - should fail on providers for different contract with different API
        ✅ Contracts API Snark Upgrade [single contract] > createContractMaintenanceTxInterface - replaceAuthority - successful replace authority with the new one [@slow]
        ✅ Contracts API Snark Upgrade [single contract] > createContractMaintenanceTxInterface - replaceAuthority - successful replace authority with the same one [@slow]
        ✅ Contracts API Snark Upgrade [single contract] > createContractMaintenanceTxInterface - replaceAuthority - should fail on contract not present on contract address
        ✅ Contracts API Snark Upgrade [single contract] > createContractMaintenanceTxInterface - insertVerifierKey - fail when key is still present
        ✅ Contracts API Snark Upgrade [single contract] > createContractMaintenanceTxInterface - insertVerifierKey - success when no key present [@slow]
        ✅ Contracts API Snark Upgrade [single contract] > createCircuitMaintenanceTxInterfaces - insertVerifierKey - fail when key is already present
        ✅ Contracts API Snark Upgrade [single contract] > createCircuitMaintenanceTxInterfaces - insertVerifierKey - success when no key present [@slow]
        ✅ Contracts API Snark Upgrade [single contract] > createCircuitMaintenanceTxInterfaces - removeVerifierKey - should fail on contract not present on contract address
✅ 19 ❌ 0 ⏭️ 0 3m 36s
✅ testkit-js/testkit-js-e2e/test/contracts.snarkupgrade.smoke.it.test.ts
        ✅ Contracts API Snark Upgrade [@slow][@smoke] > should update verifier keys from one contract to another [@smoke]
        ✅ Contracts API Snark Upgrade [@slow][@smoke] > should fail on operate with previous authority after replacement
✅ 2 ❌ 0 ⏭️ 0 3m 38s
✅ testkit-js/testkit-js-e2e/test/dapp-connector-proving.it.test.ts
        ✅ DApp Connector Proving > should deploy and call contract using dapp-connector-proof-provider with wallet-delegated proving [@slow]
✅ 1 ❌ 0 ⏭️ 0 40.0s
✅ testkit-js/testkit-js-e2e/test/fee-mint.segment-routing.it.test.ts
        ✅ Fee-mint segment routing — regression #731 > mintShieldedToken + receiveUnshielded: minted output shares the fallible segment with the unshielded fee
        ✅ Fee-mint segment routing — regression #731 > mintShieldedToken + receiveShielded: minted output shares the fallible segment with the shielded fee
✅ 2 ❌ 0 ⏭️ 0 334ms
✅ testkit-js/testkit-js-e2e/test/indexer-public-data-provider.observable1.it.test.ts
        ✅ Indexer API > should return the history of states starting from defined blockHash (inclusive:true, expected:1,2) [@slow]
        ✅ Indexer API > should return the history of states starting from defined blockHash (inclusive:false, expected:2) [@slow]
        ✅ Indexer API > should return the history of states starting from defined txId (inclusive:true, expected states:1,2) [@slow]
        ✅ Indexer API > should return the history of states starting from defined txId (inclusive:false, expected states:2) [@slow]
✅ 4 ❌ 0 ⏭️ 0 3m 36s
✅ testkit-js/testkit-js-e2e/test/indexer-public-data-provider.observable2.it.test.ts
        ✅ Indexer API > should return the history of states starting from defined blockHeight (inclusive:true, expected states:1,2) [@slow]
        ✅ Indexer API > should return the history of states starting from defined blockHeight (inclusive:false, expected states:2) [@slow]
        ✅ Indexer API > should return the entire history of states of the contract with the given address (config:{ type: 'all' }, expected states:0,1,2) [@slow]
        ✅ Indexer API > should return the history of states of the contract with the given address, starting with the most recent state (config:{ type: 'latest' }, expected states:1,2) [@slow]
✅ 4 ❌ 0 ⏭️ 0 3m 37s
✅ testkit-js/testkit-js-e2e/test/indexer-public-data-provider.singlecontract.it.test.ts
        ✅ Indexer API > queryDeployContractState - should return a contract state equivalent to the initial contract state produced during deployment construction
        ✅ Indexer API > queryContractState - should return the current contract state of a deployed contract
        ✅ Indexer API > queryContractState - should return the current contract state of a deployed contract at defined block height
        ✅ Indexer API > queryContractState - should return the current contract state of a deployed contract at defined block hash
        ✅ Indexer API > queryContractState - should return null on no contract at contract address
        ✅ Indexer API > queryZSwapAndContractState - should return the current ZSwap chain state and contract state of a deployed contract
        ✅ Indexer API > queryZSwapAndContractState - should return null on no contract at contract address
        ✅ Indexer API > watchForDeployTxData - should return the data of the transaction containing the deployment of the contract with the given address
        ✅ Indexer API > watchForTxData - should return the data of the transaction containing the contract call with the given transaction id
        ✅ Indexer API > watchForContractState - should immediately return the current state of a deployed contract
✅ 10 ❌ 0 ⏭️ 0 112ms
✅ testkit-js/testkit-js-e2e/test/level-private-state-provider.it.test.ts
        ✅ Level Private State Provider - Export/Import Integration > should preserve private state after database recreation [@slow]
✅ 1 ❌ 0 ⏭️ 0 53.3s
✅ testkit-js/testkit-js-e2e/test/proof-server.it.test.ts
        ✅ Proof server integration > should create proofs successfully for deploy and call transactions
        ✅ Proof server integration > should create proofs with transactions that has succesfull well-formedness
        ✅ Proof server integration > should execute 5 proveTx calls in parallel without errors
✅ 3 ❌ 0 ⏭️ 0 563ms
✅ testkit-js/testkit-js-e2e/test/shielded.advanced.it.test.ts
        ✅ Shielded tokens - advanced operations > should mint and send immediate shielded tokens
        ✅ Shielded tokens - advanced operations > should mint and burn shielded tokens
✅ 2 ❌ 0 ⏭️ 0 1m 10s
✅ testkit-js/testkit-js-e2e/test/shielded.fallible-segment-routing.it.test.ts
        ✅ Shielded segment routing — regression #876 > user-bound shielded coin lands in the fallible offer when the circuit is fallible
✅ 1 ❌ 0 ⏭️ 0 242ms
✅ testkit-js/testkit-js-e2e/test/shielded.transfer.it.test.ts
        ✅ Shielded tokens > should mint tokens
        ✅ Shielded tokens > should deposit shielded coin via receiveShielded (issue #686)
✅ 2 ❌ 0 ⏭️ 0 1m 23s
✅ testkit-js/testkit-js-e2e/test/unshielded.balance.it.test.ts
        ✅ Unshielded tokens - balance > should get balance of tokens - 0 value
        ✅ Unshielded tokens - balance > should get balance of tokens - minted amount
        ✅ Unshielded tokens - balance > should get balance of tokens - greater than - false
        ✅ Unshielded tokens - balance > should get balance of tokens - greater than - true
        ✅ Unshielded tokens - balance > should get balance of tokens - less than - false
        ✅ Unshielded tokens - balance > should get balance of tokens - less than - true
        ✅ Unshielded tokens - balance > should get balance of tokens - greater than or equal - true (equal)
        ✅ Unshielded tokens - balance > should get balance of tokens - greater than or equal - false
        ✅ Unshielded tokens - balance > should get balance of tokens - less than or equal - true (equal)
        ✅ Unshielded tokens - balance > should get balance of tokens - less than or equal - false
✅ 10 ❌ 0 ⏭️ 0 3m 5s
✅ testkit-js/testkit-js-e2e/test/unshielded.cross-wallet-transfer.it.test.ts
        ✅ Unshielded cross-wallet transfer (issue #720) > should send night tokens to different wallet via right<>(disclose(addr))
        ✅ Unshielded cross-wallet transfer (issue #720) > should send night tokens to different wallet via disclose(recipient) (issue #720)
✅ 2 ❌ 0 ⏭️ 0 36.0s
✅ testkit-js/testkit-js-e2e/test/unshielded.mint-and-send.it.test.ts
        ✅ Unshielded tokens - mint and send variants > should mint tokens to contract address (self)
        ✅ Unshielded tokens - mint and send variants > should mint tokens to user address
        ✅ Unshielded tokens - mint and send variants > should send tokens to self
        ✅ Unshielded tokens - mint and send variants > should send tokens to contract address (self)
✅ 4 ❌ 0 ⏭️ 0 2m 11s
✅ testkit-js/testkit-js-e2e/test/unshielded.transfer.it.test.ts
        ✅ Unshielded tokens > Custom color > should mint different tokens
        ✅ Unshielded tokens > Custom color > should receive tokens - invalid
        ✅ Unshielded tokens > Custom color > should send tokens to wallet
        ✅ Unshielded tokens > Custom color > should receive tokens from wallet
        ✅ Unshielded tokens > Native color > should transfer night from wallet to contract - receiveNightTokens
        ✅ Unshielded tokens > Native color > should transfer night to wallet - sendNightTokensToUser
✅ 6 ❌ 0 ⏭️ 0 2m 12s

Github Test Reporter by CTRF 💚

🔄 This comment has been updated

sp-io and others added 6 commits August 19, 2026 14:43
… preflight

loadLedger8 already wraps every acquisition failure in
Ledger8RuntimeMissingError, so a separate preflight assert adds no
guarantee a caller could not get from awaiting loadLedger8 directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e onchain-runtime-v3 axis

The 4-argument assertSharedLedger8Instances compared the ledger-v9 axis
against the same static import on both sides, so that comparison could
never fail; only the retained 0.16 runtime axis has two genuine
acquisition paths (this package's own dependency vs the copy the 0.16
glue resolves). The guard is now assertSharedLedger8Instance(axis,
expected, actual), Ledger8InstanceAxis names only the axis production
code asserts, and the now-unused ledger-v9-alt test alias is dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… rebuilding the state

downConvertForExecution rebuilt the whole decoded StateValue tree
(rehashing every bounded Merkle tree) on every call, even though the
encode/decode round trip every input crosses already materializes the
hashes on the pinned versions — a full deep copy per execution prep with
no effect. The walk is now read-only: assertMerkleTreesRehashed checks
each tree's root via checkRoot and throws MerkleNotRehashedError on a
rootless tree, surfacing an upstream programming error loudly instead of
silently repairing it. Ledger8CompactRuntimeStateValue shrinks to the
decode seam actually used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	packages/protocol/src/test/errors.test.ts
Extends the code-based `Symbol.hasInstance` to the three classes this PR adds
(instance mismatch, down-convert failure, merkle not rehashed) and gives each a
recognition row, so `instanceof` still holds for an error raised by a second
physical copy of the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The structural check on a decoded state only caught a wholesale collapse
to null, so a shortened array, a dropped map entry or a substituted subtree
passed through as a valid execution state. It now compares a re-encode of
the decoded value against its source, which catches every loss mode at
every depth. The Merkle walk and ChargedState construction moved inside the
coded-error handling, so a dual-instantiation or a walk failure can no
longer escape as a bare WASM TypeError past a seam whose whole contract is
code-based discrimination.

envelope.ts no longer imports onchain-runtime-v3 as a value: the pre-fork
ContractState is injected the way down-convert.ts already injects its
runtime, so no bundle reaching these modules statically links the retained
pre-fork WASM. Its version ternary became a Record keyed by LedgerVersion,
so a future era added to LEDGER_VERSIONS fails to compile here instead of
silently decoding with the pre-fork codec.

assertMerkleTreesRehashed now handles every StateValue variant explicitly
and is backed by a compile-time exhaustiveness guard in the style of
version.ts, so a vendor bump that adds a container variant cannot silently
skip the Merkle trees nested inside it.

Error corrections: Ledger8RuntimeMissingError no longer names
compact-runtime@0.16.0, which this repo does not install and never did;
Ledger8InstanceMismatchError no longer claims a dual-instantiation corrupts
results silently, because wasm-bindgen throws on every cross-copy handoff,
and its yarn why hint now names the real package instead of a placeholder;
DownConvertFailedError.stage is a closed union, which keeps the never-render-
state-contents guarantee a property of the class rather than of every
caller. The down-convert stage no longer claims a v9 source it cannot know.
@sp-io
sp-io requested a review from a team as a code owner August 20, 2026 19:08
@sp-io
sp-io changed the base branch from feat/1004-hf-fixtures to feat/1004-v8-subpath-loadv8 August 20, 2026 19:23
sp-io added 2 commits August 20, 2026 21:45
The hard-fork goldens belong to #1184, which lands them on main together
with a typed accessor. Carrying a copy here duplicated 30 files that PR
already owns, inherited from the closed and unmerged #1159.

The engine suite now serializes its own contract-state envelopes through
the two runtimes it bridges, so it depends on neither the fixtures nor
testkit-js. That dependency was not available anyway: testkit-js depends on
midnight-js-protocol, so consuming its fixture accessor from here would
close a workspace cycle. Coverage stays 100/100/100/100.

What the goldens prove and this cannot - that a real migrated on-chain
state down-converts byte-identically to its pre-migration form, and that
the tampered envelopes fail closed - moves to its own PR alongside them.
The protocol turbo.json goes with it, since nothing here reads a fixture
path any more, as does the correction to the mint-migrated-v9-merkle
generator comment.
The engine suite in #1164 builds its envelopes in-process, so it proves the
bridge is correct but not that it is correct on real data. These tests close
that gap: a genuinely migrated on-chain state must down-convert to data
byte-identical with its pre-migration form, the pre-fork tag-v6 envelope must
read to the same state as the post-fork one, a golden Merkle state must come
out with a readable root, and every tampered golden must fail closed without
leaking bytes.

Fixtures are read by path rather than through testkit-js's typed accessor
because testkit-js depends on midnight-js-protocol; a devDependency back
would close a workspace cycle. The protocol turbo.json therefore declares the
fixture directory as a test input, so editing a golden invalidates this
package's test cache instead of replaying a stale pass.

Verified locally against the fixtures from #1184: 7/7 pass. CI stays red here
until both #1164 and #1184 have landed.
sp-io and others added 5 commits August 21, 2026 09:07
…tch hint

The dual-publish rewrites dependency scopes at pack time, so the two published
copies of midnight-js-protocol depend on two differently named copies of
onchain-runtime-v3 at the same version -- an install combination no resolver can
dedupe, and therefore a live cause of the very dual-instantiation that
Ledger8InstanceMismatchError reports.

rewriteScope touches package.json only, never a string in compiled code, so the
single hardcoded package name sent every consumer installed from the
@midnightntwrk scope to `yarn why` on a package absent from their tree.
AXIS_PACKAGE_NAMES now carries every published name per axis and the message
offers a `yarn why` for each.

The errors.ts assertion is two-directional: it extracts all yarn why targets and
compares the exact pair, so a missing name and a leaked extra name both fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ger agnostic

packages/protocol is published, and consumers install with npm, pnpm or bun as
well as yarn, so a `yarn why` hint is unrunnable for most of them. Name the
packages once and every `why` equivalent once, avoiding a names-by-tools
cross-product.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ifier

rewriteScope renames this package in package.json only, never in a compiled
string, so the subpath alone identifies the import under either scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sp-io sp-io added the ai-assisted Authored or substantially edited by an AI agent label Aug 21, 2026
@sp-io
sp-io removed request for a team August 21, 2026 09:22
sp-io and others added 9 commits August 21, 2026 11:24
…kflow

The pre-push hook already runs yarn typecheck:tests; keep the compile-time drift detector comment in sync with that.
…-core

Folds PR #1186 in: asserts the down-convert engine against the committed
hard-fork goldens, which the in-process engine suite cannot cover.
…e publish rewrite

The public-npm publish (.github/scripts/publish-public-npm.mjs) rewrites the
old npm scope to the new one inside built .js/.d.ts files, not only in
package.json. AXIS_PACKAGE_NAMES held both scoped names as literals, so the
published artifact named one scope twice instead of naming both -- and no test
could see it, since the suite runs against unrewritten source.

Hold the scopes apart from the '/' and join them at use, so the rewrite has
nothing to match, and pin that with a test asserting the module carries no
scoped literal at all.

Also narrow the instance-guard contract to a shared class binding: a re-export
produces a fresh namespace object over one physical copy, so comparing
namespaces would report a mismatch on a healthy install -- the very case the
error message names first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…raction

ENVELOPE_DECODERS was a plain object literal indexed by `version`, so an
unexpected key resolved through Object.prototype instead of failing:
'constructor' handed the caller's own raw bytes back as an EncodedStateValue
and 'toString' returned the string '[object Object]', neither throwing. Both
are silent wrong answers from a function documented to fail closed, and both
are reachable from the untyped JavaScript consumers this published package
also serves.

Build the table on a null prototype, freeze it, and validate `version` before
dispatch with a new coded UnknownLedgerVersionError. Validating first also
keeps `stage` inside its closed union -- it is derived from `version`, so an
unvalidated string would otherwise reach a field whose contract is that
consumers can switch on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a fallible root()

Two silent-skip paths in the down-convert guards:

The Merkle walk's switch had no default arm and returns void, so a variant
outside the five it names fell through having checked nothing and recursed
into nothing -- every tree nested inside such a container was skipped without
a word. The compile-time exhaustiveness guard cannot cover this: the
StateValue comes from a caller-injected runtime whose WASM can emit a tag the
pinned .d.ts does not declare, and these declarations are already known to be
unfaithful. Fold the guard into the switch as a `never` default that also
throws at runtime.

checkRoot treated only `undefined` as not-rehashed, but the wasm-bindgen shim
for root() rethrows a Rust Err rather than always resolving to a value. That
throw escaped uncoded and was relabelled DOWN_CONVERT_FAILED, telling the
caller to check its envelope bytes when the fix is to rehash the tree. Treat a
throw and a null alike, carrying the cause.

structurallyEqual compared key counts rather than key sets, so two objects
sharing no key compared equal whenever the diverging key's value was
undefined. Unreachable in today's algebra; guarded so it stays that way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every container test built both sides of the re-encode comparison with
onchain-runtime-v3, so it could not fail for ordering reasons; the only
genuinely cross-codec case used a bare cell. The production comparison is a
ledger-v9 encoding against an onchain-runtime-v3 re-encoding, and the map
iteration order that makes it exact is a cross-runtime property. Build the v9
side natively over a multi-entry map inserted out of key order and over a
nested array.

Also cover the v8 decoder's truncated and trailing-bytes paths (only v9 had
them), assert the v9 extraction stage rather than v8 alone, and pin the
Map order-sensitivity that a get()-based rewrite would silently drop.

Correct two comments that claimed more than they delivered: the dist-laziness
onchain-runtime-v3 case passes today because no build entry reaches the engine
at all, and the vendor drift detectors are checked by the pre-push hook only,
never by CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other vendor runtime the protocol package depends on is pinned there;
this one was not, so a transitive copy at another version would resolve
alongside it -- producing exactly the dual-instantiation the engine's instance
guard exists to report. yarn.lock is unchanged: the pin matches what already
resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ledger8CompactRuntime was satisfied by every post-fork runtime. StateValue.decode
and `new ChargedState(...)` are structurally identical across the fork - the
wire-shape drift detectors in this suite assert exactly that - so neither member
could tell a pre-fork runtime from a post-fork one. onchain-runtime-v4 and
compact-runtime, which re-exports it, both qualified; both are public barrel
exports of this very package, so the wrong argument was one autocomplete away.
It also failed silently: decode and re-encode then used the same post-fork codec,
so the structural comparison, the Merkle walk and ChargedState construction all
passed, and a v4 ChargedState came back typed as a v3 one - to surface later as an
opaque wasm-bindgen rejection deep inside execution.

ContractState now pins the era, through the GatherResult divergence in its
query(). That divergence is incidental to this bridge rather than something it
relies on, so a compile-time negative assertion holds the line: the pre-fix
interface shape fails it, the fixed shape passes.

An omitted or incomplete runtime was separately reported as DOWN_CONVERT_FAILED at
an envelope-extraction stage, telling the caller to audit input bytes that were
fine. The binding is now checked before any decoding and raises the new
Ledger8RuntimeInvalidError, whose remediation points at the acquisition path.

Asserts what the errors already promised: `cause` pass-through on all three
DownConvertFailedError sites, by identity where the cause is injected, and the
'state down-convert' stage that separates a mid-conversion data loss from bad
input bytes. Dropping either was previously invisible to the suite.

Corrects comments that claimed more than the code delivers:
- the prototype-chain 'toString' hazard yields '[object Undefined]', not
  '[object Object]' - the decoder is read into a local and called bare
- dist-laziness does not yet cover lib/engine, since no build entry reaches it
- wasm-bindgen checks wasm-class arguments but not method receivers, so a
  duplicate install can return plausible wrong bytes with no error at all; the
  instance guard is therefore load-bearing for correctness, not just diagnostics
- the algebra does hold one undefined-valued slot, in a boundedMerkleTree leaf
  tuple, reached through the array branch
- map iteration order is a canonical hash order, not ascending by key
- the cross-codec block covers two containers and no Merkle tree
- AXIS_PACKAGE_NAMES was renamed to axisPackageNames

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@VanessaPC VanessaPC left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the three new engine modules, the error classes, and the test suites against the base (feat/1004-v8-subpath-loadv8).

The core is solid, and I checked the parts that would be easy to get wrong rather than taking the docblocks' word for it: structurallyEqual's key-set guard and its Map/Uint8Array/BigInt/empty-container edges, the null-prototype decoder table against constructor / toString / __proto__, the exhaustiveness never plus its non-redundant runtime throw, DownConvertStage staying inside its union, readHexFixture's whole-hex length check, the golden-fixture decode matrix (all four golden assertions match the recorded outcomes, all five referenced fixtures exist), the split-scope defence in errors.ts against the pack-time rewrite (which does match on the scope with its slash, so it holds), and the onchain-runtime-v3-alt alias genuinely producing a second physical copy under nodeLinker: node-modules.

Eight comments inline. Two I'd like resolved before merge:

  • down-convert.ts:276downConvertForExecution doesn't validate ledger8Runtime, so a runtime fault surfaces as DownConvertFailedError telling the caller to audit input bytes that are fine. That's the mis-diagnosis Ledger8RuntimeInvalidError was added to prevent, at the one seam that skipped the check.
  • errors.ts:283 — the remediation tells callers to pass the ContractState that loadLedger8() exposes. I pulled the published @midnightntwrk/ledger-v8@8.1.1 typings: it exports its own ContractState with a static deserialize, and has zero dependencies, so it cannot be onchain-runtime-v3's. Following the message yields a class that passes the typeof deserialize === 'function' guard and decodes on a second WASM instance — the dual-instantiation instance-guard.ts exists to catch.

The rest are low: three one-to-three-line fixes (errors.ts:130 prototype hole, instance-guard.ts:75 false dual-instantiation claim, errors.ts:242 dropped JSDoc), plus the ! assertions at down-convert.ts:212/215/219 on exported functions, the v9 path requiring an unused runtime, and an inverted comment premise in vitest.config.ts.

* across the boundary needs the source-side tree, which this function never
* sees — it belongs at the envelope seam.
*/
export const downConvertForExecution = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium — unguarded runtime seam

This validates nothing about ledger8Runtime, while the sibling seam validates precisely so that a runtime fault isn't reported as a data fault:

// envelope.ts:115
if (typeof ledger8ContractState?.deserialize !== 'function') {
  throw new Ledger8RuntimeInvalidError('ContractState.deserialize');
}

A runtime handed over without StateValue (the hand-assembled case Ledger8RuntimeInvalidError's own message calls out) produces TypeError: Cannot read properties of undefined (reading 'decode') at line 281, which the catch at line 292 re-throws as DownConvertFailedError('state down-convert', cause). That error's message then tells the caller to read the cause because it "distinguishes a tag mismatch ... from truncated, trailing, or empty input bytes" — sending them to audit input bytes that are fine. That's exactly the mis-diagnosis the new error code was added to prevent, at the one seam left unguarded.

A typeof ledger8Runtime?.StateValue?.decode !== 'function' / ChargedState check before the try would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc8f22d. downConvertForExecution now validates before the try:

if (typeof ledger8Runtime?.StateValue?.decode !== 'function') {
  throw new Ledger8RuntimeInvalidError('StateValue.decode');
}
if (typeof ledger8Runtime.ChargedState !== 'function') {
  throw new Ledger8RuntimeInvalidError('ChargedState');
}

Only the two members this function actually dereferences are checked. ContractState is on Ledger8CompactRuntime to pin the era and is never called here, so guarding it would reject a runtime that works.

Five cases red first, each pinning the code and the missingMember: no runtime, null, a runtime with no StateValue, a StateValue whose decode is not callable, and a runtime with no ChargedState.

Comment thread packages/protocol/src/errors.ts Outdated
constructor(readonly missingMember: string) {
super(
'The ledger-8 runtime handed to the down-convert engine cannot be used: the binding it needs is missing. ' +
'Acquire the runtime with loadLedger8() and pass the pre-fork ContractState class it exposes instead of ' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium — this remediation points callers into a dual-instantiation, and the guard won't catch it

Ledger8ContractState.deserialize (envelope.ts:43) is typed to return @midnight-ntwrk/onchain-runtime-v3's ContractState, and every real call site passes ocrt3.ContractState. But loadLedger8() (src/lib/load-v8.ts) dynamically imports src/v8.ts@midnightntwrk/ledger-v8. From the published 8.1.1 tarball:

package/ledger-v8.d.ts:765  export class ContractState {
                            ...
                              static deserialize(raw: Uint8Array): ContractState;
package/package.json        dependencies: none, peerDependencies: none

So ledger-v8 exports its own ContractState, and with zero dependencies it cannot be re-exporting onchain-runtime-v3's. A caller who follows this message hands over a class that satisfies the typeof deserialize === 'function' guard above and then decodes envelopes on a second, unrelated WASM instance — the exact dual-instantiation instance-guard.ts exists to detect. So this isn't only a misleading string: it routes past the check meant to catch the failure it causes.

Related: the package exposes no sanctioned lazy accessor for onchain-runtime-v3 at all (which is why it had to become an unconditional runtime dependency). Worth saying that plainly here instead of pointing at loadLedger8().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the factual half independently rather than taking the quote. npm pack @midnightntwrk/ledger-v8@8.1.1 gives ledger-v8.d.ts:765 export class ContractState { ... static deserialize(raw: Uint8Array): ContractState } with data: ChargedState, and a package.json carrying neither dependencies nor peerDependencies. So the message did point callers at a different package's class running on its own WASM instance.

Fixed in fc8f22d: the remediation now names onchain-runtime-v3, says plainly that this package exposes no accessor for it, and mentions loadLedger8() only to rule it out. The test that pinned /loadLedger8/ was replaced by one asserting the message names onchain-runtime-v3 and the absent accessor.

One part I read differently, and it is why the fix is the string and not new machinery: "routes past the check meant to catch the failure it causes" overstates the exposure. Nothing crossing this seam is a WASM handle. deserialize(raw).data.state.encode() returns a POJO, and that POJO is then decoded by onchain-runtime-v3 inside downConvertForExecution. The failure instance-guard.ts exists for - a wasm-bindgen _assertClass rejection, or a __wbg_ptr read against the wrong linear memory - is not reachable this way. What is left is narrower: a schema divergence between the two encoders' POJOs, which a guard on class identity would not catch either.

return;
case 'map': {
const map = sv.asMap()!;
map.keys().forEach((key) => assertMerkleTreesRehashed(map.get(key)!));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low/medium — the ! assertions are unsound on this docblock's own terms

The docblock at line 202 says the vendor typings "are not authoritative about definedness here (asCell() is declared non-optional yet returns undefined for a null state value)", and the default arm at line 225 exists because a caller-injected runtime "can emit a tag the pinned .d.ts does not declare". Granting both premises, the assertions can't be relied on:

  • a runtime whose type() reports 'map' while get() fails to resolve a key marshalled out by keys()assertMerkleTreesRehashed(undefined) → bare TypeError on undefined.type()
  • same shape for sv.asArray()! (line 215) and sv.asBoundedMerkleTree()! (line 212) → checkRoot(undefined)

Inside downConvertForExecution that's wrapped, but assertMerkleTreesRehashed and checkRoot are both exported and documented as throwing only MerkleNotRehashedError / DownConvertFailedError. A direct caller therefore gets an uncoded error out of a seam whose whole contract is code-based discrimination. An explicit undefined check throwing DownConvertFailedError is one line each.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inconsistency is real and I am not disputing it: the docblock says the vendor typings are not authoritative about definedness, the default arm exists precisely because a caller-injected runtime can emit a tag the pinned .d.ts does not declare, and then three ! trust those same typings.

Not in this round, and not because it is wrong. The package holds a per-file 100% branch floor, so each guard needs a fake-runtime test forcing asBoundedMerkleTree() / asArray() / map.get() to return undefined - three branches plus three tests, rather than one line each. Recorded as an explicit follow-up in the PR body instead of being folded into a round that was otherwise message and doc fixes.

Comment thread packages/protocol/src/errors.ts Outdated
* this same name under a different scope, so naming only one scope would point
* every consumer installed from the other at a package not in their tree.
*/
const AXIS_BARE_PACKAGE_NAMES: Readonly<Record<Ledger8InstanceAxis, string>> = Object.freeze({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — fail-open prototype hole, the same one this PR deliberately closed for ENVELOPE_DECODERS

This table is Object.freezed but keeps its prototype, and axis is never validated before axisPackageNames indexes it at line 135. For the untyped-JS consumers these errors are explicitly written for:

  • assertSharedLedger8Instance('constructor', a, b) → message reads Trace @midnight-ntwrk/function Object() { [native code] } and @midnightntwrk/function Object() { [native code] } with your package manager's ``why`` command
  • '__proto__' or any other non-axis string → @midnight-ntwrk/undefined

envelope.ts:71 gets exactly this right (Object.create(null) + validate-before-dispatch, with a documented rationale). Same treatment here — Object.create(null) plus a guard in assertSharedLedger8Instance — closes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc8f22d. AXIS_BARE_PACKAGE_NAMES is now Object.freeze(Object.assign(Object.create(null), { ... } satisfies Record<Ledger8InstanceAxis, string>)), and assertSharedLedger8Instance validates axis against a closed set before either probe is looked at, throwing a new UnknownLedger8AxisError (MIDNIGHT_JS_P_UNKNOWN_LEDGER8_AXIS) that keeps the offending string in a field rather than the message.

The axis set lives in instance-guard.ts, not in errors.ts. errors.ts is a build entry, so a predicate exported from it becomes public API that nothing outside the engine needs - and version.ts sets the precedent of exporting the tuple but no predicate. The one duplicated literal is checked, not trusted: satisfies Record<Ledger8InstanceAxis, true> fails the build if the union gains a member.

Covered by constructor / __proto__ / toString / valueOf / bogus, a non-string axis, and a direct pin on the table asserting no [native code] or [object reaches the message.

One distinction worth recording next to the ENVELOPE_DECODERS comparison: that hole was fail-open - it returned a value and threw nothing. This one only garbled the text of an error that was already being thrown, and only when a bogus axis coincided with a genuine probe failure. Same fix, different severity.

* why `'onchain-runtime-v3'` is the only member today.
*/
export const assertSharedLedger8Instance = (axis: Ledger8InstanceAxis, probeA: unknown, probeB: unknown): void => {
if (probeA == null || probeB == null || probeA !== probeB) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — nullish probes are reported with a factually false diagnosis

Rejecting nullish probes before the === is right, and the reasoning in the docblock is sound. The problem is the error they get: Ledger8InstanceMismatchError's message asserts "Detected two physically distinct copies of onchain-runtime-v3 loaded into the same process (a dual-instantiation)" and sends the reader to npm why to hunt a duplicate install.

A caller who optional-chained an export that moved, or simply omitted a probe, is told something untrue about their dependency tree and pointed at a hunt that will find nothing. This PR adds Ledger8RuntimeInvalidError for precisely the "handed over incomplete" fault at the envelope seam; as it stands the two seams diagnose the same class of caller error differently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc8f22d. Nullish probes now throw Ledger8RuntimeInvalidError with missingMember: 'onchain-runtime-v3 instance probe', and Ledger8InstanceMismatchError is reserved for an actual reference-equality failure, so the two seams diagnose the same class of caller error the same way.

The guard is now three ordered checks - axis, then nullish, then !== - which also means axis is known to be one of this package's own literals before it reaches missingMember, keeping that field's "never caller-supplied text" property intact.

The three existing nullish cases were flipped to assert the new code and to assert the message does not contain dual-instantiation, so a regression that reintroduced the false diagnosis fails rather than passing on a matching axis.

Comment thread packages/protocol/src/errors.ts Outdated
}
}

/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — this doc block is dropped, and UnknownLedgerVersionError ships undocumented

Two JSDoc blocks sit back to back: this one (lines 242–257) documents UnknownLedgerVersionError — it names version, LEDGER_VERSIONS and requestedVersion — and line 258 opens a second block documenting Ledger8RuntimeInvalidError.

A doc comment immediately followed by another doc comment is discarded, so the class actually declared at line 290 (UnknownLedgerVersionError) ships with no TypeDoc output and no IDE hover, while the block that does survive describes a different class. Looks like residue from the #1168 conflict resolution described in the PR body. Moving this block down to line 289 fixes both halves.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc8f22d. The block moved down to the class it documents; confirmed in the emitted typings rather than assumed - dist/errors.d.ts now carries it directly above declare class UnknownLedgerVersionError.

Also swept the rest of the file for the same shape: no other back-to-back doc blocks. UnknownLedger8AxisError was added in the same round and sits above UnknownLedgerVersionError, so it has its own block and does not re-orphan this one - which it did on the first attempt, before the sweep caught it.

if (typeof decoder !== 'function') {
throw new UnknownLedgerVersionError(String(version));
}
if (typeof ledger8ContractState?.deserialize !== 'function') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — the v9 path requires a runtime it provably never uses

The v9 decoder (line 75) never reads ledger8ContractState, yet this check rejects its absence for every version. The docblock defends that as keeping "a v9 caller from drifting into a v8 call that has no runtime to reach for", but the cost runs against the goal this same file states twice — that "a v9-only consumer would pay for a runtime it never calls".

Concretely: a node-2.x-only consumer must acquire the retained pre-fork onchain-runtime-v3 runtime purely to extract a post-fork envelope, and extractEncodedStateValue(raw, 'v9', undefined) throws Ledger8RuntimeInvalidError for an argument that is dead on that path.

An optional third parameter, checked inside the 'v8' decoder (or right after the version validation, only when version === 'v8'), gives the same accurate diagnosis without the forced acquisition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as it is; the docblock is corrected in 64a5068.

Two things came out of checking this one. First, the stated cost does not exist on this stack. extractEncodedStateValue has exactly one call site anywhere in it - createLedger8Engine() in #1168 / #1165 - and that factory has already awaited onchain-runtime-v3 two lines earlier to assemble the runtime it hands to downConvertForExecution. The v9 decoder is there so the bridge can read a post-fork envelope before down-converting it, not to serve a caller holding no pre-fork runtime; such a caller reads ledger-v9 directly and never reaches this function. So no consumer is currently forced to acquire anything.

Second, the shape of the alternative matters. That call site is (raw, version) => extractEncodedStateValue(raw, version, ocrt3.ContractState), where version is a LedgerVersion union value rather than a literal - so overloads, or a discriminated parameter object, would not compile against it. The only workable version of your suggestion is the optional third parameter, which trades a compile-time error on the one path that genuinely needs the argument for a runtime one.

What was actually wrong was the justification. The comment defended the requirement by the cost to a v9-only consumer while the same file argues laziness on a different axis (the erased import type, which is about bundling, not about the call contract). The docblock now separates those two, gives the call-graph reason, and records the condition that flips the decision: if this seam is ever surfaced beyond the engine, make the parameter optional and move the check into the 'v8' decoder.

// a glob matching no file is ignored silently, so renaming or moving
// one of these files deletes its floor without any warning; and while
// the global thresholds above are also 100, these entries are
// redundant. They exist so that a future lowering of the global floor

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low — the comment's premise is inverted, which matters exactly when someone edits this

In Vitest, files matched by a thresholds glob key are removed from the global threshold aggregate and judged only against their own entry. So these four entries aren't redundant with the global 100s — they change which files the global gate covers.

No behavioural difference while every number here is 100. But this comment is what a future editor would rely on when lowering one of these floors, and at that moment the file also silently stops contributing to the package-wide gate — the opposite of the protection the comment promises ("a future lowering of the global floor cannot quietly take these files down with it").

The neighbouring warning about a glob matching no file being silently ignored is accurate and worth keeping.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one - the comment is correct for the pinned toolchain, and I think the premise here is the inverted one.

Vitest 4.1.7, node_modules/vitest/dist/chunks/coverage.*.js, resolveThresholds:

// Global threshold is for all files, even if they are included by glob patterns
for (const file of files) {
  const fileCoverage = coverageMap.fileCoverageFor(file);
  globalCoverageMap.addFileCoverage(fileCoverage);
}

The loop runs over every file with no exclusion of glob-matched ones; the per-glob entries are pushed as additional threshold sets alongside it. So these four entries are an extra gate on top of the global 100s, not a replacement for them, and lowering one of these floors does not drop that file out of the package-wide gate.

The exclusion behaviour you describe was real in earlier majors and is still what a lot of the circulating documentation says, so this is worth having on the record rather than left to be re-raised. The neighbouring warning about a glob matching no file being silently ignored stands as you say.

sp-io and others added 2 commits August 31, 2026 16:26
Review of #1164 found five seams whose failure reporting was wrong or
whose docs did not ship. All are caller-facing diagnoses, none change a
success path.

- downConvertForExecution validated nothing about the runtime it
  dereferences, so a missing binding surfaced as a TypeError that the
  catch relabelled DOWN_CONVERT_FAILED - an error telling the caller to
  audit input bytes that are fine. It now fails with
  LEDGER8_RUNTIME_INVALID naming the absent member, matching the sibling
  check on the envelope seam.
- Ledger8RuntimeInvalidError told callers to pass the ContractState that
  loadLedger8() exposes. loadLedger8() resolves the v8 ledger package,
  which exports its own same-named class with a matching static
  deserialize on a separate WASM instance, so following the message
  passed the duck-typed guard and decoded on the wrong copy. The message
  now names onchain-runtime-v3 and says plainly that this package
  exposes no accessor for it.
- The UnknownLedgerVersionError doc block sat immediately before a
  second doc block, so it was discarded and the class shipped with no
  typings comment. Moved down to the class it documents.
- A nullish instance probe was reported as a dual-instantiation, telling
  the caller two physical copies exist and sending them to npm why after
  a duplicate that is not there. It is now LEDGER8_RUNTIME_INVALID, the
  same code the envelope seam already uses for a binding handed over
  incomplete.
- The axis package-name table kept its prototype and the axis was never
  validated, so a non-axis string rendered an Object.prototype member as
  the npm package to trace. The table is null-prototype and frozen, and
  the axis is validated against a closed, satisfies-checked set before
  any probe is compared, throwing the new UnknownLedger8AxisError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…real call graph

The docblock defended requiring ledger8ContractState on the v9 path by
the cost to a v9-only consumer, and that consumer does not exist:
extractEncodedStateValue is reached only from the ledger-8 engine's
factory, which has already awaited onchain-runtime-v3 to assemble the
runtime it hands to downConvertForExecution. The v9 decoder is there so
the bridge can read a post-fork envelope before down-converting it, not
to serve a caller holding no pre-fork runtime.

Says that instead, keeps the two arguments apart - the call-graph claim
here and the bundling claim behind the import type on
Ledger8ContractState are unrelated - and records the condition that
would flip the decision, so a later PR surfacing this seam beyond the
engine knows to make the parameter optional and move the check into the
v8 decoder.

Docs only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sp-io
sp-io requested a review from VanessaPC September 1, 2026 06:38
…o feat/1004-engine-core

# Conflicts:
#	packages/protocol/src/test/dist-laziness.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted Authored or substantially edited by an AI agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants