Skip to main content

ZSwap Offerfile Kernel (frontend)

Template: templates/zswap-da

ZSwap is a peer-to-peer DEX with no pool, no escrow and no on-chain order book. A maker builds a Midnight transaction that is deliberately unbalanced — it spends what they are giving and pays out what they want, but nobody has funded the second half — serializes it, and publishes it as a blob on Celestia. A taker who likes the terms mirrors the missing side with their own wallet, merges the two halves into a single transaction, proves it, and submits it. It settles atomically or not at all.

This template directory contains the frontend only. There is no packages/ tree here: it is a flat React 19 + Vite 7 single-page app. The sync node, batcher, Compact contracts, database and validator it talks to live in a separate repository, effectstream/zswap-offerfiles-kernel. Read this template for how a Midnight dApp frontend is wired — wallet discovery, offer encoding, in-browser proving, batcher submission — and read the backend repo for the state machine and the primitive configuration.

What this template shows

Data availability used as an order book. The thing posted to Celestia is not a summary of an offer, it is the offer: a real, signed, partially-built Midnight transaction, bech32m-encoded under MIP-0005 with the HRP swapoffer. src/services/makerOffer.ts builds it through the wallet's makeIntent, deliberately without paying fees:

// makeIntent(payFees:false) → serialize → encodeOffer. The offer is
// intentionally imbalanced (gives ≠ wants); the taker's wallet balances + the
// batcher pays fees, so the maker commits no Dust.

Because the offer never touches Midnight while it is open, a maker can post ten offers and cancel them all by doing nothing. The cost of listing is a Celestia blob, not a contract call. What the chain sees is only the settlement.

Taking is mirror-then-merge, not "accept". src/services/browserContract.ts decodes the maker's blob back into a Transaction, works out which segment carries the asset imbalance (pickSwapSegment), and then has the taker's wallet construct the exact inverse — the maker's wants become the taker's inputs, the maker's gives become the taker's outputs — before merging and proving. The two code paths differ by token kind for a concrete wallet reason, documented in the source: shielded-only offers have no Intent slots, so balanceSealedTransaction throws "No segments found", and the code mirrors via makeIntent and merges segment-0 offers instead; unshielded offers cannot take that path because Lace's unshielded makeIntent adds an empty structural Intent[1] to both sides, which collides on merge.

Offer liveness: the hard part of an off-chain order book. An order book living in a DA layer can show you an offer whose coins were spent five minutes ago — nothing revokes a blob. So the backend answers three questions about every offer before listing it and before letting it be taken:

QuestionAnswered from
Has this coin already been spent?Shielded coin nullifiers (PrimitiveTypeMidnightNullifierAndCommitment) and unshielded UTXO spends (PrimitiveTypeMidnightUnshieldedSpend)
Does the UTXO the offer spends actually exist?Coin commitments (the same nullifier/commitment primitive) and unshielded UTXO creations (PrimitiveTypeMidnightUnshieldedCreate)
Is the Merkle root the offer proved against real, and recent?The zswap coin-commitment tree root as it advances (PrimitiveTypeMidnightZswapRoot)

Those four primitives ship in @effectstream/sm/builtin and are configured in the backend node, not in this directory — this template contains no ConfigBuilder and no primitive declarations at all. Two of the three answers surface directly in the frontend, which is the part worth studying here.

Root freshness shows up as a retryable submission error. src/services/api.ts treats ROOT_UNKNOWN as transient rather than fatal:

// ROOT_UNKNOWN is transient: the maker proves against a real chain root, but
// the sync node may not have ingested it into `known_roots` yet. Re-submitting
// the same blob succeeds once the root lands (mirrors the e2e suites). Other
// errors throw immediately.

That is root-liveness seen from the client: the node refuses an offer whose Merkle root it has not yet observed, and the frontend retries up to 24 times at 4-second intervals rather than showing a failure. When the retries run out the user is told their wallet is ahead of the chain, not that the offer was invalid.

