Skip to content

Repository files navigation

ZSwap Presale

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).

Payment token — USDMx (demo) → Cardano-bridged USDM

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/.

Lifecycle

 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)
  • exchangeassert 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; sendShielded the NIGHT refund from the vault to the caller's coin public key. Allowed while the sale is open.
  • withdrawsendShielded the whole vault to the owner. Only when nightMode = released and the sale has locked. (In locked/pump.fun mode the NIGHT can never leave.)

Authentication — proof-of-knowledge, not ownPublicKey()

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.

Two batchers

  • Intermediary = the harness: builds + proves the merged exchange/cancel/withdraw tx (it batches the buyer offers). The operator supplies no NIGHT.
  • Balancing adapter = a separate MidnightBalancingAdapter batcher (port 3334) with its own genesis-funded DUST wallet: it receives the proven, unbalanced tx, adds DUST fees, and submits. The intermediary forwards via POST /send-input ({tx, txStage:"unbound"}).

Architecture

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.tsMidnightBalancingAdapter, :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).

Prerequisites

  • bun ≥ 1.3, compact CLI with the 0.30.0 toolchain (compact update 0.30.0).
  • Midnight ZK params cached locally (the proof server uses ~/.cache/midnight/zk-params).
  • No Docker — launchMidnight runs the node/indexer/proof-server as native npm-midnight-* binaries.

Run

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 dev

Using the dashboard

Open 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:

  1. Deployoperator — price, target N, nightMode (locked = pump.fun / released = presale), token name.
  2. Walletsbuyer — create/batch buyers (each funded price × tokens NIGHT from the dust-capable funder pool); build their NIGHT→TOKENX offers.
  3. Exchangeoperator"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.
  4. Cancelbuyer"Cancel all", or a per-holder "Cancel" button to return one holder's TOKENX → NIGHT refund from the vault (while the sale is open).
  5. Withdrawowner — 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 load-bearing construction

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.

Batch sizing

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.

Status — verified live (bun run dev)

  • Compiledexchange / 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%"). No ownPublicKey.
  • Vault sendcancel refunds NIGHT from the vault into the buyer's wallet; withdraw drains it to the owner after lock.
  • Two-batcherexchange + cancel route 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).
  • Buildsbuild:midnight, build:pgtypes, the frontend tsc --noEmit + vite build green.

Next steps

  • 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.
  • refund on expiry, and mainnet *.mainnet.ts configs with env-sourced secrets.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages