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:
| Question | Answered 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
| Feature | Where | Used for |
|---|---|---|
MIP-0005 offer codec (OfferFiles, @effectstream/mip-zswap-offer/mip5) | src/services/makerOffer.ts | Encoding the maker's transaction as a swapoffer1… blob |
| MIP-0005 decode | src/services/offerParse.ts, src/services/offerSender.ts | Reconstructing a Transaction from an offer blob |
MIP-0006 leg derivation (P2pAtomicSwaps.deriveTokenLegs) | src/decodeOffer.ts | Turning a raw offer transaction into tagged gives/wants legs |
@effectstream/wallets (walletLogin, allInjectedWallets) | src/state/wallet.ts | Discovering and connecting injected Midnight wallets |
@effectstream/wallets/midnight-local (MidnightLocalConnector) | src/state/wallet.ts | The built-in JS wallet, in facade mode, on undeployed networks |
Batcher HTTP API (POST /send-input) | src/services/api.ts | Submitting the settled Midnight transaction at wait-receipt |
| Node event stream (SSE) | src/hooks/useEventStream.ts | Live offer_indexed / offer_consumed / offer_expired / token_minted |
| Node custom API routes | src/services/api.ts | Order 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.ts | Offer 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.tsships 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.jsonresolves@zswap-da/contract-offer-filesthrough a relativefile:path, so the checkout must be namedzswap-offerfile-kernel(singular — the GitHub repository iszswap-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
| Service | URL |
|---|---|
| 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 server | Fetched 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:
- Re-read fresh balances and run
shortfallsFromLegsover thegiveslegs, throwing a readable message rather than letting the wallet hang. buildMakerOfferBlob(src/services/makerOffer.ts) collects the wallet's shielded and unshielded addresses, mapsgivesto inputs andwantsto outputs routed back to the maker, and callsmakeIntent. The intent id is drawn at random from≥ 2, because segment 0 is the guaranteed offer and Lace'sbalanceSealedTransactionlands its balancing intent at segment 1.- The serialized transaction is encoded with
OfferFilesinto aswapoffer1…string. api.submitSwapOfferRetrying(blob)POSTs it to/api/zswap/submit, retrying onROOT_UNKNOWNwhile the status line reads "Waiting for chain to sync root…".- 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 statusnot_publicuntil 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.settleOffer → proveAndSubmitOffer. 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:
cryptoandnode:cryptoare aliased tosrc/shims/crypto.ts, becausecrypto-browserifyis missingtimingSafeEqual, which the midnight-js private-state provider's storage encryption needs. The alias is repeated as an esbuild plugin underoptimizeDeps, because Vite's pre-bundling does not honourresolve.alias.- A
zk-artifact-404plugin forces a real 404 on/keys/*and/zkir/*when the file is not present inpublic/. Vite's SPA fallback would otherwise returnindex.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 intopublic/— this guard exists so a missing artifact fails loudly instead of silently. DenoandBunare defined asundefinedfor transitive dependencies that probe for Node globals, and@midnight-ntwrk/onchain-runtimeis excluded from dependency optimization.
Configuration
All build-time variables are optional; each has a working default.
| Variable | Default | Purpose |
|---|---|---|
VITE_API_BASE | http://<hostname>:9999 | Backend API base URL |
VITE_BATCHER_URL | http://<hostname>:3334 | Batcher base URL |
VITE_BATCHER_TARGET | midnight-balancer | Batcher adapter the settled transaction is routed to |
VITE_MIDNIGHT_NETWORK_ID | undeployed | Midnight 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 theENABLEDlist is commented out: thepackage.jsondepends on@zswap-da/contract-offer-filesvia afile:path outside the repository, and there is notestscript, so the runner'sbun run teststep 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 integration —
PrimitiveTypeCelestiaGeneric, theCelestiaAdapterthat writes blobs, andlaunchCelestia - Primitives — how a primitive turns chain activity into state-machine inputs
- Batcher overview — adapters, targets and the confirmation levels this frontend asks for
evm-midnighttemplate — a self-contained Midnight template with node, contracts and frontend in one workspace