Guide for deploying or upgrading Midnight contracts/dApps to the Preprod network.
- Compact version manager (
compact):0.4.0 - Compiler version:
0.28.0(installed and invoked via the version manager)
Important: You do not invoke
compactcdirectly. Thecompactversion manager finds and runs the correct compiler version. All compilation usescompact compile(ornpm run compactin this project).
Remove any existing Compact installations first:
rm -rf ~/.compact
rm -f ~/.local/bin/compact- Install the version manager:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/midnightntwrk/compact/releases/download/compact-v0.4.0/compact-installer.sh | sh- Add to PATH:
source $HOME/.local/bin/env- Install the compiler version:
compact update 0.28.0compact --version # expect: compact 0.4.0
compact list # should show → 0.28.0 as the selected version| Issue | Cause | Fix |
|---|---|---|
compact: command not found |
PATH not updated after install | Run source $HOME/.local/bin/env |
| Old version still resolving | Stale installations in /usr/local/bin or ~/.compact |
Remove old installations before installing (see Clean Install) |
Compact compiler 0.28.0 uses language version 0.20.0. Contracts targeting older language versions (e.g. >= 0.16 && <= 0.18) will fail to compile.
Update the pragma in your .compact file(s):
- pragma language_version >= 0.16 && <= 0.18;
+ pragma language_version >= 0.20;compact compile src/counter.compact src/managed/counterExpected output:
Compiling 1 circuits:
After a successful compile, src/managed/counter/ will contain:
contract/ — index.js, index.d.ts (TypeScript bindings)
keys/ — increment.prover, increment.verifier (ZK keys)
zkir/ — increment.zkir, increment.bzkir (ZK circuit IR)
| Issue | Cause | Fix |
|---|---|---|
language version 0.20.0 mismatch |
Pragma range doesn't include 0.20.0 |
Update pragma to >= 0.20 |
The midnight-js framework packages were upgraded from 2.0.x to 3.0.0. This brings new types from @midnight-ntwrk/ledger-v7 and a new CompiledContract pattern from @midnight-ntwrk/compact-js.
"@midnight-ntwrk/compact-runtime": "0.14.0",
"@midnight-ntwrk/ledger": "^4.0.0",
- "@midnight-ntwrk/midnight-js-contracts": "2.0.2",
- "@midnight-ntwrk/midnight-js-http-client-proof-provider": "2.0.2",
- "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "2.0.2",
- "@midnight-ntwrk/midnight-js-level-private-state-provider": "2.0.2",
- "@midnight-ntwrk/midnight-js-network-id": "2.0.2",
- "@midnight-ntwrk/midnight-js-node-zk-config-provider": "2.0.2",
- "@midnight-ntwrk/midnight-js-types": "2.0.2",
+ "@midnight-ntwrk/midnight-js-contracts": "3.0.0",
+ "@midnight-ntwrk/midnight-js-http-client-proof-provider": "3.0.0",
+ "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "3.0.0",
+ "@midnight-ntwrk/midnight-js-level-private-state-provider": "3.0.0",
+ "@midnight-ntwrk/midnight-js-network-id": "3.0.0",
+ "@midnight-ntwrk/midnight-js-node-zk-config-provider": "3.0.0",
+ "@midnight-ntwrk/midnight-js-types": "3.0.0",midnight-js 3.0.0 uses CompiledContract from @midnight-ntwrk/compact-js instead of passing raw contract objects. The contract must be piped through withVacantWitnesses (if no witnesses) and withCompiledFileAssets to resolve the context type to never:
import { CompiledContract } from '@midnight-ntwrk/compact-js';
import { Counter } from '@midnight-ntwrk/counter-contract';
const counterCompiledContract = CompiledContract.make('counter', Counter.Contract).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets(contractConfig.zkConfigPath),
);This counterCompiledContract is then passed to deployContract and findDeployedContract via the compiledContract field.
Circuit IDs are now branded types (ImpureCircuitId) from @midnight-ntwrk/compact-js, not plain strings:
import type { ImpureCircuitId } from '@midnight-ntwrk/compact-js';
export type CounterCircuits = ImpureCircuitId<Counter.Contract<CounterPrivateState>>;The Simulator from @midnight-ntwrk/compact-runtime now uses a different constructor pattern:
import { Simulator } from '@midnight-ntwrk/compact-runtime';
// Old:
const sim = new Simulator(witnesses);
const ledgerState = sim.ledger(Counter.initialState(new Uint8Array(32)));
// New:
const sim = Simulator.make(Counter.Contract, witnesses);
const ledgerState = sim.state('counter').data;The monolithic @midnight-ntwrk/wallet + @midnight-ntwrk/wallet-api packages are replaced by the new modular wallet SDK ecosystem. This eliminates the need for @midnight-ntwrk/zswap type bridging and most as any casts, since the new wallet uses ledger-v7 types natively.
- "@midnight-ntwrk/wallet": "5.0.0",
- "@midnight-ntwrk/wallet-api": "5.0.0",
- "@midnight-ntwrk/zswap": "^4.0.0",
+ "@midnight-ntwrk/wallet-sdk-facade": "1.0.0",
+ "@midnight-ntwrk/wallet-sdk-hd": "3.0.0",
+ "@midnight-ntwrk/wallet-sdk-shielded": "1.0.0",
+ "@midnight-ntwrk/wallet-sdk-dust-wallet": "1.0.0",
+ "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "1.0.0",
+ "@midnight-ntwrk/wallet-sdk-address-format": "3.0.0",The new wallet is composed of three sub-wallets orchestrated by WalletFacade:
- ShieldedWallet — handles ZK-shielded transactions (zswap)
- UnshieldedWallet — handles transparent transactions
- DustWallet — handles dust (fee) transactions
Each sub-wallet has its own configuration requirements.
Replace randomBytes-based seed generation with HD wallet key derivation:
import { HDWallet, Roles, generateRandomSeed } from '@midnight-ntwrk/wallet-sdk-hd';
import { toHex } from '@midnight-ntwrk/midnight-js-utils';
import { Buffer } from 'buffer';
const seed = toHex(Buffer.from(generateRandomSeed()));
const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
if (hdWallet.type !== 'seedOk') throw new Error('Failed to initialize HDWallet from seed');
const derivationResult = hdWallet.hdWallet
.selectAccount(0)
.selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
.deriveKeysAt(0);
if (derivationResult.type !== 'keysDerived') throw new Error('Failed to derive keys');
hdWallet.hdWallet.clear();
const keys = derivationResult.keys;Using the keys derived above, initialize the three sub-wallets and the facade:
import { Roles } from '@midnight-ntwrk/wallet-sdk-hd';
import * as ledger from '@midnight-ntwrk/ledger-v7';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import {
createKeystore, InMemoryTransactionHistoryStorage,
PublicKey, UnshieldedWallet,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
// `keys` comes from derivationResult.keys in the Key Derivation step above
const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], getNetworkId());
const shieldedWallet = ShieldedWallet(shieldedConfig).startWithSecretKeys(shieldedSecretKeys);
const unshieldedWallet = UnshieldedWallet(unshieldedConfig).startWithPublicKey(
PublicKey.fromKeyStore(unshieldedKeystore),
);
const dustWallet = DustWallet(dustConfig).startWithSecretKey(
dustSecretKey,
ledger.LedgerParameters.initialParameters().dust,
);
const wallet = new WalletFacade(shieldedWallet, unshieldedWallet, dustWallet);
await wallet.start(shieldedSecretKeys, dustSecretKey);Each sub-wallet requires different config fields:
ShieldedWallet:
{
networkId: getNetworkId(),
indexerClientConnection: { indexerHttpUrl, indexerWsUrl },
provingServerUrl: new URL(proofServer),
relayURL: new URL(node.replace(/^http/, 'ws')),
}UnshieldedWallet:
{
networkId: getNetworkId(),
indexerClientConnection: { indexerHttpUrl, indexerWsUrl },
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
}DustWallet:
{
networkId: getNetworkId(),
costParameters: { additionalFeeOverhead: 300_000_000_000_000n, feeBlocksMargin: 5 },
indexerClientConnection: { indexerHttpUrl, indexerWsUrl },
provingServerUrl: new URL(proofServer),
relayURL: new URL(node.replace(/^http/, 'ws')),
}The WalletProvider / MidnightProvider interface used by midnight-js is bridged from the facade. However, wallet.signRecipe() has a bug that causes "Failed to clone intent" errors when signing proven transactions.
In @midnight-ntwrk/wallet-sdk-unshielded-wallet v1.0.0, TransactionOps.addSignature() clones intents via Intent.deserialize() with a hardcoded 'pre-proof' type marker. This works for UnprovenTransaction (which has PreProof intents), but fails for UnboundTransaction (which has Proof intents after proveTx()). The WASM deserializer cannot parse proof-format bytes using the pre-proof marker.
The bug is in TransactionOps.ts line ~101:
// BUG: hardcoded 'pre-proof' fails for proven (UnboundTransaction) intents
ledger.Intent.deserialize<ledger.SignatureEnabled, ledger.PreProof, ledger.PreBinding>(
'signature',
'pre-proof', // ← should be 'proof' for UnboundTransaction
'pre-binding',
originalIntent.serialize(),
)When this error occurs, DUST coins allocated during balanceUnboundTransaction() become stuck in a "pending" state. The wallet SDK does not release pending coins on transaction failure (documented known issue in wallet SDK 1.0.0). The wallet must be restarted to recover them.
Instead of calling wallet.signRecipe(), implement signing manually with the correct proof marker for each transaction type:
import * as ledger from '@midnight-ntwrk/ledger-v7';
/**
* Sign all unshielded offers in a transaction's intents, using the correct
* proof marker for Intent.deserialize.
*/
const signTransactionIntents = (
tx: { intents?: Map<number, any> },
signFn: (payload: Uint8Array) => ledger.Signature,
proofMarker: 'proof' | 'pre-proof',
): void => {
if (!tx.intents || tx.intents.size === 0) return;
for (const segment of tx.intents.keys()) {
const intent = tx.intents.get(segment);
if (!intent) continue;
const cloned = ledger.Intent.deserialize(
'signature',
proofMarker, // Use the correct marker for the transaction type
'pre-binding',
intent.serialize(),
);
const sigData = cloned.signatureData(segment);
const signature = signFn(sigData);
if (cloned.fallibleUnshieldedOffer) {
const sigs = cloned.fallibleUnshieldedOffer.inputs.map(
(_: any, i: number) => cloned.fallibleUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.fallibleUnshieldedOffer = cloned.fallibleUnshieldedOffer.addSignatures(sigs);
}
if (cloned.guaranteedUnshieldedOffer) {
const sigs = cloned.guaranteedUnshieldedOffer.inputs.map(
(_: any, i: number) => cloned.guaranteedUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.guaranteedUnshieldedOffer = cloned.guaranteedUnshieldedOffer.addSignatures(sigs);
}
tx.intents.set(segment, cloned);
}
};
// In balanceTx:
async balanceTx(tx, ttl?) {
const recipe = await wallet.balanceUnboundTransaction(tx, keys, { ttl });
const signFn = (payload: Uint8Array) => unshieldedKeystore.signData(payload);
signTransactionIntents(recipe.baseTransaction, signFn, 'proof'); // proven tx
if (recipe.balancingTransaction) {
signTransactionIntents(recipe.balancingTransaction, signFn, 'pre-proof'); // wallet-created tx
}
return wallet.finalizeRecipe(recipe);
}Key insight: After proveTx(), the base transaction is an UnboundTransaction with Proof intents (use 'proof'). The balancing transaction created by the wallet is an UnprovenTransaction with PreProof intents (use 'pre-proof').
The old nativeToken() from @midnight-ntwrk/ledger (v4) returns a tagged 68-character hex token type (02000000...0000). The new wallet SDK stores balances keyed by raw 64-character hex token types from @midnight-ntwrk/ledger-v7.
If you use the wrong token type for balance lookups, the wallet will appear to have zero balance even when funds are present.
- import { nativeToken } from '@midnight-ntwrk/ledger';
- const balance = state.unshielded.balances[nativeToken()];
+ import { unshieldedToken } from '@midnight-ntwrk/ledger-v7';
+ const balance = state.unshielded.balances[unshieldedToken().raw];Debugging tip: If your wallet shows
Synced: truebut zero balance, logstate.unshielded.balancesand compare the actual key (0000...0000, 64 chars) against what your lookup function returns. If you see02000...0000(68 chars), you're still using the oldnativeToken().
- state.syncProgress?.synced → state.isSynced
- state.balances[nativeToken()] → state.unshielded.balances[unshieldedToken().raw]- wallet.close() → wallet.stop()
- Wallet & Resource type → WalletContext (custom type bundling wallet + keys)saveState/wallet.serialize()— serialization not yet supported in facade 1.0.0WalletBuilder— replaced by direct sub-wallet constructiontoZswapNetworkId()— no longer needed, facade uses string network IDs natively
levelPrivateStateProvider now requires either a walletProvider or privateStoragePasswordProvider for encrypting private state storage. Passing neither throws an error at runtime.
privateStateProvider: levelPrivateStateProvider<typeof CounterPrivateStateId>({
privateStateStoreName: contractConfig.privateStateStoreName,
+ walletProvider: walletAndMidnightProvider,
}),You must provide exactly one of:
walletProvider— uses the wallet's encryption public key (recommended when using wallet-sdk-facade)privateStoragePasswordProvider: () => string— a function returning a custom password (min 16 chars)
Providing both will also throw an error.
The wallet SDK provides bech32m-encoded addresses for all wallet types via @midnight-ntwrk/wallet-sdk-address-format:
import {
MidnightBech32m, ShieldedAddress,
ShieldedCoinPublicKey, ShieldedEncryptionPublicKey,
} from '@midnight-ntwrk/wallet-sdk-address-format';
// Shielded address (mn_shield-addr_<network>1...)
const coinPubKey = ShieldedCoinPublicKey.fromHexString(state.shielded.coinPublicKey.toHexString());
const encPubKey = ShieldedEncryptionPublicKey.fromHexString(state.shielded.encryptionPublicKey.toHexString());
const shieldedAddr = MidnightBech32m.encode(networkId, new ShieldedAddress(coinPubKey, encPubKey)).toString();
// Unshielded address (mn_addr_<network>1...)
const unshieldedAddr = unshieldedKeystore.getBech32Address();
// Dust address (mn_dust_<network>1...)
const dustAddr = state.dust.dustAddress;Important: UnshieldedKeystore uses getBech32Address() (a method), not .address (a property).
Address format reference:
| Type | Prefix | Example |
|---|---|---|
| Shielded | mn_shield-addr_<network>1... |
mn_shield-addr_preprod1q... |
| Unshielded | mn_addr_<network>1... |
mn_addr_preprod1q... |
| Dust | mn_dust_<network>1... |
mn_dust_preprod1w... |
On Preprod/Preview, NIGHT tokens generate DUST over time, but only after UTXOs are explicitly registered for dust generation via an on-chain transaction. This must happen before any contract deployment or interaction.
The registration flow:
- Check if dust is already available from a previous session
- Filter for unregistered NIGHT coins
- Call
wallet.registerNightUtxosForDustGeneration()with the coins and signing function - Wait for the wallet to report a non-zero dust balance
DUST balance accrues over time once registered. Initial DUST generation may take a minute or two.
| Issue | Cause | Fix |
|---|---|---|
Failed to clone intent during deploy/call |
signRecipe uses hardcoded 'pre-proof' marker for all intents, but proven transactions have 'proof' intents |
Bypass signRecipe and sign manually with correct proof markers — see workaround above |
| DUST balance drops to 0 after failed transaction | Pending coins not released on failure (wallet SDK 1.0.0 known issue) | Restart the wallet to recover pending DUST |
| Wallet shows zero balance despite receiving funds | Using nativeToken() (68-char tagged) instead of unshieldedToken().raw (64-char raw) for balance lookup |
Replace nativeToken() with unshieldedToken().raw from @midnight-ntwrk/ledger-v7 |
Either privateStoragePasswordProvider or walletProvider must be provided |
levelPrivateStateProvider now requires encryption config |
Pass walletProvider or privateStoragePasswordProvider in config |
Cannot find package '@midnight-ntwrk/wallet-sdk-address-format' |
Transitive dep not hoisted | Add @midnight-ntwrk/wallet-sdk-address-format as direct dependency |
Unknown field "zswapLedgerEvents" at runtime |
Local standalone indexer too old for new wallet SDK | Update docker images to versions compatible with wallet-sdk-facade |
Unknown field "dustLedgerEvents" at runtime |
Same as above | Same — update indexer docker images |
Unknown type "UnshieldedAddress" at runtime |
Same as above | Same — update indexer docker images |
The wallet-sdk-facade and midnight-js 3.0.0 require updated docker images with GraphQL v3 schema support. The indexer now exposes subscription fields for zswapLedgerEvents, dustLedgerEvents, and unshieldedTransactions that the new wallet SDK depends on for syncing.
- image: "midnightnetwork/proof-server:4.0.0"
+ image: 'midnightntwrk/proof-server:7.0.0'
- image: 'midnightntwrk/indexer-standalone:2.1.1'
+ image: 'midnightntwrk/indexer-standalone:3.0.0'
- image: 'midnightnetwork/midnight-node:0.12.0'
+ image: 'midnightntwrk/midnight-node:0.20.0'Note: The registry consolidated from midnightnetwork/ to midnightntwrk/ on Docker Hub.
For Preprod/Preview deployments where you only need a local proof server (indexer and node are remote):
# proof-server.yml
services:
proof-server:
image: 'midnightntwrk/proof-server:7.0.0'
command: ['midnight-proof-server -v']
ports:
- '6300:6300'
environment:
RUST_BACKTRACE: 'full'The proof server 7.0.0 no longer accepts the --network flag. Use -v for verbose mode only:
- command: ["midnight-proof-server", "--network", "testnet"]
+ command: ["midnight-proof-server -v"]The indexer 3.0.0 requires additional config:
environment:
APP__APPLICATION__NETWORK_ID: 'undeployed'
APP__INFRA__STORAGE__PASSWORD: 'indexer'
APP__INFRA__PUB_SUB__PASSWORD: 'indexer'
APP__INFRA__LEDGER_STATE_STORAGE__PASSWORD: 'indexer'Updated healthcheck for proof-server and indexer:
# Proof server
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:6300/version']
# Indexer
healthcheck:
test: ['CMD-SHELL', 'cat /var/run/indexer-standalone/running']The indexer GraphQL endpoint path changed from v1 to v3:
- /api/v1/graphql
+ /api/v3/graphql
- /api/v1/graphql/ws
+ /api/v3/graphql/wsNetwork configs were updated to use the new GraphQL v3 paths and target the Preview and Preprod networks instead of the old testnet.
Local (Standalone):
| Service | Endpoint |
|---|---|
| Indexer HTTP | http://127.0.0.1:8088/api/v3/graphql |
| Indexer WS | ws://127.0.0.1:8088/api/v3/graphql/ws |
| Node | http://127.0.0.1:9944 |
| Proof Server | http://127.0.0.1:6300 |
| NetworkId | undeployed |
Preview:
| Service | Endpoint |
|---|---|
| RPC Node | https://rpc.preview.midnight.network |
| Indexer HTTP | https://indexer.preview.midnight.network/api/v3/graphql |
| Indexer WS | wss://indexer.preview.midnight.network/api/v3/graphql/ws |
| Faucet | https://faucet.preview.midnight.network |
| Proof Server | http://localhost:6300 (local) |
| NetworkId | preview |
Preprod:
| Service | Endpoint |
|---|---|
| RPC Node | https://rpc.preprod.midnight.network |
| Indexer HTTP | https://indexer.preprod.midnight.network/api/v3/graphql |
| Indexer WS | wss://indexer.preprod.midnight.network/api/v3/graphql/ws |
| Faucet | https://faucet.preprod.midnight.network |
| Proof Server | http://localhost:6300 (local) |
| NetworkId | preprod |
- TestnetLocalConfig (testnet, local indexer at v1)
- TestnetRemoteConfig (testnet, remote indexer at v1)
+ PreviewConfig (preview network, v3 paths)
+ PreprodConfig (preprod network, v3 paths)
StandaloneConfig (local docker, updated to v3 paths)Quick reference for deploying a DApp to Preprod:
- Proof server: Run locally via Docker (
docker compose -f proof-server.yml up) - Wallet: Create or restore from seed — the app connects to remote Preprod indexer and RPC
- Fund wallet: Send tNight to the unshielded address via https://faucet.preprod.midnight.network
- Wait for DUST: After funding, NIGHT UTXOs are registered for dust generation automatically. Wait for DUST to accrue before deploying.
- Deploy contract: Once DUST balance is non-zero, deploy your contract through the DApp
| Pitfall | Resolution |
|---|---|
Failed to clone intent during deploy |
Wallet SDK signing bug — bypass signRecipe() with manual signing using correct proof markers (see Section 4) |
| DUST drops to 0 after failed deploy | Known wallet SDK issue — restart wallet to release pending coins |
| Wallet shows zero balance after faucet | Ensure you're using unshieldedToken().raw (not nativeToken()) — see Section 4 |
Either privateStoragePasswordProvider or walletProvider must be provided |
Pass walletProvider to levelPrivateStateProvider — see Section 4 |
| Proof server fails with "unexpected argument '--network'" | Remove --network flag, use -v only — see Section 5 |
@midnight-ntwrk/wallet-sdk-address-format not found |
Add it as a direct dependency in package.json |
Migration guide covering: Compact compiler, Contract pragma, JS dependencies (midnight-js 3.0.0), Wallet SDK (wallet-sdk-facade), Docker infrastructure, Network configuration, and Preprod deployment.