Spend detection arrives as a push. The node's SSE stream (src/hooks/useEventStream.ts) delivers offer_consumed events, and src/types/index.ts declares the payload carrying a nullifier — the same nullifier the primitive observed on-chain. src/state/myTrades.ts consumes these to move an offer out of the book, and names the gap the DA model creates:

// not_public: submitted to Celestia but not yet visible in the live order book

Balance guards as a correctness requirement, not a nicety. src/services/takerBalance.ts exists because of a specific failure mode: when the wallet does not hold a coin the transaction needs, Lace's makeIntent hangs indefinitely instead of erroring. So both createOffer and takeOffer in src/state/useZSwapApp.ts re-read fresh balances — readState(connected), not the cached React state — and refuse before touching the wallet. The comparison is exact-integer with no decimal scaling, because offer amounts are raw bigints and wallet balances are raw integer strings keyed by token color.

Effectstream features used

FeatureWhereUsed for
MIP-0005 offer codec (OfferFiles, @effectstream/mip-zswap-offer/mip5)src/services/makerOffer.tsEncoding the maker's transaction as a swapoffer1… blob
MIP-0005 decodesrc/services/offerParse.ts, src/services/offerSender.tsReconstructing a Transaction from an offer blob
MIP-0006 leg derivation (P2pAtomicSwaps.deriveTokenLegs)src/decodeOffer.tsTurning a raw offer transaction into tagged gives/wants legs
@effectstream/wallets (walletLogin, allInjectedWallets)src/state/wallet.tsDiscovering and connecting injected Midnight wallets
@effectstream/wallets/midnight-local (MidnightLocalConnector)src/state/wallet.tsThe built-in JS wallet, in facade mode, on undeployed networks
Batcher HTTP API (POST /send-input)src/services/api.tsSubmitting the settled Midnight transaction at wait-receipt
Node event stream (SSE)src/hooks/useEventStream.tsLive offer_indexed / offer_consumed / offer_expired / token_minted
Node custom API routessrc/services/api.tsOrder book, quotes, pairs, known tokens, Midnight config
Midnight ledger primitives (declared in the backend repo)Observed in src/services/api.ts and src/hooks/useEventStream.tsOffer liveness: root freshness (ROOT_UNKNOWN) and nullifier-driven offer_consumed

Quick start

Prerequisites:

  • Bun, and a Midnight browser wallet (Lace) for anything that transacts. Minting, creating and taking offers all need it — src/state/tradeWallet.ts ships the local JS wallet as a typed stub that throws a "coming soon" error for those three operations.

  • The backend, checked out as a sibling directory. package.json resolves @zswap-da/contract-offer-files through a relative file: path, so the checkout must be named zswap-offerfile-kernel (singular — the GitHub repository is zswap-offerfiles-kernel):

    Code/
    ├── effectstream/ # this monorepo
    └── zswap-offerfile-kernel/ # backend — github.com/effectstream/zswap-offerfiles-kernel

Start the backend first. It compiles the Compact contract this app imports, and serves the API, the batcher and the ZK artifacts:

git clone git@github.com:effectstream/zswap-offerfiles-kernel.git zswap-offerfile-kernel
cd zswap-offerfile-kernel
bun install
bun run dev

Then the frontend:

cd effectstream/templates/zswap-da
bun install
bun run dev
ServiceURL
ZSwap frontend (Vite)http://localhost:10600
Backend API (default in src/config.ts)http://<hostname>:9999
Batcher (default in src/config.ts)http://<hostname>:3334
Midnight contract, indexer and proof serverFetched at runtime from GET /api/midnight/config

src/state/wallet.ts carries fallback URLs used only by the local JS wallet when that config call fails — indexer http://<hostname>:8088/api/v3/graphql, node http://<hostname>:9944, proof server http://<hostname>:6300. They are a last resort, not the source of truth.

