<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: John</title>
    <description>The latest articles on DEV Community by John (@agava).</description>
    <link>https://dev.to/agava</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3962975%2F9d8904e4-9d07-4cda-b917-82c4ad1950e6.png</url>
      <title>DEV Community: John</title>
      <link>https://dev.to/agava</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/agava"/>
    <language>en</language>
    <item>
      <title>A small custody contract for a hybrid cryptoexchange</title>
      <dc:creator>John</dc:creator>
      <pubDate>Mon, 01 Jun 2026 15:58:24 +0000</pubDate>
      <link>https://dev.to/agava/a-small-custody-contract-for-a-hybrid-cryptoexchange-5h88</link>
      <guid>https://dev.to/agava/a-small-custody-contract-for-a-hybrid-cryptoexchange-5h88</guid>
      <description>&lt;h2&gt;
  
  
  A small custody contract for a hybrid exchange
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;0x44f07dfb118284466cbe461785944538bc80f4bc&lt;/code&gt; and the source is verified on BscScan.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the contract is
&lt;/h3&gt;

&lt;p&gt;The contract is called &lt;code&gt;ExchangeVault&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Compiled with Solidity 0.8.25, MIT license. Built on OpenZeppelin &lt;code&gt;Ownable2Step&lt;/code&gt;, &lt;code&gt;Pausable&lt;/code&gt;, &lt;code&gt;ReentrancyGuard&lt;/code&gt;, &lt;code&gt;SafeERC20&lt;/code&gt;, and &lt;code&gt;EIP712&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  State
&lt;/h3&gt;

&lt;p&gt;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, &lt;code&gt;depositEnabled&lt;/code&gt; and &lt;code&gt;withdrawEnabled&lt;/code&gt;, so I can switch individual assets on and off without touching the whole contract. A &lt;code&gt;usedWithdrawIds&lt;/code&gt; mapping for replay protection. The standard Ownable storage. The standard Pausable storage. That's basically it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deposits
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;event Deposit(address indexed user, address indexed token, uint256 amount);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Withdrawals
&lt;/h3&gt;

&lt;p&gt;This is the more interesting part. A withdrawal requires two signatures.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function withdraw(
  WithdrawRequest calldata req,
  bytes calldata userSignature,
  bytes calldata operatorSignature
) external nonReentrant whenNotPaused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user signs an EIP712 &lt;code&gt;WithdrawRequest&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;The &lt;code&gt;WithdrawRequest&lt;/code&gt; struct also includes a &lt;code&gt;fee&lt;/code&gt; 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 &lt;code&gt;feeRecipient&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Operational controls
&lt;/h3&gt;

&lt;p&gt;A few owner-only switches that make the system practical to operate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setTokenConfig(token, canDeposit, canWithdraw)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setOperatorSigner(newSigner)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setFeeRecipient(newRecipient)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pause()&lt;/code&gt; and &lt;code&gt;unpause()&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What the contract deliberately doesn't do
&lt;/h2&gt;

&lt;p&gt;It doesn't know about trading pairs.&lt;/p&gt;

&lt;p&gt;It doesn't know about order books.&lt;/p&gt;

&lt;p&gt;It doesn't have a price oracle.&lt;/p&gt;

&lt;p&gt;It doesn't move funds between users on its own.&lt;/p&gt;

&lt;p&gt;It doesn't have a margin or borrowing function.&lt;/p&gt;

&lt;p&gt;It doesn't have governance.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What v2 looks like
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That's the rough sketch. When v2 actually ships I'll write it up the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the contract yourself
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Contract on BscScan: &lt;a href="https://bscscan.com/address/0x44f07dfb118284466cbe461785944538bc80f4bc" rel="noopener noreferrer"&gt;https://bscscan.com/address/0x44f07dfb118284466cbe461785944538bc80f4bc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;App: &lt;a href="https://app.agavadex.com" rel="noopener noreferrer"&gt;https://app.agavadex.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;API docs: &lt;a href="https://docs.agavadex.com/api-overview" rel="noopener noreferrer"&gt;https://docs.agavadex.com/api-overview&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Project: &lt;a href="https://agavadex.com" rel="noopener noreferrer"&gt;https://agavadex.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you find something I got wrong or want to dig into one of the design decisions, write to me. The TG community at &lt;a href="https://t.me/agavadex" rel="noopener noreferrer"&gt;https://t.me/agavadex&lt;/a&gt; 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.&lt;/p&gt;

</description>
      <category>smartcontracts</category>
      <category>blockchain</category>
      <category>web3</category>
      <category>dex</category>
    </item>
  </channel>
</rss>
