Skip to content

compact-deploy fails on preprod: dust-sync OOM and strict isSynced never completes (pins dust-wallet@4.0.0) #115

Description

@0xisk

Deploying any contract on preprod with compact-deploy fails before a transaction is ever submitted. The wallet first crashes with a JS-heap OOM during dust sync, and once that is worked around the sync gate never completes. Both problems trace back to @openzeppelin/compact-deployer@0.0.1 pinning the pre-fix @midnight-ntwrk/wallet-sdk-dust-wallet@4.0.0.

This is the deployer-side manifestation of midnightntwrk/midnight-wallet#425, which was fixed upstream in dust-wallet@4.1.0 (PRs midnightntwrk/midnight-wallet#442 and #450).

Note: the affected package is shipped as a vendored tarball (@openzeppelin/compact-deployer + @openzeppelin/compact-cli) and isn't under packages/ in this repo today. Filing here per the tooling umbrella; please redirect if it tracks elsewhere.

Environment

  • @openzeppelin/compact-deployer@0.0.1 (with compact-cli, compact-deploy bin)
  • Network: preprod (https://rpc.preprod.midnight.network, indexer https://indexer.preprod.midnight.network/api/v4/graphql)
  • Node v24.15.0; local proof server on :6300
  • SDK pins: wallet-sdk-dust-wallet@4.0.0, wallet-sdk-facade@4.0.0, wallet-sdk-shielded@3.0.0, wallet-sdk-unshielded-wallet@3.0.0, ledger-v8@8.0.3, testkit-js@4.1.0

📝 Details

Three distinct issues, in the order they surface:

1. Dust sync OOMs: no batchUpdates, on the leaky dust-wallet@4.0.0.

preprod's dust history is ~1M events (dustLedgerEvents(id: 0) is a global, unfiltered stream every client must replay). The deployer builds the wallet without setting the wallet-sdk batchUpdates knob, so dust sync uses the SDK default batch size of 10 and dust-wallet@4.0.0 exhausts the V8 heap mid-replay:

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

This is exactly midnightntwrk/midnight-wallet#425. The upstream fix is dust-wallet@4.1.0; the documented workaround is batchUpdates: { size: 5000, timeout: 1, spacing: 4 } (Ura labs validated ~146 MB peak, ~1000 events/sec at size 5000).

The knob must be set on the config used by wallet/handler.js (the cache-restore + fresh-build path, around the line const config = builderForConfig.config). buildDustConfig spreads that config into DustWallet(config).restore(...), so injecting config.batchUpdates there covers both the restore and fresh-sync paths, shielded and dust. Setting it only on wallet/build-deployer.js is not enough: when an on-disk snapshot exists, handler.js builds the dust wallet on a separate config and the workaround is bypassed, OOMing within ~90 seconds.

2. Sync gate uses strict isSynced, which never completes on a live chain.

The syncAndVerifyFunds gate in deployer.js waits on:

Rx.filter((s) => s.isSynced)

FacadeState.isSynced requires shielded, dust and unshielded to each be isStrictlyComplete() (applyGap === 0). On a live network the dust stream advances continuously, so the dust wallet stays 1-2 events behind the tip indefinitely and isSynced never flips true. The gate then times out (--sync-timeout) even though the wallet is fully usable. The SDK already exposes SyncProgress.isCompleteWithin(maxGap) (default 50) for this, and the unshielded wallet uses it internally. Switching the gate to a tolerant check lets sync complete the instant the wallet reaches the tip:

Rx.filter((s) =>
  s.shielded.state.progress.isCompleteWithin(50n) &&
  s.dust.state.progress.isCompleteWithin(50n) &&
  s.unshielded.progress.isCompleteWithin(50n))

3. Node default heap is the binding cap.

Even with batching, the restored dust tree plus shielded trial-decryption spikes past V8's ~2 GB default old-space (the host had 30 GB free). NODE_OPTIONS=--max-old-space-size=8192 was needed to finish. Worth documenting, or setting a sane --max-old-space-size default in the compact-deploy bin.

What worked once patched. With all three applied locally, ShieldedFungibleToken deployed cleanly on preprod (block 1236787) after a ~24 min dust sync. The state-snapshot resume (.states/*.gz, checkpointed every 5 min) is a nice touch: the retry resumed dust at ~493k/1.06M instead of from zero. The v4 indexer, args/seed/signing-key resolution, and the submit all worked.

Suggested fix. Rebuild the deployer against dust-wallet@4.1.0 (removes the OOM and the need for the batchUpdates workaround) and change the sync gate from isSynced to isCompleteWithin. Optionally raise or document the Node heap limit.

🔢 Code to reproduce bug

# compact.toml with a [networks.preprod] block (indexer api/v4) and any
# [contracts.X] entry, plus a prefunded preprod seed file.
compact-deploy <Contract> \
  --network preprod \
  --config ./compact.toml \
  --seed-file ./deploy/preprod.seed \
  --proof-server http://127.0.0.1:6300 \
  --sync-timeout 5400 -v

# Observed: dust sync climbs for a bit, then:
#   FATAL ERROR: ... JavaScript heap out of memory
#
# After forcing batchUpdates={size:5000,...} and raising --max-old-space-size,
# sync reaches the tip but the strict `isSynced` gate never fires, so
# --sync-timeout eventually trips.

Any prefunded preprod seed reproduces it (per #425 the dust commitment tree is global, so every wallet replays the same ~1M-event history).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions