Overview
Umbra is a dark pool built on top of a single Uniswap v4 pool. It does not replace the pool, it stands in front of it. Traders hand Umbra sealed orders; Umbra opens them all at once, matches what it can internally, and sends only the unmatched remainder to the curve as one swap.
Four contracts, and each does one thing.
| Contract | Does |
|---|---|
UmbraDarkPool | Holds balances, runs the batch, prices the fills, settles against the pool. |
UmbraHook | Tolls ordinary swaps, and closes the pool to everyone but the batch while orders are opening. |
UmbraLpManager | Owns the single-side liquidity position. Add, remove, claim, owner only. |
UmbraToken | Plain ERC-20. Fixed supply, no owner, no mint, no tax. |
Lifecycle of a batch
One batch runs at a time. openBatch() is permissionless and starts the clock; the next batch cannot open until the current one has settled, or until it is past hope and the grace period has expired.
1. Commit
For ten minutes, anyone can seal one order into the batch with commit(bytes32 blind), paying a bond that is the same for every commit. Nothing else is written and no funds move, so on chain your commit is indistinguishable from everyone else’s. One commit per address per batch.
blind = keccak256(abi.encode(darkPool, batchId, trader, isBuy, amountIn, salt))
2. Open
For the next three minutes the pool is dark and each trader calls reveal(isBuy, amountIn, salt). The contract recomputes the hash; if it does not match, there is no order. On a match, amountIn moves from your free balance into the batch and your bond is returned to your balance.
A commit that never opens forfeits its bond to the treasury, which anyone can trigger with forfeit(batchId, who). That is what stops an attacker from stuffing a batch with commits it never intends to honour.
3. Settle
After the reveal window, settle() crosses the batch, sends the residue to the curve, takes the fee and records what each side is owed. It is permissionless: any participant can call it, and every participant wants to, because nothing can be claimed until it happens.
4. Claim
claim(batchId) credits your fill to your balance, pro rata to what you put in. It is a division, not a loop, so the cost of claiming does not depend on how many people were in the batch.
Balances
Umbra holds balances so that committing does not have to move money. You deposit ETH with deposit() and UMBRA with depositToken(amount), at any time, unrelated to any order, and you take back whatever is free with withdraw(ethAmount, tokAmount).
Deposit ahead of time, not next to your order. A deposit that lands in the same minute as a commit tells a watcher which side you are probably on. A balance that has been sitting there for a week tells them nothing.
How a batch is priced
At settlement the contract reads the pool’s current square-root price and values the sell side in ETH at that mid.
// UMBRA valued in ETH at the pool mid sellEth = sellTok · 2^192 / sqrtPriceX96² // ETH valued in UMBRA at the pool mid buyTok = buyEth · sqrtPriceX96² / 2^192
Whichever side is smaller is filled whole, at the mid, against the other side. The larger side keeps its surplus and that surplus is the only thing that goes to the curve.
Buy-heavy batch. Sellers are filled at the mid and receive sellEth. Buyers receive every UMBRA the sellers brought, plus whatever the residual swap bought, divided pro rata by the ETH each buyer put in. That blended number is the buy-side clearing price, and it is the same for all of them.
Sell-heavy batch. The mirror image. Buyers are filled at the mid, sellers share the ETH the buyers brought plus whatever the residual sale raised.
claim_i = sideTotalOut · amountIn_i / sideTotalIn
No participant can improve their price by acting earlier, later, or with a bigger transaction fee, because none of those appear anywhere in that formula.
The residue and its bound
The residual swap carries a hard price bound: MAX_IMPACT_BPS on the square-root price, passed straight to Uniswap as sqrtPriceLimitX96. The pool stops there on its own, which means the swap consumes only part of the input if the book is thin.
Whatever the bound refuses is not lost and is not forced through. It is recorded as a refund and comes back to the side that could not be filled, pro rata, in the same claim call as the fill.
The dark window
Commit-and-reveal on its own still leaks, because reveals are public. Anyone watching them could position themselves before settle() lands. So during the reveal window the hook refuses every swap that is not the batch itself: isDark() returns true and beforeSwap reverts with PoolIsDark().
The pool is closed for three minutes per batch. That is the cost, and it is a real one. In exchange, the interval in which your order is legible and unfilled is an interval in which nobody can act on it.
An unsettled batch cannot hold the pool shut. Once GRACE has passed beyond the reveal window, isDark() goes false on its own and the pool trades normally again, settled or not.
Fees
| Where | Rate | Paid by |
|---|---|---|
| Batch fill | 30 bps, fixed at deployment | Both sides, on their output, to the treasury. |
| Ordinary swap on the pool | 100 bps, capped at 300, can only ever be lowered | Anyone swapping the pool directly, in the input currency. |
| Batch residue | None from the hook | The batch is exempt from the toll, so a batch is tolled once and not twice. |
Trading through Umbra is cheaper than trading the pool directly, before counting anything saved on execution. That is deliberate: the dark pool has to be the rational route, not the virtuous one.
Parameters
| Name | Value | Meaning |
|---|---|---|
COMMIT_WINDOW | 10 minutes | How long orders may be sealed into a batch. |
REVEAL_WINDOW | 3 minutes | How long the batch has to open. The pool is dark for exactly this long. |
GRACE | 10 minutes | After this, an unsettled batch stops blocking the pool. |
BOND | 0.002 ETH | Identical for every commit. Returned on reveal, forfeited otherwise. |
FEE_BPS | 30 | Taken from each side’s output at settlement. |
MAX_IMPACT_BPS | 500 | Bound on the square-root price move of the residual swap. |
UmbraDarkPool
| Function | Who | What it does |
|---|---|---|
openBatch() | anyone | Starts a batch and returns its id. Reverts if one is still live. |
commit(bytes32 blind) | anyone | Seals one order. Payable, and the value must equal BOND exactly. |
reveal(bool isBuy, uint128 amountIn, bytes32 salt) | committer | Opens the order, moves the input into the batch, returns the bond. |
settle() | anyone | Crosses the batch, swaps the residue, takes the fee, records the fills. |
claim(uint256 batchId) | participant | Credits your fill and any refund to your balance. Once per order. |
forfeit(uint256 batchId, address who) | anyone | Sends a silent committer’s bond to the treasury. |
deposit() / depositToken(uint256) | anyone | Funds your balance. Unrelated to any order. |
withdraw(uint256 eth, uint256 tok) | anyone | Takes back free balance. Amounts inside a live batch are not free. |
isDark() | view | True while a batch is opening and unsettled, false past the grace period. |
UmbraHook
A v4 hook with BEFORE_SWAP and BEFORE_SWAP_RETURNS_DELTA, so its address must end in 0x0088 in the low fourteen bits and is mined with CREATE2 at deployment.
| Function | Who | What it does |
|---|---|---|
beforeSwap(...) | PoolManager | Exempts the batch, reverts everyone else while the pool is dark, otherwise takes the toll as an ERC-6909 claim. |
collect(address currency) | anyone | Draws accrued toll of that currency to the treasury. |
wireDark(address) | deployer | Points the hook at the dark pool. Works exactly once, then freezes. |
setFee(uint256 bps) | deployer | Lowers the toll. Raising it reverts, and 300 bps is a hard ceiling. |
setTreasury(address) | deployer | Moves where collected toll is sent. |
UmbraLpManager
Owns the liquidity position and nothing else. It supports a single-sided seed: the pool is initialised at the top of the range so the position is all UMBRA and contributes zero ETH. addLiquidity, removeLiquidity and claimFees are restricted to the owner. The hook lives in the PoolKey it is handed, so the same manager works on a hooked or a hookless pool.
Removing from a hooked v4 pool has to go through cast send with an explicit gas limit. The unlock callback defeats gas estimation and a plain forge broadcast dies out of gas without a useful error.
UmbraToken
One billion UMBRA, eighteen decimals, minted once to the deployer. No owner, no mint, no tax, no pause, no blacklist, nothing to upgrade. Every mechanic lives in the hook and in the dark pool, where it can be read.
Errors
| Error | Why |
|---|---|
PoolIsDark() | You tried to swap the pool while a batch was opening. |
BadWindow() | Right call, wrong act: committing after the window, revealing before it. |
BadPreimage() | The revealed order is not the one that was sealed. |
BadBond() | A commit whose value is not exactly BOND. Every commit must look alike. |
AlreadyCommitted() | One order per address per batch. |
InsufficientBalance() | Revealing more than you deposited, or withdrawing what a batch is holding. |
BatchLive() | A batch is still running, so a new one cannot open. |
NotSettled() / NothingToClaim() | Claiming too early, twice, or on an order you never opened. |
Integration
Build the blind exactly as the contract does, keep the salt, and never reuse it. Losing the salt means losing the order: the amount stays in your balance, but the bond is gone.
// viem import { encodeAbiParameters, keccak256, parseEther } from 'viem' const salt = crypto.getRandomValues(new Uint8Array(32)) const blind = keccak256(encodeAbiParameters( [{type:'address'},{type:'uint256'},{type:'address'},{type:'bool'},{type:'uint128'},{type:'bytes32'}], [DARK_POOL, batchId, trader, true, parseEther('1'), toHex(salt)] )) await dark.write.commit([blind], { value: BOND }) // ... wait for the commit window to shut ... await dark.write.reveal([true, parseEther('1'), toHex(salt)]) // ... after the reveal window ... await dark.write.settle() await dark.write.claim([batchId])
Limits and threat model
- Balances are public. Umbra hides orders, not wallets. Someone who watches a deposit land and a commit follow it a minute later can guess your side. Fund early, and not in a round number that matches your order.
- A batch of one is not dark. If you are the only order in a batch, the residual swap is your order. Privacy here is a crowd property, and a thin batch has little of it.
- The mid can be moved before the batch. Crossing happens at the pool mid at settlement time. Someone can push that mid in the block before, at the cost of the toll and the risk of the batch going the other way. The impact bound limits the damage; it does not remove the game.
- No limit price per order. Uniform-price batches cannot exclude one order without sorting the book. The batch-level impact bound is the substitute, and it either fills you within the bound or hands your input back.
- Bond griefing. An attacker can flood commits and never open them. It costs them a bond each, all of which goes to the treasury, and it cannot forge a fill.
- Rounding dust. A perfectly matched batch still moves the pool by a few parts in ten billion, because the two conversions round in opposite directions. Measured, not assumed: 4.0e-10 in the fork test.
- Not audited. Sixteen tests against the real mainnet PoolManager is not an audit, and nothing here has been deployed.
Test results
Every test runs against a fork of Ethereum mainnet and the live v4 PoolManager at 0x000000000004444c5dc75cB358380D2e3dE08A90, with a real hooked ETH/UMBRA pool seeded single-side.
Ran 5 tests for test/UmbraLaunchFork.t.sol [PASS] opening FDV is three ETH 3016 mETH [PASS] seeded single side with no ETH [PASS] buy pays the toll and pull returns the ETH [PASS] fee can only ever go down [PASS] dark can only be wired once Ran 12 tests for test/UmbraDarkFork.t.sol [PASS] balanced batch never touches the curve drift 4.0e-10 [PASS] one price per side identical to the last digit [PASS] buy heavy crosses first then sends the residue out [PASS] sell heavy is symmetric [PASS] pool goes dark while orders open [PASS] an abandoned batch cannot hold the pool shut [PASS] a hash that does not match is not an order [PASS] a commit that never opens forfeits its bond [PASS] commits are all the same size on chain [PASS] only one batch at a time [PASS] withdraw returns free balance only [PASS] contract stays solvent after a settlement 17 passed, 0 failed
Deployments
| Network | Contract | Address |
|---|---|---|
| Ethereum | UmbraToken | 0xd53b7db38794556bacd33e58be217f91c55fbbbf |
| Ethereum | UmbraHook | 0xc2a8cf2a65c0aab8b1b81f2cce660306200ac088 |
| Ethereum | UmbraDarkPool | 0x8cc0f7b08f6bd7391fa0e9a7f80faf2e09b16eac |
| Ethereum | UmbraLpManager | 0x73dacf720df809ffbe288e587320eb45c4351706 |
All four are verified on Etherscan. The hook was wired to the dark pool in the deployment transaction and that wiring is frozen, so the address in the table is the only dark pool this hook will ever answer to.
The ETH/UMBRA pool is open and seeded. Its id is 0x67b38337aec223b2e51a6c13de5050b1973a11af9d3480caeccd5bddb486b1ea, fee tier 10000, tick spacing 200, with the hook in the key. That id is the one the dark pool was constructed for, checked on chain before the liquidity went in: a batch settles into this pool and can settle into no other.
There was no presale and there is no allocation. The liquidity was seeded single-side, which means the whole supply went into the range and no ETH did.
The liquidity is burned. The position was moved to the canonical v4 PositionManager, which issues an ERC-721 for it, and that token was sent to 0x…dEaD. It is position #379295, and no address can withdraw it any more, this project’s author included. Burning the position removes the right to withdraw liquidity; it does not touch swaps, so buying and selling work exactly as they did before.