DEV Community

Cover image for A small custody contract for a hybrid cryptoexchange
John
John

Posted on • Originally published at Medium

A small custody contract for a hybrid cryptoexchange

A small custody contract for a hybrid exchange

When I started AgavaDEX I made a decision to keep the on-chain part of the exchange as small as possible. The matcher lives off-chain, the order book lives off-chain, fills happen off-chain. Everything that needs to be verifiable lives on chain. The single contract on BNB Chain that holds user funds is published at 0x44f07dfb118284466cbe461785944538bc80f4bc and the source is verified on BscScan.

This post walks through what's in it, what it does, and what it deliberately does not do. The contract is small enough that I think the design is worth explaining out loud. It's also the first version. There's a v2 outlined at the end that pushes more of the trading logic into the chain itself.

What the contract is

The contract is called ExchangeVault. It's a custody contract. Users deposit BNB or supported ERC20 tokens into it, the off-chain matcher matches their orders and accounts internal balances, and when the user wants to withdraw, the contract releases funds back to them. The contract knows nothing about trading. It knows about deposits, withdrawals, and a small set of operational controls.

Compiled with Solidity 0.8.25, MIT license. Built on OpenZeppelin Ownable2Step, Pausable, ReentrancyGuard, SafeERC20, and EIP712.

State

The contract stores a few things. An operator signer address (the off-chain matcher's signing key). A fee recipient address (a destination if the matcher ever signs a non-zero fee on a withdrawal; in v1 it's always zero). Two per-token flags, depositEnabled and withdrawEnabled, so I can switch individual assets on and off without touching the whole contract. A usedWithdrawIds mapping for replay protection. The standard Ownable storage. The standard Pausable storage. That's basically it.

Deposits

There are two deposit paths. deposit(token, amount) for ERC20. depositNative() for BNB. Both check that the asset is enabled, both transfer (or accept) the value, both emit a Deposit event with the user, token, and amount.

event Deposit(address indexed user, address indexed token, uint256 amount);
Enter fullscreen mode Exit fullscreen mode

That's the entire deposit surface. There's no order routing, no internal accounting visible on-chain. The matcher reads these events and updates its internal balance ledger. If you deposit, the matcher sees the event, credits your internal balance. You trade against other people's internal balances. The on-chain contract never sees those trades.

This is intentional. The reason on-chain custody plus off-chain matching works is that traders only need on-chain proof of the two endpoints: when funds went in, when they came out. Everything between those two points is a question of trust in the matcher, but the funds themselves can't be moved without going through this contract.

Withdrawals

This is the more interesting part. A withdrawal requires two signatures.

function withdraw(
  WithdrawRequest calldata req,
  bytes calldata userSignature,
  bytes calldata operatorSignature
) external nonReentrant whenNotPaused
Enter fullscreen mode Exit fullscreen mode

The user signs an EIP712 WithdrawRequest off-chain saying "I want to withdraw amount X of token T to address R, with this unique ID, before deadline D". The matcher countersigns the same request with its operator key. The contract verifies both signatures and then releases the funds.

Why two signatures. The user signature proves the user actually authorized the withdrawal. Their funds, their key, their decision. The operator signature proves the matcher's books agree that the user has that much to withdraw. Without the operator signature, anyone could try to drain the contract by replaying old user-signed messages, or by claiming an internal balance they don't actually have.

Each WithdrawRequest has a unique withdrawId. The contract marks it used after release. Replay the same signed message and it fails with WithdrawAlreadyUsed. Each request has a deadline, after which the contract refuses with Expired.

The WithdrawRequest struct also includes a fee field that the matcher fills in when it countersigns. The contract just enforces whatever fee value the matcher signed; it does not impose any fee by itself. In v1 the matcher signs all WithdrawRequests with fee zero, so users don't pay anything at withdraw time. The feeRecipient address exists as infrastructure if fees are ever introduced. Currently nothing flows there. Every Withdraw event publishes the fee value on-chain alongside the amount and receiver, so this is verifiable per request.

