
Zentis
One market-making position across three chains that rebalances by pricing instead of bridging.
Demo
About
Zentis is one market-making position whose inventory lives on three chains at once. Every leg quotes off the same mid, published at the same instant by a Chainlink Confidential Workflow, and the book is managed as a single book — without bridging. Nothing crosses a chain boundary but a signed number. The problem A market maker running the same pair on three chains is running three businesses that cannot see each other. When one leg gets bought out, the textbook fix is a round trip: sell the asset here, bridge the stablecoin, buy it back there. That costs three ways — you wait for the transfer, you pay fees on both sides, and while the value is in flight it sits on neither leg. The bridge fee is not really the problem. The problem is that a market maker exists to earn the spread, and the bridge route makes it cross the spread as a taker, twice, to fix its own inventory. Zentis does not move the money. It moves the price, and no money needs to move at all. How it works One mid, one book. A confidential workflow reads one real WETH/USDC mid from Ethereum mainnet and writes it to all three registries under one sequence number, at one instant. Each leg then anchors its own constant-product curve back onto that shared mid. This matters more than it sounds: before it existed, three unarbitraged testnet pools quoted the same pair at 30,187, 4,156 and 2,583 USDC per WETH, and a book weight measured against one of those is not comparable with a weight measured against another. The cross-chain term — the entire claim of the project — was being computed from incommensurable inputs until this was fixed. A correction and a concession. Each leg's quote shifts off the mid by two terms with different justifications. The correction reprices the leg's own curve back onto the mid; it is a correction, not a cost the maker chooses to pay. The concession is what a leg pays to shed what it holds too much of — and it is capped by what bridging would actually have cost, priced from a real 1inch Fusion+ quote. Pricing therefore never costs more than the bridge it replaces. This is Avellaneda–Stoikov's reservation price expressed as a SwapVM instruction, with Guéant's multi-position term on top. The reference cannot drain the position. The tilt is applied to balanceIn only, never balanceOut. Since amountOut = amountIn·balanceOut/(balanceIn′+amountIn) < balanceOut for any balanceIn′ > 0, a quote can never exceed the maker's real Aqua balance regardless of what the reference says. There is no clamp, no post-check and no revert at settlement — the failure is unreachable rather than guarded. A published boundary can only ever narrow the cap the maker signed, never widen it. Funds never leave the maker's wallet. Aqua records a balance and pulls from the maker at settlement; its own token balance is zero. We verified this by reading the chain rather than assuming it. ship() and dock() move no tokens at all, and push() tops a live position up with no dock and no re-ship. The three sponsor integrations, and why each is load-bearing Each layer consumes the one above it. Removing any of them breaks the product, not just a prize claim. 1inch — three custom SwapVM instructions on official, unmodified Aqua contracts. ZentisSkew (0x9e) reads the reference and scales balanceIn. ZentisSpread (0x9f) applies the dynamic half-spread as a fee, mirroring FeeFlatIn including its partial-fill correction. ZentisBand (0xb3) bounds the realised, taker-facing price against the reference. They run on a seven-entry opcode table over a router that extends SwapVM and Simulator exactly as AquaSwapVMRouter does — the diff against stock is three instruction libraries, a shared tilt library and a nine-line dispatcher. 1inch/swap-vm and 1inch/aqua are pinned git submodules, byte-identical to upstream. Dropping the nine stock instructions a Zentis program can never reach leaves the router at 17,437 bytes, 7,139 under EIP-170 and smaller than the stock 16-entry router carrying none of our code. Chainlink — two Confidential Workflows computing the reference in a TEE. The fast workflow is strictly deterministic over block-pinned data and owns the mid, the tilt and the slope. The slow workflow is hourly, bucketed to the nearest basis point, and owns the spread, the adverse-selection markout and the bridge-priced budget — a live quote endpoint would return a different answer every call, which is why the Fusion+ read sits there and not on the deterministic path. The TEE constraint is pinned to AWS Nitro in us-west-2 rather than left permissive, and there is zero runtime.log() inside either enclave, enforced by test rather than promised. What stays secret is the maker's aggression — the gains that turn inventory into a concession — plus the API keys and the Fusion+ payload, whose size and direction are exactly what a front-runner would want. The market price, the Aqua balances and the published reference are deliberately not secret: they are public data, and claiming otherwise would be theatre. The Graph — an authored standard, not a dashboard. Every Aqua event (Shipped, Docked, Pulled, Pushed, and SwapVM.Swapped) carries zero indexed parameters, so nobody can filter Aqua activity by maker, app or token through eth_getLogs topics — every consumer must ingest and decode the whole venue. aqua-standard does it once, for every app, keyed by the triple (maker, app, strategyHash) because that is how Aqua actually keys balances. It is deployed on Ethereum mainnet and Arbitrum One, where it spans multiple apps; zentis-fills runs on all three testnets, where one identical GraphQL document returns one position with each chain's own leg — and nothing configures that join, because the subgraph decodes the shipped program, finds ZentisSkew, and reads the positionId out of its arguments. The decoder never guesses: an app's program is read against an opcode table only when that table is known from verified source, and every other app is marked UNKNOWN with its opcodes reported as custom and the raw blob retained. That rule earned its keep. The deployed Aqua router uses completely different opcode numbering from the repository we compile against — the constant-product swap is 0x50 in source and 17 on chain, and the fee denominator is 1e9 rather than 1e7 — so decoding live positions against the source table produces confident nonsense rather than an error. Finding that is, we think, the most interesting thing in the data layer: a decoder written against upstream source silently mis-reads production.
How it's made
The stack, and why each piece is there Contracts. Solidity 0.8.30 exactly, because swap-vm and aqua both pin an exact pragma and anything else fails to compile. Foundry, with via_ir, optimizer_runs = 700, fuzzing pinned at 2,000 runs and seed 0x1 so every number regenerates identically. Fixed point is plain int256 at 1e18 scale with no PRBMath — the arithmetic needs multiplication only, and one less dependency buys real headroom against EIP-170. Five unmodified pinned submodules: 1inch/swap-vm@f09a41e, 1inch/aqua@9c5c42e, forge-std, openzeppelin-contracts, 1inch/solidity-utils. Off-chain. TypeScript strict on bun 1.4.2 with viem 2.34.0, everywhere — the workflows, the quote service, the console readers, the console itself. @chainlink/cre-sdk 1.20.0 with the cre CLI 1.32.0. The subgraphs are AssemblyScript on graph-cli 0.98 with matchstick 0.6.0. The reference model is Python 3.12 with nothing outside the standard library, deliberately, so it can be run by anyone with a Python and no install step. The site is Next 16 / React 19 / Tailwind v4 with GSAP 3.15 (ScrollSmoother), three.js and xterm.js. The console is Ink 7 on React 19, compiled to a single binary with bun build --compile. Infrastructure. The publisher runs as one Cloud Run job on one Cloud Scheduler entry, with the CRE session on a GCS volume and secrets in Secret Manager; systemd user timers are the local equivalent. There is no root workspace manifest — every package installs its own dependencies — and two install orderings are load-bearing, which CI comments on so nobody has to rediscover them. How the layers are pieced together The seam between layers is deliberately narrow: the instruction receives one signed scalar and does not care where it came from. That is why three separate extensions to this design — portfolio covariance tilt, a chain-graph routing solver, and adding a third chain — each needed zero contract changes. The tilt could come from a pairwise difference, from a reservation price, or from the dual of a twelve-node flow problem; on chain it is the same int16 in the same struct field. Concretely, one ZentisRef struct (two storage slots) is the entire interface between the enclave and the curve. The fast workflow owns the mid, tilt, slope, reference balance and sequence; the slow workflow owns the spread, markout and boundary through a read-modify-write. When they race, the loser is rejected by the registry's sequence guard — which, because rejections emit and return rather than revert, makes two writers on one slot safe by accident of a fix made for a different reason. The same policy exists in three languages — Solidity (ZentisTiltLib), TypeScript (@zentis/strategy-sdk), and Python (reference_model) — held together by committed fixture vectors rather than by discipline. That caught a real bug: Python floored where TypeScript and Solidity truncate toward zero, so the implementations disagreed whenever the first leg was the lighter one and the difference was odd. The "three implementations agree bit for bit" claim had only ever been checked with a positive imbalance. What the partner technologies actually bought us 1inch / Aqua + SwapVM made the product possible rather than merely hosting it. _dispatch / _runOpcode as a virtual hook means extending the VM is one contract and nine lines with no fork. Aqua's non-custodial model — a recorded balance plus an approval, with settlement pulling from the maker's wallet — is what makes "funds never leave your wallet" literally true and re-shipping cheap. And Shipped emitting the full strategy blob for data availability is what lets a cross-chain position be verified from chain data alone: our positionId lives inside the program bytes, so anyone can prove two legs are one position with no messaging layer, no attestation and no registry. Chainlink CRE is the only reason the maker's aggression can be secret while the number it produces is public. But the specific thing that made it buildable in a hackathon window was learning — from Chainlink Labs in Discord, not from the docs — that cre workflow simulate runs a confidential workflow with no deploy access. And LAST_FINALIZED_BLOCK_NUMBER plus blockNumber(n) turned out to be exactly the right primitive for determinism: resolve the finalized block once, then name that number in every subsequent read. The Graph gave us a signal that did not previously exist. Because Aqua's events carry no indexed parameters, the only way to ask "how is this venue positioned" is to ingest and decode everything — which nobody had done. Building the standard produced a venue-level positioning dataset that the slow workflow now reads to allocate each leg's rebalancing budget away from chains the venue already leans into.