Skip to content

feat!: migrate the kernel to ledger-v9 / node 2.x (BREAKING) - #49

Open
acedward wants to merge 8 commits into
mainfrom
00001-ledger-v9
Open

feat!: migrate the kernel to ledger-v9 / node 2.x (BREAKING)#49
acedward wants to merge 8 commits into
mainfrom
00001-ledger-v9

Conversation

@acedward

Copy link
Copy Markdown
Contributor

BREAKING — migrates the whole workspace from the ledger-v8 line (@midnight-ntwrk/ledger-v8@8.1.0, midnight-js 4.1.1, wallet-SDK v1, @effectstream/*@0.103.1) to the node-2.x / ledger-v9 line: @effectstream/*@0.200.1, @effectstream/mip-zswap-offer@0.4.0-v9.0 (dist-tag ledger-v9), @midnightntwrk/ledger-v9@1.0.0-rc.3, midnight-js 5.0.0-beta.6, wallet-sdk 2.0.0-beta.2, compact-runtime 0.18.0-rc.1 / compactc 0.33.0-rc.2. Targets midnight-node 2.0.0-rc.4, indexer 4.4.0-rc.1 (explicit /api/v4), proof-server 9.0.0-rc.5.

Why breaking

  • Every manifest changes scope/major for the Midnight SDK line (new npm scope @midnightntwrk, no hyphen).
  • The wire format moves transaction[v9](signature[v1],…)transaction[v12](signature[v2],…): v8-era blobs and fixtures no longer deserialize. The committed validator fixture is regenerated against a live rc.4 stack.
  • Signature types are now tagged ({tag: 'schnorr'|'ecdsa', value}); UtxoSpend.owner and the signer-callback contract changed shape.

Changes of substance (beyond the 31 import-site rename)

  1. validatorderive.ts passes the tagged SignatureVerifyingKey to addressFromKey (a rename-only migration compiles clean and derives garbage); shapes.testkit.ts builds tagged owners.
  2. signer callbacks — the v2 SDK requires (data) => Promise<Signature>; every unshieldedKeystore.signData callback moved to signDataAsync.
  3. e2e settlement gate — new waitForWalletSettlement wherever one facade does a second prove+submit: rc.4 rejects stale-DUST reuse with 1010 Custom error: 170 (MalformedError::InvalidDustSpendProof). The gate is deliberately DUST-subtree-only: this repo finalizes offer transactions that are never submitted on-chain (they live in Celestia until a taker settles), so a full-quiescence gate deadlocks.
  4. api tests/v1/offers rows carry offerId (the offer hash), not DB ids; ids resolved by hash while offers are live. A byte-identical re-submit of an archived offer answers 409 DUPLICATE_OFFER (dedup probes history first, by design), accepted alongside 400 NULLIFIER_SPENT.
  5. external-stack mode (new)start.external.ts + packages/node/preflight-external.ts run only the kernel's own processes against externally-operated Midnight + Celestia infrastructure (e.g. the acedward/midnight-2-offers compose stack). The dev orchestrator cannot do this: launchMidnight is unconditional and declares stopProcessAtPort [9944, 8088, 6300], which kills the external stack's listeners. Celestia auth arrives by sourcing /celestia/auth/celestia.env.

Verification

  • bun run test: 62 tests, 0 failures — full Phase A + B (deploy, mint, dual-chain sync, batcher settle, multi-token, unshielded-only, negative paths), zero error-170 submissions.
  • bun test packages/validator/: 93/93, including the crypto ladder against the regenerated fixture.
  • Dependency graph: zero ledger-v8 in the store; exactly one ledger-v9@1.0.0-rc.3 and one onchain-runtime-v4@4.0.0-rc.3 (single-WASM rule).

Moves the contract half of the ledger-v9 migration onto the node-2.x
toolchain: compactc 0.33.0-rc.2 (language 0.25, runtime 0.18.0-rc.1,
compact-js 2.5.5-rc.7). offer-files.compact compiles clean with no source
edits -- the language-0.25 Field/Uint change does not bite, since the only
arithmetic here is already annotated `as Uint<128>`.

`compact compile +0.33.0-rc.2` is not an option: the compact version
manager does not publish the 0.33 line (compact list tops out at 0.31.1).
So infra/ fetches the pinned compactc release asset from
LFDT-Minokawa/compact into a container, and infra/compact.sh runs it --
honouring $COMPACTC if a host install is available instead.

We deliberately do NOT pass --feature-zkir-v3. compactc 0.33 defaults to
ZKIR v2, and the emitted keys are tagged verifier-key[v6], which the ledger
maps to ProofVersioned::V2 -- provable by the plain proof-server:9.0.0-rc.5.
The _experimental proof-server build is needed only for the zkir-v3 lane.
Adding that flag later means switching every deployment's proof server too.

Worth noting: all twelve ZK artifacts are byte-identical to the compactc
0.31.0 output, so verifier keys are unchanged and this bump alone does not
force a contract redeploy. Only the generated TypeScript moved.

The rest of the migration -- renaming @midnight-ntwrk/ledger-v8 to
@midnightntwrk/ledger-v9 across the workspace -- is NOT here, and cannot be
until five @effectstream/* packages move off ledger-v8 upstream. Even
packages/validator, which otherwise has no effectstream runtime coupling,
would end up with two ledger wasm modules in one process because
@effectstream/mip-zswap-offer hard-codes the v8 specifier in a value import,
and two copies fail every cross-copy instanceof check. LEDGER-V9-MIGRATION.md
records the full audit, the blocker, and the prepared v8->v9 API delta.
Moves the whole workspace off the ledger-v8 line onto the published
ledger-v9 set: @effectstream/*@0.200.1, @effectstream/mip-zswap-offer@
0.4.0-v9.0 (dist-tag ledger-v9), @midnightntwrk/ledger-v9@1.0.0-rc.3,
midnight-js 5.0.0-beta.6, wallet-sdk 2.0.0-beta.2 line, compact-runtime
0.18.0-rc.1 (compactc 0.33.0-rc.2 via infra/compact.sh, pinned earlier).
Runs against midnight-node 2.0.0-rc.4 / indexer 4.4.0-rc.1 (API v4
explicit) / proof-server 9.0.0-rc.5.

BREAKING: every package manifest changes scope/major for the Midnight
SDK line; the serialized transaction wire format moves from
transaction[v9](signature[v1],...) to transaction[v12](signature[v2],...),
so v8-era blobs and fixtures do not deserialize. The committed validator
fixture is regenerated against a live rc.4 stack.

Beyond the 31 import-site migration, the changes of substance:

* validator: v9's UtxoSpend.owner is a tagged SignatureVerifyingKey
  ({tag: 'schnorr'|'ecdsa', value}), not a bare hex string. derive.ts
  passes the tagged object to addressFromKey (bare-string inputs are
  wrapped as schnorr); shapes.testkit.ts builds tagged owners. A
  rename-only migration compiles clean here and derives garbage.
* signer callbacks: the v2 SDK requires (data) => Promise<Signature>;
  every unshieldedKeystore.signData callback moved to signDataAsync
  (sync signData made Effect's evaluate().then() throw at sign time).
* e2e: new waitForWalletSettlement gate wherever one facade does a
  second prove+submit — node rc.4 rejects stale-DUST reuse with
  1010 Custom error 170 (MalformedError::InvalidDustSpendProof).
  Deliberately NOT the full-quiescence gate the effectstream e2e uses:
  this repo finalizes offer txs that are never submitted on-chain
  (they live in Celestia until a taker settles), so pending.all and
  shielded pendingCoins stay non-zero by design; the gate is
  DUST-subtree quiescence + strict-complete progress.
* api tests: /v1/offers rows are MIP-0006 payloads carrying offerId
  (the offer hash), not DB ids — resolve ids by hash while offers are
  live; a byte-identical re-submit of an archived offer answers
  409 DUPLICATE_OFFER (dedup probes offer_file_history first, by
  design) so the negative test accepts that or 400 NULLIFIER_SPENT.
* external-stack mode: new start.external.ts + packages/node/
  preflight-external.ts run ONLY the kernel's own processes (pglite,
  contract deploy, mint, sync, batcher) against externally-operated
  Midnight + Celestia infrastructure. The dev orchestrator cannot be
  pointed at an external stack: launchMidnight is unconditional AND
  declares stopProcessAtPort [9944, 8088, 6300], which would kill the
  external stack's listeners. Celestia auth arrives by sourcing
  /celestia/auth/celestia.env when present.
* indexer URLs move to explicit /api/v4 (v3 was an alias).

Verified: bun run test green — 62 tests, 0 failures, full Phase A + B
(deploy, mint, dual-chain sync, batcher settle, multi-token, unshielded-
only, negative paths) with zero error-170 submissions; packages/validator
93/93 including the crypto ladder against the regenerated fixture;
marker-dedup 8/8.
Two failures, both v9-migration fallout the e2e could not see:

* @effectstream/db 0.200.1's startPglite close() DESTROYS live sockets
  (0.103.1 closed politely). The db test files deliberately never send a
  client Terminate (PGlite WASM throws on it), so every file's teardown
  now fired an unhandled 'error' on its still-open pg client — which bun
  attributes to the NEXT file, cascading "Connection terminated
  unexpectedly" across the whole run and eating ~20 tests' registration.
  Fix: swallow the expected teardown 'error' on every client right after
  connect() (13 files). CI's exact invocation now passes locally:
  313 tests, 0 fail, 0 errors (was 286/4 with 14 errors).

* docs playground: midnight-js 5 moved FetchZkConfigProvider's custom
  fetch from a positional argument into { fetchFunc } (and the v5
  provider verifies artifacts against the compactc integrity manifest by
  default). Same fix as the frontend template. docs tsc -b is clean.
@effectstream/*@0.200.2 (monorepo PR #882, "fix(db): make PGlite
force-close opt-in") restores startPglite().close()'s graceful default:
live client sockets are no longer destroyed unless force is requested.
That was the regression fe61612 worked around by swallowing the expected
teardown 'error' on all 13 db-backed test clients.

Bump every @effectstream pin 0.200.1 -> 0.200.2 and DELETE all 13
swallow handlers — running without them is the verification. CI's exact
invocation: 313 tests, 0 failures, 0 errors.
…FERS

An offer whose maker is a CONTRACT CALL (e.g. an AA-Manager open swap) can
never pass wellFormed against the blank reference state: contract-call proof
verification reads the contract's verifier keys FROM STATE, and a blank state
has no contracts - every such offer dies with PROOF_INVALID
('call to non-existant contract'), measured live 2026-08-26 with a proven
selector-6 open-swap offer from the demo stack (which then SETTLED on-chain,
ledger-exact, via the stock balanceFinalizedTransaction taker path - the offer
class is genuine and settleable; only ingestion refused it).

verifyOfferCrypto now takes contractMakerRetry (default off): strict wellFormed
runs FIRST, and only the exact missing-contract failure class widens into one
retry with verifyContractProofs=false - native zswap proofs and signatures stay
verified; the contract-call proof is verified by the node itself at settlement.
Threaded from ALLOW_CONTRACT_MAKER_OFFERS in the node (POST /v1/offers and the
STM ingestion ladder) and the batcher's pre-fee gate. Default remains
fail-closed: the relaxed lane admits ingestion-time spam of unprovable
contract-call offers, bounded by liveness checks, size caps and the limiter.

Tests: validator 93/0, node api+marker-dedup 25/0.
The DA write path authenticates; the health probe did not. Against an
auth-enabled Celestia node the header.NetworkHead probe 401s, the tip reads
null forever, and /v1/health/sync reports 'syncing' on a fully-caught-up
kernel — starving any consumer that gates on currentness (measured live
2026-08-26 on the demo stack: a book-mirror client blocked at its readiness
gate). Token comes from the same CELESTIA_AUTH_TOKEN the batcher config
reads; absent (skip-auth deployments) the header is simply omitted.
…stack

The demo stack runs the kernel through `bunx orchestrator start
start.external.ts`, which puts six processes in one container and re-deploys
the offer-files contract on every recreate. Splitting that into one
container per concern (demo-infra Phase 11) needs two things from this repo;
both are additive and neither changes how `start.dev.ts` / `start.external.ts`
behave.

1. packages/node/pglite.dev.ts — PGLite as a STANDALONE process.

   PGLite is a real Postgres-wire server the node dials over TCP, not an
   in-process library, so `main.dev.ts` cannot simply absorb it. Until now the
   only way to start it was the orchestrator's `launchPglite()` helper, which
   means a container that wants the store had to run the orchestrator too.

   This calls `startPglite()` directly rather than spawning the CLI — the
   upstream module guards its CLI behind `import.meta.main` and exports the
   function — so the kernel container is two real processes (this and
   main.dev.ts), not a wrapper babysitting a child. It closes the store on
   SIGTERM so a normal `docker stop` is the graceful path. Port precedence
   matches `launchPglite()`: --port, then DB_PORT, then 5432.

2. config.dev.ts / main.grand-b.ts — honour MIDNIGHT_CONTRACT_ADDRESS.

   When the deploy is its own one-shot container, the address exists before
   the sync node starts and has to be injectable. `api.ts` and
   `config.preview.ts` already read this variable; `config.dev.ts` and
   `main.grand-b.ts` did not — they dereferenced `midnightContract!` with a
   non-null assertion, so a missing artifact surfaced as a bare TypeError
   that says nothing about the cause. Both now resolve env-first, file
   second, and throw a message naming both ways to fix it.

   The two files are kept byte-identical in this region on purpose:
   grand-b-config-drift.test.ts asserts they do not diverge, and it caught
   this change when only one side had been edited.

Verified: node + validator + batcher + database suites 264/0 (the drift test
among them); pglite.dev.ts starts, accepts a TCP connection on a chosen port,
and exits 0 on SIGTERM.
It was added yesterday for exactly one consumer: the split kernel container,
which ran an embedded PGLite beside the sync node because the orchestrator was
the only way to start one. The demo stack now runs a single shared PostgreSQL
for every component (demo-infra T11.4), so the kernel container selects the
real server with PGLITE=false plus DB_HOST/DB_PORT/DB_USER/DB_PW/DB_NAME — all
of which @effectstream/db already supported — and starts one process.

Nothing in this repo referenced the file. Note that `PGLITE` defaults to TRUE
in @effectstream/utils, so a container that wants the real server must say so
explicitly; the demo's entrypoint refuses to boot otherwise rather than
silently falling back to an embedded store.

PGLite is still used here for local, non-Docker development (start.dev.ts /
start.external.ts / start.mainnet.ts via the orchestrator's launchPglite) and
as the in-process database for the packages/database tests. Neither runs in a
demo container.

Tests: node + database suites unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant