This PoC tests one boundary:
wallet/client keeps the zswap secret key
proof server builds and proves a split spend without seeing that key
The wallet already knows how to derive keys, sync/index chain data, decrypt owned shielded outputs, filter spent outputs, and maintain the zswap Merkle state. The new wallet-side work for split proving is the handoff plus a real local proof: it computes the split-prove request fields and proves that the wallet knows the sk linking those fields.
wallet/client
existing wallet work:
derive zswap key
fetch preview/indexer data
decrypt owned shielded outputs
filter spent outputs
build Merkle state/path
split-prove handoff:
generate/reuse wallet attestation proof
prove sk -> nullifier/coinBindingTag/commitmentSk
compute nullifier
read selected output commitmentHash
package coin metadata + Merkle state/path
POST /v2/prove-split-spend
proof server
pre-verify wallet attestation and client derivation proofs
reconstruct QualifiedCoinInfo
recompute commitmentHash = H(coin, pk) and reject mismatches
load Merkle tree/path
reject handoffs whose commitment does not reproduce the tree root
prove coinCommitment, Merkle membership, nullifier insertion, coinBindingTag, and value commitment
call Input::new_split
prove midnight/zswap/spend-split
return proofHex + provedInputHex
local chain
verify attestation, client-derivation, and spend-split proofs in node admission
read through the indexer
Dust-balance and submit through the wallet SDK/raw RPC bridge
The live e2e always spends the selected local-chain shielded output and creates
a shielded output for MIDNIGHT_PREVIEW_RECIPIENT_SHIELDED_ADDRESS. The token
movement is full split-send only: client derivation proof -> server split proof
-> split transaction assembly -> wallet SDK Dust balancing -> submission.
The wallet-side per-spend client-derivation circuit is intentionally smaller than the server split-spend circuit. The current artifacts are:
| Circuit | prover key | Role |
|---|---|---|
sk_prove / client derivation |
2.82 MB | per spend, wallet-local |
wallet_attest / wallet attestation |
2.82 MB | one time per sk, wallet-local |
spend-split / server split spend |
5.74 MB | per spend, proof server |
The wallet attestation proves pk = H_persistent(sk) and commitmentSk = H_transient(sk, r) once. The per-spend client proof opens that same commitmentSk, proves the canonical nullifier = H_persistent(coin, sk), and discloses coinBindingTag = H_transient(domain, coin, pk). The server split proof computes the canonical coinCommitment = H_persistent(coin, pk), checks the Merkle leaf, inserts the public nullifier, discloses the same coinBindingTag, and proves the value commitment. The e2e report treats the per-spend client proof and server proof durations as the primary split-prove comparison and separates out attestation, scan, handoff overhead, output proving, Dust balancing, and submit time because those are setup or full transaction workflow costs.
deps/midnight-ledger/proof-server/src/endpoints.rs:POST /v2/prove-split-spend.deps/midnight-ledger/proof-server/src/preview_client.rs: local CLI helper that simulates wallet-side preview work and handoff creation.deps/midnight-ledger/proof-server/src/bin/preview_split_prove.rs: runnable preview e2e command.deps/midnight-ledger/zswap/src/construct.rs:new_splitconstructors.deps/midnight-ledger/zswap/src/prove.rs: split circuit key resolution.deps/midnight-ledger/zswap/static/*split*: local split proving artifacts.tools/derive_midnight_zswap_seed.mjs: temporary phrase-to-zswap-seed helper for the PoC.tools/preview_balance_submit_split_tx.mjs: wallet SDK Dust balancing and submit bridge.
Use the local Compact CLI directly when regenerating the split-prove artifacts:
compact compile --no-communications-commitment circuits/sk_proof.compact /tmp/sk-prove-compile
compact compile --no-communications-commitment circuits/wallet_attestation.compact /tmp/wallet-attest-compile
compact compile --no-communications-commitment deps/midnight-ledger/zswap/zswap-split.compact /tmp/zswap-split-compileCopy the generated client derivation artifacts into circuits/static/client-derivation/ and the wallet attestation artifacts into circuits/static/wallet-attestation/. Mirror the verifier keys needed by node admission into deps/midnight-ledger/zswap/static/client-derivation.verifier and deps/midnight-ledger/zswap/static/wallet-attestation.verifier. Copy the generated zswap spendSplitUser and signSplitUser artifacts into deps/midnight-ledger/zswap/static/, update the .sha256 sidecars, and mirror spendSplitUser.zkir to deps/midnight-ledger/zkir-precompiles/zswap/spend-split.zkir.
Create .env:
cp .env.example .envUse the local-dev defaults from .env.example. The MIDNIGHT_PREVIEW_* prefix is historical — every var targets the local undeployed network. The e2e cannot run against the public preview chain: that node lacks the SPEND_SPLIT_VK / SIGN_SPLIT_VK fallback in well_formed (ledger commit a72735ae) and its indexer cannot replay split-send blocks.
MIDNIGHT_PREVIEW_NODE_WS=ws://127.0.0.1:9944
MIDNIGHT_PREVIEW_INDEXER_HTTP=http://127.0.0.1:8088/api/v4/graphql
MIDNIGHT_PREVIEW_INDEXER_WS=ws://127.0.0.1:8088/api/v4/graphql/ws
MIDNIGHT_PREVIEW_NETWORK_ID=undeployed
# Refuse accidental public-preview / non-patched endpoints by default.
MIDNIGHT_ALLOW_REMOTE_PATCHED_STACK=
MIDNIGHT_PREVIEW_RECOVERY_PHRASE="<local-dev funded test phrase>"
MIDNIGHT_PREVIEW_ACCOUNT=0
MIDNIGHT_PREVIEW_ZSWAP_KEY_SCAN_LIMIT=1
MIDNIGHT_PREVIEW_ZSWAP_EVENT_LIMIT=50000
MIDNIGHT_PREVIEW_RECIPIENT_SHIELDED_ADDRESS=<receiver shielded address>
MIDNIGHT_PREVIEW_TRANSFER_AMOUNT=200000000
MIDNIGHT_PREVIEW_WALLET_SUBMIT_MODE=raw-rpc
# Image tag produced by `docker build -f deps/midnight-node/Dockerfile.split-prove`.
MIDNIGHT_NODE_IMAGE=midnight-node:split-prove
MIDNIGHT_PREVIEW_ZSWAP_SEED_HEX=.env is gitignored. Do not commit it. MIDNIGHT_PREVIEW_ZSWAP_SEED_HEX is
only enough for prove-only helper paths; the full split-send e2e requires the
recovery phrase so the helper can derive the matching Dust and unshielded keys.
The standalone Midnight local-dev network is vendored into this repo under
deps/midnight-local-dev. It stays as its own npm package.
Install its dependencies once:
make local-installmake local-nodesThe Makefile loads the root .env before starting local-dev. Put image
overrides there when needed:
MIDNIGHT_NODE_IMAGE=midnight-node:split-prove
MIDNIGHT_INDEXER_IMAGE=split-prove/indexer-standalone:local
MIDNIGHT_PROOF_SERVER_IMAGE=<split-proof-server-image>The local chain uses network_id=undeployed and exposes 9944, 8088, and
6300 on localhost.
The compose default for MIDNIGHT_INDEXER_IMAGE is
split-prove/indexer-standalone:local, which is the v4.0.1 indexer rebuilt
against deps/midnight-ledger so its Zswap verifier matches the rebuilt node.
Build it once with:
make rebuild-imagesThe indexer source lives at deps/midnight-indexer (submodule pointing at
ADGLx/midnight-indexer, branch feature/split-prove-indexer-4.0.1). Re-run
make rebuild-images after any change to that submodule or to
deps/midnight-ledger. To run against the stock 4.0.1 image instead (which
will crash on the split-send block), override:
MIDNIGHT_INDEXER_IMAGE=midnightntwrk/indexer-standalone:4.0.1Install dependencies once:
npm installRun the e2e command:
make e2eExpected output:
split-sent preview output key_index=0 mt_index=<index> input_value=<raw NIGHT> transfer_value=<raw NIGHT> change_value=<raw NIGHT> token=<token-type> recipient=<shielded-address> status="proofBuilt" client_proof_ms=<ms> server_proof_ms=<ms> split_proof_total_ms=<ms> server_client_ratio=<ratio> proof_len=<bytes> tx_hash=<hash> tx_id=<id> tx_len=<hex chars> pre_submit_wasm_check=skipped inclusion=finalized block_hash=<hash>
The underlying preview driver can still be run directly. By default it starts a local proof server on a random port. To use an already running proof server:
cargo run --offline -p midnight-proof-server --bin preview-split-prove \
--manifest-path deps/midnight-ledger/Cargo.toml \
-- --proof-server-url http://127.0.0.1:6300The command always assembles and submits the split-send transaction. It sends
MIDNIGHT_PREVIEW_TRANSFER_AMOUNT raw NIGHT units to
MIDNIGHT_PREVIEW_RECIPIENT_SHIELDED_ADDRESS, defaulting to 500 NIGHT, and
returns any remainder as shielded change to the spender wallet. Submission
defaults to MIDNIGHT_PREVIEW_WALLET_SUBMIT_MODE=raw-rpc, which uses the wallet
SDK to sync Dust, add fee-balancing DustActions, finalize the transaction, and
then submit the finalized transaction directly through node RPC. The selected
wallet must have enough spendable Dust. Use
MIDNIGHT_PREVIEW_WALLET_SUBMIT_MODE=wallet to submit through the wallet SDK
watcher instead.
The helper uses local wallet SDK packages when installed, or
../one-am-wallet/node_modules; override with MIDNIGHT_PREVIEW_WALLET_NODE_MODULES.
First-time Dust proving may need to download and verify Dust proving assets; the
helper retries finalizeRecipe twice by default. Tune with
MIDNIGHT_PREVIEW_DUST_PROVE_ATTEMPTS and
MIDNIGHT_PREVIEW_DUST_PROVE_RETRY_DELAY_MS.
When testing against the local Docker stack, the node and indexer images need
matching ledger/zswap code. The rebuilt indexer image
(split-prove/indexer-standalone:local, built from
deps/midnight-indexer against deps/midnight-ledger) is the compose default
and replays split-send blocks successfully. The stock
midnightntwrk/indexer-standalone:4.0.1 image links its own packaged ledger
crates and will exit with
malformed transaction: Invalid proof -- while verifying Zswap proof while
replaying the block containing a split-send tx.
To verify transaction correctness without relying on the indexer's post-submit replay, the e2e path uses these checks:
- Rust-side
Input<Proof>::well_formedandOutput<Proof>::well_formedare called before the tx is assembled, using the locally-built zswap verifier keys, so the Zswap proof is verified against the same code the rebuilt node runs (seepreview_client.rs). - The wallet helper uses
author_submitAndWatchExtrinsicand waits for the node to reportfinalizedby default before resolving. Tune withMIDNIGHT_PREVIEW_RAW_RPC_WAIT_FOR=submitted|inBlock|finalized. The node's inclusion check uses the same locally-built ledger code as the proof-server. - An optional JS-side
ledger.wellFormedcheck is available viaMIDNIGHT_PREVIEW_VALIDATE_LEDGER_WASM=1. Off by default because the registry@midnight-ntwrk/ledger-v8wasm package links the packaged zswap verifier and will reject locally-modified proofs even when the local node accepts them — same root cause as the stock indexer crash.
The live e2e test asserts the local node reports inclusion with a block hash, so it succeeds even when the stock indexer container crashes during replay.
POST /v2/prove-split-spend
Request shape:
{
"coinBindingTag": "<32-byte field hex>",
"nullifier": "<32-byte hex>",
"pk": "<32-byte public key hex>",
"commitmentHash": "<32-byte hex>",
"coinValue": 500,
"coinType": "<32-byte token type hex>",
"coinNonce": "<32-byte hex>",
"mtIndex": 1622,
"contractAddress": null,
"zswapState": "<serialized zswap state hex>",
"clientDerivationProof": "<serialized client derivation proof hex>",
"attestedCommitmentSk": "<32-byte field hex>",
"attestationProof": "<serialized wallet attestation proof hex>",
"prove": true
}Response shape:
{
"status": "proofBuilt",
"keyLocation": "midnight/zswap/spend-split",
"merklePathSource": "zswapState",
"inputPreimageHex": "<serialized Input<ProofPreimage> hex>",
"proofHex": "<serialized proof hex>",
"provedInputHex": "<serialized Input<Proof> hex>",
"proofError": null
}When "prove": true, the endpoint requires zswapState or zswapStateFile. The older simulated single-leaf fallback is still useful for preimage-shape debugging with "prove": false, but it is intentionally rejected for proof-building requests.
The current demo supports user-owned shielded coins only. Contract-owned split spends are rejected by the proof-server and SDK preimage builder.
Open work toward production:
- Move the PoC preview client logic into the real wallet/client integration point.
- Replace full
zswapStatehandoff with the smallest acceptable Merkle witness if that is the desired production API. - Move the wallet SDK Dust balancing bridge into the real wallet/client integration point.
- Harden the endpoint and request validation for production.
Compile the CLI:
cargo check --offline -p midnight-proof-server --bin preview-split-prove \
--manifest-path deps/midnight-ledger/Cargo.tomlRun the synthetic e2e test. This generates a client derivation proof, posts it over the HTTP endpoint, verifies it on the server, and builds the split spend proof:
cargo test --offline -p midnight-proof-server synthetic_client_derivation_proof_is_verified_before_split_proving \
--manifest-path deps/midnight-ledger/Cargo.toml \
-- --nocaptureRun the live full split-send e2e test. This is opt-in because it uses wallet
secrets from .env, the configured indexer/node, and a local proof server:
MIDNIGHT_RUN_PREVIEW_E2E=1 \
MIDNIGHT_PREVIEW_RECIPIENT_SHIELDED_ADDRESS=<receiver> \
cargo test --offline -p midnight-proof-server preview_wallet_proves_real_unspent_split_spend \
--manifest-path deps/midnight-ledger/Cargo.toml \
-- --nocapture