An Effectstream template implementing a ZSwap presale / bonding sale on Midnight: a
contract issues N shielded TOKENX at a fixed price P (NIGHT per TOKENX). Buyers build
unbalanced ZSwap offers (NIGHT in → self-addressed TOKENX out). A server-side intermediary
accumulates them and, once ~90% is backed, executes them in batches — one exchange contract
call merged with the buyer legs in a single block. After 90% sold anyone may self-serve; until
the sale locks (at N·P NIGHT) any holder may cancel (return TOKENX → get NIGHT back).
The banked NIGHT is either frozen in the contract (pump.fun) or withdrawable by the
owner (presale) — fixed at deploy.
The package/dir is still named
midnight-preorder/@midnight-preorder/*(this is the presale branch of that template; names kept to limit churn).
Buyers pay in USDMx, a shielded Midnight stablecoin. For this demonstration USDMx is minted
on demand by a built-in faucet (the tokenswap contract) so anyone can try the flow without sourcing
real funds. The presale doesn't hard-code the currency: it seals a tokenswapAddress at deploy
(TOKENSWAP_CONTRACT_ADDRESS) and reconstructs the payment-token color from it
(tokenType(TOKEN_DOMAIN, tokenswapAddr)).
Because the payment token is just that configured address, pointing it at a different token
contract switches the currency — no contract changes. To run the sale against real
Cardano-bridged USDM, set TOKENSWAP_CONTRACT_ADDRESS to the bridged-USDM token contract and
deploy. Bridge USDM from Cardano to Midnight at https://midnight.anytoany.xyz/.
buyer (NIGHT) intermediary BATCHER (harness) balancing BATCHER contract
┌─────────────┐ offer ┌─────────────────────────────┐ unbal. ┌──────────────┐ one ┌─────────────┐
│ +M·P NIGHT │ ──────▶│ exchange(totalToken, │ proven │ + DUST fees │ tx │ mint TOKENX │
│ −M TOKENX │ │ totalNight) + merge legs │ tx │ (own wallet) │ ─────▶│ recv NIGHT │
│ (self-addr) │ │ prove (intermediary secret) │ ───────▶│ submit │ │ vault += V │
└─────────────┘ └─────────────────────────────┘ └──────────────┘ └─────────────┘
cancel: −TOKENX / +NIGHT (contract sendShielded refunds from the vault, before lock)
withdraw: owner drains the vault (released mode, after lock)
- exchange —
assert price·totalToken == totalNight; flat-mint TOKENX (consumed by the buyers' self-addressed outputs); receive NIGHT into the vault. Intermediary-gated until 90% sold; open afterwards. Both token deltas net in segment 0 of the one merged tx. - cancel — receive the returned TOKENX;
sendShieldedthe NIGHT refund from the vault to the caller's coin public key. Allowed while the sale is open. - withdraw —
sendShieldedthe whole vault to the owner. Only whennightMode = releasedand the sale has locked. (Inlocked/pump.fun mode the NIGHT can never leave.)
ownPublicKey() returns an unconstrained witness — a prover can inject any key without
knowing a secret, so it can't gate access. Instead the contract stores only a commitment
persistentHash([authSalt, secret]) (computed in the constructor from a witness secret, so the
secret never touches the chain). exchange (pre-90%) and withdraw recompute the hash from the
caller-supplied secret and assert it matches — which binds the proof. Cancel/withdraw
recipients are explicit args (same reason). Mirrors zk-cardano's ballot.compact.
- Intermediary = the harness: builds + proves the merged
exchange/cancel/withdrawtx (it batches the buyer offers). The operator supplies no NIGHT. - Balancing adapter = a separate
MidnightBalancingAdapterbatcher (port 3334) with its own genesis-funded DUST wallet: it receives the proven, unbalanced tx, adds DUST fees, and submits. The intermediary forwards viaPOST /send-input({tx, txStage:"unbound"}).
| Package | Role |
|---|---|
packages/contracts-midnight/contract-preorder |
The ZSwapPresale Compact contract — exchange / cancel / withdraw circuits, secret-commitment auth, NIGHT vault, lock + nightMode. Compiles to src/managed/ with real proving keys. |
packages/contracts-midnight |
Midnight node/indexer/proof-server launch + deploy.ts (price / target / threshold / nightMode / token name; generates the role secrets). |
packages/harness |
The intermediary + buyer simulator (HTTP API on :3340). Manages wallets, funds buyers, builds + proves NIGHT→TOKENX offers, runs batched exchange / cancel / withdraw via the guaranteed-only construction, then forwards the proven tx to the balancing batcher. gate.ts is the standalone 1-buyer proof it generalizes. |
packages/batcher |
The balancing-adapter batcher (batcher.dev.ts → MidnightBalancingAdapter, :3334) that pays DUST + submits, plus block-sizing tools. |
packages/frontend |
The ops dashboard (Vite/React, :10599) — actor tabs Deploy ▸ Wallets ▸ Exchange (the "Start 90% flow" button) ▸ Cancel ▸ Withdraw, with a read-only chain-state column. Thin client over the harness API. |
packages/node + packages/database |
Sync node + DB — indexes presale state each block; GET /api/campaign + exchange history. |
packages/tests |
Phase A (infra up) + Phase B (campaign indexed). |
bun≥ 1.3,compactCLI with the0.30.0toolchain (compact update 0.30.0).- Midnight ZK params cached locally (the proof server uses
~/.cache/midnight/zk-params). - No Docker —
launchMidnightruns the node/indexer/proof-server as nativenpm-midnight-*binaries.
bun install
bun run dev # pglite + midnight node/indexer/proof + deploy + sync node
# + intermediary harness (:3340) + balancing batcher (:3334) + dashboard (:10599)bun run dev brings up the full local Midnight stack (node 9944, indexer 8088, proof
6300), compiles + deploys the presale contract, and starts the sync node, the harness
intermediary (:3340), the balancing batcher (:3334), and the dashboard (:10599). Then
open http://localhost:10599.
⚠️ One local Midnight stack at a time — the ports are shared; stop any other running stack first.
Configure the sale at deploy time (or from the Deploy tab):
PRESALE_PRICE=10 PRESALE_TARGET=100 PRESALE_NIGHT_MODE=released PRESALE_TOKEN_NAME=Batata bun run devOpen http://localhost:10599. Left = actor tabs (all writes); right = a scrollable read-only chain-state column (tokensSold/N with the 90% marker, NIGHT banked, status open/locked, nightMode, funder pool, buyer balances); a live SSE event console runs full-width along the bottom. All wallets are harness-managed — no browser wallet needed:
- Deploy ▸ operator — price, target
N, nightMode (locked = pump.fun / released = presale), token name. - Wallets ▸ buyer — create/batch buyers (each funded
price × tokensNIGHT from the dust-capable funder pool); build their NIGHT→TOKENX offers. - Exchange ▸ operator — "Start 90% flow": the intermediary executes pending offers in ≤16-buyer batches (the balancing batcher pays DUST + submits). Past 90% sold, a per-buyer "Buy" button lets each pending buyer self-serve their own NIGHT→TOKENX exchange.
- Cancel ▸ buyer — "Cancel all", or a per-holder "Cancel" button to return one holder's TOKENX → NIGHT refund from the vault (while the sale is open).
- Withdraw ▸ owner — drain the vault (released mode, after lock; disabled in pump.fun mode).
Scriptable: POST /api/deploy {price?, target?, nightReleased?, tokenName?}, /api/wallets/batch,
/api/offers/build, /api/exchange, /api/cancel, /api/withdraw, GET /api/state (:3340).
The exchange call and the buyer offers must net in one segment. The documented midnight-js
path puts the call's mint in a random fallible segment while its receipt sits in segment 0 — the
node rejects it (EffectsCheckFailure). The harness re-assembles the call guaranteed-only
(low-level ContractCallPrototype + Transaction.fromParts, transcript forced into segment 0),
co-located with the buyer legs. packages/harness/gate.ts is the standalone 1-buyer proof.
An exchange tx fits only a few dozen ZSwap proofs, so N settles across batches/blocks.
Measured binding limit: block_usage (200,000/block) → ~18 buyer in+out pairs per block;
the harness chunks to BATCH_SIZE = 16. See docs/SIZING.md.
- ✅ Compiled —
exchange/cancel/withdraw+ the secret-commitment auth compile on Compact 0.30.0. - ✅ Gate (proof-of-knowledge) — the correct intermediary secret is accepted; a wrong secret is
rejected (
"intermediary only before 90%"). NoownPublicKey. - ✅ Vault send —
cancelrefunds NIGHT from the vault into the buyer's wallet;withdrawdrains it to the owner after lock. - ✅ Two-batcher —
exchange+cancelroute through the balancing batcher, which pays DUST from its own wallet and submits (the operator supplies no NIGHT). - ✅ End-to-end (headless) — the full UI lifecycle (deploy → fund → Start-90%-flow → lock →
withdraw) and both night modes pass via Playwright (
packages/frontend/e2e/app.spec.ts). - ✅ Builds —
build:midnight,build:pgtypes, the frontendtsc --noEmit+vite buildgreen.
- DA-layer ingestion + STM auto-trigger — today the operator starts the flow with the
"Start 90% flow" button. The ideal: buyers post ZSwaps to a DA layer; the sync-node STM detects
the 90% crossing and auto-triggers the intermediary batcher (documented hooks in
packages/node/state-machine.ts). - Client-side self-serve build — the dashboard now exposes per-buyer Buy (post-90%) and per-holder Cancel controls, but the offer is still built + proven server-side in the harness; the remaining step is a true in-browser wallet building/proving its own exchange.
refundon expiry, and mainnet*.mainnet.tsconfigs with env-sourced secrets.