If @zswap-da/contract-offer-files fails to resolve, the backend has not compiled the contract yet. Run its dev stack once, then bun install here again.

Project structure

There is no packages/ directory. The template is a flat Vite app:

index.html Vite entry point
vite.config.ts React + wasm + node-stdlib polyfills, crypto shim, ZK-artifact 404 guard
public/ Static assets served at the site root
src/
App.tsx Shell: Order book / How it works / Faucet, plus the bottom console dock
main.tsx React root
config.ts API base, batcher URL and batcher target resolution
constants.ts Filter directions, token kinds, page size, validation limits
decodeOffer.ts MIP-0005 decode + MIP-0006 leg derivation, for display
debug.ts dlog / timed instrumentation used throughout the services
utils.ts Token-name lookup and formatting helpers
hooks/ Wallet, contract, order book, SSE events, tokens, mint reconciliation
screens/ Market, Swap, MyTrades, Faucet, HowItWorks
services/ api, browserContract, makerOffer, offerParse, offerSender,
takerBalance (+ its test), mintQueue
shims/ crypto polyfill and loose .d.ts files for @effectstream/wallets
state/ useZSwapApp orchestration, wallet + tradeWallet adapters,
local myOffers / myTrades stores, formatting
styles/ Design tokens and global CSS
types/ Shared types and the window.midnight declaration
ui/ Modals, toasts, wallet and network menus, console dock, icons

How it works

Configuration resolution

src/config.ts resolves the backend endpoints in a fixed order — window.API_BASE set by a hosting page, then the VITE_API_BASE build-time variable, then http://<hostname>:9999. The same three tiers apply to the batcher. That ordering is what lets one built bundle be dropped behind a proxy without a rebuild.

Everything Midnight-specific — contract address, indexer URI, indexer WS URI, proof server URI and network id — is fetched at runtime from GET /api/midnight/config, so the frontend hardcodes no deployment. src/hooks/useContract.ts additionally refuses to proceed when the connected wallet's networkId differs from the one the backend reports.

Making an offer

createOffer in src/state/useZSwapApp.ts:

  1. Re-read fresh balances and run shortfallsFromLegs over the gives legs, throwing a readable message rather than letting the wallet hang.
  2. buildMakerOfferBlob (src/services/makerOffer.ts) collects the wallet's shielded and unshielded addresses, maps gives to inputs and wants to outputs routed back to the maker, and calls makeIntent. The intent id is drawn at random from ≥ 2, because segment 0 is the guaranteed offer and Lace's balanceSealedTransaction lands its balancing intent at segment 1.
  3. The serialized transaction is encoded with OfferFiles into a swapoffer1… string.
  4. api.submitSwapOfferRetrying(blob) POSTs it to /api/zswap/submit, retrying on ROOT_UNKNOWN while the status line reads "Waiting for chain to sync root…".
  5. The blob is recorded locally (addMyOffer) so the maker's own offer is filtered out of the book, and a trade row is appended with status not_public until the order book confirms it.

Taking an offer

takeOffer runs the same fresh-balance guard against the offer's pays legs (takerShortfalls), then calls tradeWallet.settleOfferproveAndSubmitOffer. That decodes the blob, picks the imbalanced segment, mirrors the taker side, merges, proves in the browser against the proof server, and hands the serialized transaction to the batcher:

body: JSON.stringify({
data: {
address,
addressType: MIDNIGHT_ADDRESS_TYPE,
input: JSON.stringify({ tx: serializedTxHex, txStage }),
timestamp: new Date().toISOString(),
target: BATCHER_TARGET,
},
confirmationLevel: 'wait-receipt',
timeoutMs: 600_000,
}),

The 600-second timeout is deliberate and commented in place: Dust balancing plus chain inclusion can take three to five minutes on preview, and the batcher's 300-second default is too tight. BATCHER_TARGET defaults to midnight-balancer — the backend batcher adapter that pays the fees the maker did not.