The trust model is: the user trusts the matcher not to refuse to countersign a legitimate withdrawal, because if it does, the user can't get their money out. The matcher trusts the user not to double-spend, which the matcher itself prevents by tracking internal balances. Neither party can unilaterally move funds.

Operational controls

A few owner-only switches that make the system practical to operate.

setTokenConfig(token, canDeposit, canWithdraw) toggles deposit and withdraw per asset. When I add a new pair to the matcher, the underlying tokens get enabled here first. If I ever need to retire an asset, I disable deposits but keep withdrawals open so users can exit.

setOperatorSigner(newSigner) lets me rotate the matcher's signing key. If the key ever leaks, I can rotate to a new one. Anyone holding the old key can no longer authorize anything.

setFeeRecipient(newRecipient) lets me change the recipient address. Right now nothing goes there since v1 withdrawals are signed with fee zero, but the function exists for the case where fees are ever introduced.

pause() and unpause() let me halt the contract in an emergency. If something breaks in the matcher or I detect a problem, I can pause withdrawals and deposits while I investigate. Users can never lose access this way, only temporary delay. Pausable is one of those features that purists hate, but it's very useful when you operate a live system.

The owner is rotated through Ownable2Step. To transfer ownership, the current owner proposes the new address. The new owner has to accept it explicitly with acceptOwnership. This prevents accidentally transferring to a wrong address that no one controls.

What the contract deliberately doesn't do

It doesn't know about trading pairs.

It doesn't know about order books.

It doesn't have a price oracle.

It doesn't move funds between users on its own.

It doesn't have a margin or borrowing function.

It doesn't have governance.

That is the whole point. The on-chain surface is small enough to audit in an afternoon. The off-chain matcher is where the complexity lives. If you don't trust the matcher, you can still withdraw your funds at any time using only your own key plus a countersignature.

There is exactly one scenario where users could be stuck. If the matcher refuses to countersign withdrawals (operator outage, malicious operator, lost keys), users can't exit. This is the central trust point of the hybrid model. It's the same trust point a CEX has, except much harder to abuse because the matcher can't move funds unless the user also signs. It can only refuse to release them.

For v1, this trade-off is acceptable to me. Most CEX users tolerate it routinely. AgavaDEX makes it a much smaller trust surface than a CEX (no possibility of fractional reserves, no comingling, no hidden balance ledger), but it's still a trust point and I want to be honest about that rather than dress it up.

What v2 looks like

There's a path to make this stronger. The thing I've been thinking through is moving more of the matcher's state onto chain so trades themselves become verifiable.

Not full on-chain matching. That kills the performance gain that makes the hybrid model interesting in the first place. What's possible is publishing periodic state commitments. Every N minutes or every N trades, the matcher posts a Merkle root of all current user balances to the contract. The contract verifies the previous root is consistent with the new root given the published trades. Users get a way to prove what their balance should be, and a force-exit mechanism if the matcher goes silent for too long.

This is closer to how Arbitrum, Optimism, and other rollups guarantee user funds even when the sequencer misbehaves. The matcher becomes more like a sequencer than a custodian. The contract becomes a settlement layer that the matcher must respect.

I don't think this move should be rushed. Doing it badly is worse than not doing it. But the v1 contract is structured so the v2 path is incremental. Adding a settlement function next to the existing withdraw function doesn't break anything that already works. The withdrawal path stays as a fallback even when state commitments are in place.

That's the rough sketch. When v2 actually ships I'll write it up the same way.

Reading the contract yourself

The source is verified on BscScan. You can read the entire thing in about thirty minutes. There are no behind-the-scenes contracts, no proxies, no hidden upgrade paths. The contract address is fixed and not upgradeable. If I ever need to change the logic, the v2 will be a separate deployment and users will migrate explicitly.

Contract on BscScan: https://bscscan.com/address/0x44f07dfb118284466cbe461785944538bc80f4bc

App: https://app.agavadex.com

API docs: https://docs.agavadex.com/api-overview

Project: https://agavadex.com

If you find something I got wrong or want to dig into one of the design decisions, write to me. The TG community at https://t.me/agavadex is the right place for questions about how the contract fits together with the matcher. The whole point of writing this down is so the trust model is explicit, not vibes.

Top comments (0)