Don't update Compact maps on Midnight Undeployed
I spent an afternoon chasing SubmissionError / FiberFailure wrapping: RpcError 1010: Invalid Transaction: Custom error: 117 and a WASM panic that looks like feesWithMargin / transaction_merge Unreachable. The circuit compiled. The proof server was up. The genesis wallet had tDUST. The bug was in how I used Compact Map . This post is the lesson: on Midnight Local Undeployed, do not update an existing Map key. Insert a new key. If a value must become public, disclose() it. And hashing a witness then checking != empty is not access control. I verified this against a real local stack, not a sketch. Versions I actually ran | Piece | Pin | |---|---| | Compact language | pragma language_version 0.23 | | Compact toolchain | 0.31.x | @midnight-ntwrk/compact-runtime | 0.16.0 | @midnight-ntwrk/midnight-js-* / testkit-js | 4.1.1 | @midnight-ntwrk/wallet-sdk | 1.2.0 (not wallet@5 ) | | Node | midnightntwrk/midnight-node:0.22.5 :9944 | | Indexer | midnightntwrk/indexer-standalone:4.0.2 :8088 | | Proof server | midnightntwrk/proof-server:8.0.3 :6300 | Local Undeployed writes are server-append. Lace does not sign them. setNetworkId("undeployed") is a string at midnight-js 4.1.1. The standalone chain funds genesis seed โฆ0002 , not โฆ0001 . If those pins drift, you get a different class of failure (often 196 = verifier mismatch). Don't mix public-network proof-server 8.1.0 with this indexer. The mental model in one paragraph A Compact circuit has three kinds of data: - Witnesses - private inputs ( localSecretKey() ). They never land on the public ledger unless you move them. - Circuit-local values - hashes, asserts, math. Still private until disclosed. - Ledger - public state. The only way a private value becomes public is disclose(...) . That is why a reviewer can ask "why does this circuit need disclose() here?" Because credits.insert(pk, amount) without disclose would try to write a private value into public state. The compiler/runtime will not let that slide. What I wrote first (this panics on Undeployed) The EVM instinct is a balances map you overwrite: pragma language_version 0.23; import CompactStandardLibrary; export ledger balances: Map , Uint >; witness localSecretKey(): Bytes ; export circuit transfer(to: Bytes , amount: Uint ): [] { const sk = localSecretKey(); const from = persistentHash >>([pad(32, "musdc:signer:v1"), sk]); // WRONG on Undeployed: lookup + insert of the SAME key const fromBal = balances.lookup(from); balances.insert(disclose(from), disclose(fromBal - amount)); const toBal = balances.lookup(to); balances.insert(disclose(to), disclose(toBal + amount)); } That compiles as Compact. On this Undeployed dust wallet it does not survive callTx . Updating an existing key trips the merge path. Symptom soup: SubmissionError , FiberFailure , Unreachable in transaction_merge . I hit the same class of bug twice: first on an NFT owners map, then again on a token balances map after I thought I had "fixed NFTs." Same ledger shape, same panic. What works: insert-only credit log Treat the public ledger as an append-only journal. Reconstruct demo balances off-chain if you need a UI number. This is the token circuit I compiled and ran (MidnightUSDC.compact , Compact 0.23): pragma language_version 0.23; import CompactStandardLibrary; export ledger credits: Map , Uint >; export ledger credit_to: Map , Bytes >; export ledger faucet_claimed: Set >; export ledger spent_nonces: Set >; witness localSecretKey(): Bytes ; export circuit faucet(): [] { const sk = localSecretKey(); const pk = persistentHash >>([pad(32, "musdc:signer:v1"), sk]); assert(!faucet_claimed.member(disclose(pk)), "already claimed"); faucet_claimed.insert(disclose(pk)); credits.insert(disclose(pk), disclose(10000000 as Uint )); credit_to.insert(disclose(pk), disclose(pk)); } export circuit transfer(to: Bytes , amount: Uint , nonce: Bytes ): [] { const sk = localSecretKey(); const fromPk = persistentHash >>([pad(32, "musdc:signer:v1"), sk]); assert(!spent_nonces.member(disclose(nonce)), "nonce spent"); assert(amount > 0 as Uint , "amount zero"); assert(faucet_claimed.member(disclose(fromPk)), "signer"); spent_nonces.insert(disclose(nonce)); credits.insert(disclose(nonce), disclose(amount)); credit_to.insert(disclose(nonce), disclose(to)); } What changed: - Faucet inserts once, keyed by signer pk. Never updated. - Transfer inserts a new row keyed by a fresh nonce . Neverbalances[from] = old - amount . - Every value that hits a ledger insert isdisclose(...) . - Spend auth is faucet_claimed.member(fromPk) , not "the hash exists." NFT list/buy on the same stack uses the same rule: owners insert on mint only; sales append with a fresh random id; cancel writes a new listing id instead of overwriting the old price key. Circuit name is listSale - list is a Compact keyword. Why disclose() is on the insert fromPk is derived from a witness. Left alone, it is private. credits.insert writes public Map state. disclose(fromPk) is the explicit "this byte string may leave the circuit." Skip it and you either fail compile or you ship a circuit that cannot be proven against public ledger types. Skip disclose on nonce and the spent-nonce set cannot be checked by later transactions. That is the whole privacy model: private by default, public by disclose . The auth footgun that looks like privacy This also compiles, and it is not access control: const auth = persistentHash >>([pad(32, "movenft:minter:v1"), sk]); assert(auth != pad(32, ""), "auth"); Any non-empty secret hashes to something that is not 32 zero bytes. I replaced it with a stored pk: const pk = persistentHash >>([pad(32, "movenft:minter:v1"), sk]); const empty = pad(32, ""); if (minter_pk == empty) { minter_pk = disclose(pk); // first call bootstraps } else { assert(pk == minter_pk, "minter"); // later calls bind the witness } Same shape as assert(pk == buyer) on a mandate circuit: compare the derived pk to a value the ledger already knows. On the token, assert(fromPk != empty) was the same undercut. faucet_claimed.member(fromPk) is the bind. SDK side: don't cache the genesis wallet Undeployed appends reuse the deploy-time LevelDB private state. Mismatch โ 117. Keep seed, store name, and private-state id in one module imported by deploy and every callTx path. Open MidnightWalletProvider , prove, submit, stop() in finally . A cached provider holds LevelDB open and the next HTTP request dies. import { setNetworkId } from "@midnight-ntwrk/midnight-js-network-id"; import { MidnightWalletProvider } from "@midnight-ntwrk/wallet-sdk"; setNetworkId("undeployed"); // string, not a runtime enum at 4.1.1 const wallet = await MidnightWalletProvider.create({ networkId: "undeployed", seedHex: GENESIS_SEED, // โฆ0002 indexerUrl: "http://localhost:8088/api/v4/graphql", indexerWsUrl: "ws://localhost:8088/api/v4/graphql/ws", proofServerUrl: "http://localhost:6300", nodeUrl: "ws://localhost:9944", }); await wallet.start(true); try { // CompiledContract + callTx } finally { await wallet.stop(); } Need import WebSocket from "ws" in Node. Compact 0.31 emits contract/index.js (not only .cjs ). How I proved it wasn't folklore After rewriting maps to insert-only and binding the faucet set: compact compile contracts/MidnightUSDC.compact contracts/managed/midnight-usdc - Wipe local private state, full genesis deploy - Two sequential transfers succeeded - Mint โ list โ buy e2e printed E2E_OK If you change circuits after deploy: compile โ copy prover/verifier artefacts โ wipe LevelDB โ full redeploy. Mixing new verifiers with old state is 196. Retrying the same UI click after 117 does not heal it. What I'd tell my past self Design the public Map s as journals before the first Undeployed demo. Do not port ERC-20 storage slots into Compact and hope the dust wallet merges them. disclose() is not ceremony - it is the privacy boundary. And hash != empty is a compile-time-shaped lie. Working contracts and the insert-only token live in zealymidnight (contracts/MidnightUSDC.compact , contracts/MoveNft.compact ). Top comments (0)
Comments
No comments yet. Start the discussion.