Multi-segment offers are rejected outright: pickSwapSegment throws when more than one segment carries a non-zero shielded or unshielded imbalance.

Identifying who made an offer

src/services/offerSender.ts recovers the maker's identity from the offer transaction alone, so the UI can label offers "yours" versus "theirs" — but only for unshielded legs. Its opening comment states the limit plainly: shielded-only offers stay anonymous by construction, because coin commitments hide the recipient's coin public key and only the holding wallet can decrypt the ciphertext. For those the function returns undefined and the caller falls back to a neutral label.

Minting test tokens

The Faucet screen (src/screens/Faucet.tsx) calls the mint_shielded and mint_unshielded circuits on the deployed OfferFiles contract through src/services/browserContract.ts, which assembles the standard midnight-js provider stack — indexerPublicDataProvider, httpClientProofProvider, FetchZkConfigProvider, levelPrivateStateProvider — and resolves the contract with findDeployedContract. Newly minted token names are held in src/services/mintQueue.ts and registered against their derived color once the mint lands, via POST /api/known-tokens.

Browser build workarounds

vite.config.ts is worth reading before starting your own Midnight frontend; every entry in it fixes a real breakage and is annotated in place:

  • crypto and node:crypto are aliased to src/shims/crypto.ts, because crypto-browserify is missing timingSafeEqual, which the midnight-js private-state provider's storage encryption needs. The alias is repeated as an esbuild plugin under optimizeDeps, because Vite's pre-bundling does not honour resolve.alias.
  • A zk-artifact-404 plugin forces a real 404 on /keys/* and /zkir/* when the file is not present in public/. Vite's SPA fallback would otherwise return index.html, which the proof server cannot parse and rejects with a 400. In normal operation the ZK artifacts are served by the backend (GET /keys/*, GET /zkir/*) rather than staged into public/ — this guard exists so a missing artifact fails loudly instead of silently.
  • Deno and Bun are defined as undefined for transitive dependencies that probe for Node globals, and @midnight-ntwrk/onchain-runtime is excluded from dependency optimization.

Configuration

All build-time variables are optional; each has a working default.

VariableDefaultPurpose
VITE_API_BASEhttp://<hostname>:9999Backend API base URL
VITE_BATCHER_URLhttp://<hostname>:3334Batcher base URL
VITE_BATCHER_TARGETmidnight-balancerBatcher adapter the settled transaction is routed to
VITE_MIDNIGHT_NETWORK_IDundeployedMidnight network id used for address formatting and offer parsing

At runtime a hosting page may set window.API_BASE and window.BATCHER_URL before the bundle loads; both take precedence over the build-time values. The network is not user-selectable — the NetworkMenu displays what the build was configured with.

Testing

The template ships one unit suite, src/services/takerBalance.test.ts, run with Bun's test runner:

bun test

It covers the pure shortfall calculator: sufficient and exact balances producing no shortfall, a missing token counting as zero, shielded and unshielded legs reading from their own balance maps, and multi-leg offers reporting only the uncovered leg. That logic is deliberately separated from blob decoding inside src/services/takerBalance.ts precisely so it can be tested without a wallet or a chain.

[!NOTE] This template is excluded from the repository's template test runner. The reason is recorded in templates/run-template-tests.ts, where its entry in the ENABLED list is commented out: the package.json depends on @zswap-da/contract-offer-files via a file: path outside the repository, and there is no test script, so the runner's bun run test step can never pass. It is to be re-enabled once both are sorted.

Where to go next

  • Midnight integration — the four zswap ledger primitives behind the offer-liveness checks, with their exact payload shapes
  • Celestia integrationPrimitiveTypeCelestiaGeneric, the CelestiaAdapter that writes blobs, and launchCelestia
  • Primitives — how a primitive turns chain activity into state-machine inputs
  • Batcher overview — adapters, targets and the confirmation levels this frontend asks for
  • evm-midnight template — a self-contained Midnight template with node, contracts and frontend in one workspace