Building a Confidential OTC Settlement Protocol with Oasis Sapphire & Base

Building a Confidential OTC Settlement Protocol with Oasis Sapphire & Base

Learn how to build a privacy-preserving OTC trading protocol where counterparties negotiate confidentially using Oasis Sapphire while settling trades transparently on Base. Large cryptocurrency trades rarely happen on decentralized exchanges. When someone wants to swap a few hundred dollars worth of ETH for USDC, an automated market maker like Uniswap works exceptionally well. Liquidity is abundant, settlement is immediate, and the entire process can be completed in a single transaction. The picture changes completely once the trade size reaches institutional territory. Imagine a DAO treasury planning to exchange 500,000 USDC for ETH. Executing that transaction through a public liquidity pool immediately reveals valuable information to the entire market. Arbitrage bots begin simulating profitable routes before the transaction is finalized. Market makers widen spreads. Searchers compete to extract value from the pending order, while other traders infer the treasury's strategy simply by observing the transaction. The protocol itself is functioning exactly as designed. The problem is that transparency and negotiation don't always belong together. This is precisely why Over-the-Counter (OTC) trading exists. Rather than exposing an order to an entire market, two counterparties negotiate privately before agreeing on a final settlement price. Only after both parties reach an agreement is the trade executed. Traditional financial institutions have relied on this model for decades because confidentiality reduces information leakage and allows large transactions to occur without unnecessarily moving markets. Public blockchains introduce an interesting challenge. How do you preserve confidential negotiations while still benefiting from decentralized settlement? This is where Oasis Sapphire becomes particularly interesting. Instead of treating confidentiality as an application-layer concern, Sapphire provides confidential smart contract execution directly within an EVM-compatible environment. Sensitive contract state remains protected while developers continue writing familiar Solidity code. In this tutorial we'll build a simplified OTC trading protocol that combines confidential negotiation on Oasis Sapphire with transparent settlement on Base. Rather than building another decentralized exchange, we'll focus on the negotiation process itself. By the end of this guide you'll have built a protocol capable of: Creating confidential OTC trade offers Receiving private counterparty quotes Accepting offers without revealing negotiations publicly Escrowing assets before settlement Executing the final settlement on Base Cancelling expired orders safely Preventing replay attacks through signed messages Although the protocol we'll build is intentionally simplified for educational purposes, its architecture mirrors patterns commonly found in production financial systems. Understanding the Problem Before writing a single line of Solidity, it's worth asking an important question. Why don't institutions simply use AMMs for everything? After all, decentralized exchanges already provide liquidity, transparent pricing, and permissionless access. The answer comes down to market impact. Suppose Alice wants to purchase 400 ETH. If that trade is submitted directly to a public liquidity pool, several things happen almost immediately. First, the pending transaction becomes visible to sophisticated searchers monitoring the network. Next, those searchers estimate the price impact by simulating the trade against current liquidity. If profitable opportunities exist, competing transactions are constructed before Alice's order has even been finalized. Even if nobody explicitly frontruns the transaction, the market itself now possesses information that previously existed only inside Alice's trading strategy. That information has value. The larger the order becomes, the more valuable it becomes. The sequence below illustrates what happens inside a typical public execution environment. sequenceDiagram participant Alice participant Ethereum participant Searcher participant LiquidityPool Alice->>Ethereum: Submit Swap Ethereum-->>Searcher: Pending Transaction Searcher->>Searcher: Simulate Trade Searcher->>LiquidityPool: Competing Transactions LiquidityPool-->>Alice: Final Execution Enter fullscreen mode Exit fullscreen mode Nothing malicious has occurred. Every participant simply acted upon publicly available information. For large trades, however, that transparency introduces unnecessary information leakage. OTC trading avoids this problem entirely by moving negotiation away from the public market. Only the final agreement becomes visible. Why Oasis Sapphire? One obvious question is why we need another blockchain at all. Couldn't we simply negotiate off-chain using encrypted messages? In some situations, yes. However, purely off-chain negotiation introduces several new trust assumptions. Who stores the offers? Who guarantees neither party modified the terms? How do we prove a particular offer existed if negotiations break down? How do multiple market makers compete fairly? A confidential smart contract solves these coordination problems. Instead of trusting a centralized OTC desk, participants submit encrypted offers into a shared protocol. The blockchain enforces protocol rules while keeping sensitive trading information confidential. From the outside, observers know an offer exists. They do not learn: which asset is being traded, the quoted price, the requested amount, acceptable settlement conditions, or which counterparty accepted the offer. Only the final settlement transaction becomes public. That distinction dramatically reduces information leakage while preserving decentralized execution. System Overview Our protocol consists of five independent components. Each has a single responsibility. Separating these concerns keeps the contracts significantly easier to audit and makes future upgrades less risky. flowchart LR User["Trader"] Frontend["Next.js Frontend"] SDK["TypeScript SDK"] Registry["OTC Registry"] Escrow["Escrow Contract"] Coordinator["Settlement Coordinator"] Base["Base Network"] User --> Frontend Frontend --> SDK SDK --> Registry Registry --> Coordinator Coordinator --> Escrow Escrow --> Base Enter fullscreen mode Exit fullscreen mode Let's briefly examine the role of each component. OTC Registry The registry acts as the confidential marketplace. Every new offer enters the protocol through this contract. Rather than immediately transferring assets, users first describe the trade they would like to perform. Those details remain confidential until the offer is accepted. Escrow Contract Once two counterparties agree on a trade, both assets must be secured before settlement begins. Instead of relying on trust between participants, the escrow contract temporarily locks both sides of the trade. Neither participant can withdraw funds unilaterally after acceptance. Only successful settlement or cancellation releases escrowed assets. Settlement Coordinator The final contract coordinates settlement on Base. Once escrow conditions are satisfied, settlement becomes deterministic. This separation keeps negotiation logic isolated from execution logic, significantly simplifying auditing. Project Architecture We'll build the project as a monorepo. Although our example remains relatively small, organizing the repository this way closely resembles production Solidity projects. confidential-otc-protocol/ ├── contracts/ │ │ OTCRegistry.sol │ │ Escrow.sol │ │ SettlementCoordinator.sol │ │ libraries/ │ │ SignatureVerifier.sol │ │ OfferHasher.sol │ │ ├── sdk/ │ │ otcClient.ts │ │ signer.ts │ │ quotes.ts │ ├── backend/ │ │ api.ts │ │ relayer.ts │ │ ├── frontend/ │ │ app/ │ ├── script/ │ ├── test/ │ └── docs/ Enter fullscreen mode Exit fullscreen mode Throughout this tutorial we'll implement each directory individually before integrating everything into a complete protocol. By the end, you'll have a repository structured much more like a production codebase than a simple Solidity example. Protocol Lifecycle Before diving into the implementation, it's useful to visualize the complete lifecycle of an OTC trade. stateDiagram-v2 [*] --> Draft Draft --> Submitted Submitted --> Quoted Quoted --> Accepted Accepted --> Escrowed Escrowed --> Settled Submitted --> Cancelled Quoted --> Cancelled Accepted --> Expired Enter fullscreen mode Exit fullscreen mode Notice that settlement is only one stage of the protocol. Most of the protocol's complexity actually happens before assets move. Negotiation, verification, escrow, and acceptance each introduce their own state transitions. Designing these transitions carefully is one of the easiest ways to eliminate entire classes of smart contract bugs before they ever appear. In the next section we'll begin implementing the protocol's core contract: OTCRegistry.sol. Rather than jumping directly into business logic, we'll first design the underlying data model and explain why each field exists before writing any functions. Setting Up the Project Before writing any Solidity, let's create the project structure. We'll use Foundry because it has become the de facto standard for professional Solidity development. It provides fast compilation, excellent testing utilities, built-in fuzzing, and straightforward deployment scripts. If you haven't already installed Foundry, you can do so with: curl -L https://foundry.paradigm.xyz | bash foundryup Enter fullscreen mode Exit fullscreen mode Now create a new project. forge init confidential-otc-protocol cd confidential-otc-protocol Enter fullscreen mode Exit fullscreen mode We'll also install OpenZeppelin contracts. forge install OpenZeppelin/openzeppelin-contracts Enter fullscreen mode Exit fullscreen mode Our project now looks like this. confidential-otc-protocol/ ├── lib/ │ ├── script/ │ ├── src/ │ ├── test/ │ ├── foundry.toml │ └── README.md Enter fullscreen mode Exit fullscreen mode Throughout the tutorial we'll gradually replace the default template with our protocol implementation. Why Foundry? Hardhat remains an excellent framework, but Foundry offers several advantages for protocol development. First, tests execute considerably faster because they're written directly in Solidity. Second, fuzz testing is built into the framework instead of requiring external plugins. Finally, cheatcodes make it straightforward to simulate different users, timestamps, balances, and blockchain conditions. Since we're building financial infrastructure, confidence in our testing environment matters just as much as confidence in the contracts themselves. Designing the Registry Most blockchain tutorials begin with functions. Production protocols begin with data. The quality of a smart contract is largely determined by how well its state is modeled. Changing storage layouts after deployment is difficult, especially once external integrations exist. Before writing a single function, we should understand exactly what information our registry needs to remember. An OTC offer answers a simple question. "What is one party willing to trade?" That sounds simple, but the protocol needs considerably more information than just two token addresses. Our registry must remember: who created the offer, which asset they're selling, which asset they expect, the amount offered, the minimum amount they'll accept, when the offer expires, whether it's still active, and whether somebody has already accepted it. Representing these pieces of information explicitly makes later contract logic much easier to understand. Creating the Offer Model Inside src/, create a file named OTCRegistry.sol. We'll start with the data model before implementing any business logic. // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract OTCRegistry { enum OfferStatus { Open, Accepted, Cancelled, Settled, Expired } struct Offer { address maker; address tokenOffered; address tokenRequested; uint256 amountOffered; uint256 minimumAmountRequested; uint256 expiry; uint256 nonce; OfferStatus status; } } Enter fullscreen mode Exit fullscreen mode Although this contract contains only a few lines of Solidity, there's already quite a bit happening. Instead of storing offers as loosely connected mappings, we've grouped related information into a single structure. This makes the protocol easier to reason about because every offer becomes a self-contained object. Let's examine each field. Maker address maker; Enter fullscreen mode Exit fullscreen mode The maker is the wallet that created the offer. This address becomes important throughout the protocol. Only the maker can: cancel an open offer, update certain parameters (before acceptance), or withdraw escrow if settlement fails. Rather than checking ownership through complex access-control mechanisms, we'll simply compare msg.sender against this stored address. Offered Token address tokenOffered; Enter fullscreen mode Exit fullscreen mode This represents the asset currently owned by the maker. For example, USDC Enter fullscreen mode Exit fullscreen mode or WETH Enter fullscreen mode Exit fullscreen mode The registry itself won't hold these tokens. Instead, it records what the maker intends to escrow later in the protocol. Keeping negotiation separate from asset custody makes the protocol significantly easier to audit. Requested Token address tokenRequested; Enter fullscreen mode Exit fullscreen mode This is the asset the maker expects in return. Together, tokenOffered ↓ tokenRequested Enter fullscreen mode Exit fullscreen mode define the trading pair. Unlike AMMs, we're not computing prices algorithmically. The maker decides exactly what exchange they're willing to accept. Offered Amount uint256 amountOffered; Enter fullscreen mode Exit fullscreen mode This field specifies how many units of the offered token the maker wishes to trade. For example, 500,000 USDC Enter fullscreen mode Exit fullscreen mode The protocol intentionally avoids floating-point arithmetic. Every amount is stored using the token's smallest denomination. For ERC-20 tokens, that typically means accounting for decimals within the frontend or SDK. Minimum Requested Amount uint256 minimumAmountRequested; Enter fullscreen mode Exit fullscreen mode This field acts as protection against unfavorable execution. Suppose Alice wants to exchange 500,000 USDC Enter fullscreen mode Exit fullscreen mode for at least 165 ETH Enter fullscreen mode Exit fullscreen mode If a counterparty proposes 160 ETH Enter fullscreen mode Exit fullscreen mode the protocol should reject the trade automatically. Encoding these constraints directly into the offer ensures every accepted trade satisfies the maker's original conditions. Expiry uint256 expiry; Enter fullscreen mode Exit fullscreen mode No financial offer should remain valid forever. Market conditions change. Token prices fluctuate. Liquidity disappears. Rather than relying on users to manually cancel stale offers, we'll associate every offer with an expiration timestamp. Any interaction after that timestamp becomes invalid. This single field eliminates an entire class of replay attacks. Nonce uint256 nonce; Enter fullscreen mode Exit fullscreen mode One subtle problem appears once signatures enter the picture. Suppose Alice signs an offer today. What's stopping somebody from submitting the exact same signed payload next month? The answer is nonces. Each new offer consumes a unique value. Once that value has been used, the protocol refuses to accept another offer carrying the same nonce. We'll implement this protection shortly. Organizing Storage With the data model complete, we can decide how offers will actually be stored. mapping(uint256 => Offer) private offers; uint256 public offerCounter; mapping(address => uint256) public nonces; Enter fullscreen mode Exit fullscreen mode Each mapping serves a different purpose. The first stores every offer ever created. The second generates unique identifiers. Finally, the third keeps track of each user's latest nonce. Separating these concerns keeps lookups straightforward while avoiding nested mappings that quickly become difficult to maintain. Why Sequential IDs? Earlier we discussed deterministic hashes. Why switch to numeric identifiers? Because this protocol has different requirements. Unlike signed intents that may exist off-chain before submission, OTC offers originate inside the protocol itself. Sequential identifiers offer several practical advantages. They make indexing simpler. They're cheaper to generate than hashes. And they're considerably easier to reference from frontends and block explorers. Offer #421 is much easier for developers to work with than 0xb7e13b8ecb5e... Enter fullscreen mode Exit fullscreen mode Internally, we'll still use cryptographic hashes later for signatures. But externally, sequential identifiers make the protocol much friendlier to use. Creating Offers With storage in place, we can finally implement our first state-changing function. function createOffer( address tokenOffered, address tokenRequested, uint256 amountOffered, uint256 minimumAmountRequested, uint256 expiry ) external returns (uint256 offerId) { require(expiry > block.timestamp, "Offer already expired"); offerId = ++offerCounter; offers[offerId] = Offer({ maker: msg.sender, tokenOffered: tokenOffered, tokenRequested: tokenRequested, amountOffered: amountOffered, minimumAmountRequested: minimumAmountRequested, expiry: expiry, nonce: nonces[msg.sender]++, status: OfferStatus.Open }); } Enter fullscreen mode Exit fullscreen mode Although concise, this function already demonstrates several useful design principles. First, validation occurs before any state modification. Second, every newly created offer immediately receives a unique identifier. Finally, we increment the maker's nonce at creation time, ensuring every offer remains uniquely identifiable even if all other parameters are identical. In the next section we'll improve this function by replacing require strings with custom errors, adding events for off-chain indexing, and integrating EIP-712 signatures so offers can be created through signed messages rather than direct transactions. Adding Production-Grade Solidity Patterns The first version of OTCRegistry.sol works, but it still looks closer to a prototype than production infrastructure. Before moving deeper into confidential execution and cross-chain settlement, we need to improve the contract foundations. Financial protocols are rarely compromised because the developer forgot how to write a function. They fail because small design decisions accumulate. Gas inefficiencies become expensive at scale. Missing events break indexing systems. Weak signature handling creates replay vulnerabilities. Poorly defined state transitions make auditing significantly harder. The next iteration of the registry focuses on those details. Replacing require Strings with Custom Errors The first improvement is replacing traditional revert messages. A common Solidity pattern looks like this: require( expiry > block.timestamp, "Offer already expired" ); Enter fullscreen mode Exit fullscreen mode This works, but it is unnecessarily expensive. Every revert string must be stored inside the contract bytecode. For frequently triggered errors, this increases deployment size and runtime gas costs. Modern Solidity provides custom errors. Instead of storing text, we define an error selector. error OfferExpired(); error InvalidToken(); error InvalidAmount(); error Unauthorized(); error OfferNotOpen(); Enter fullscreen mode Exit fullscreen mode Now validation becomes: if (expiry Offer) private offers; uint256 public offerCounter; mapping(address => uint256) public nonces; } Enter fullscreen mode Exit fullscreen mode The storage layout remains unchanged. This is intentional. Changing storage unnecessarily creates migration complexity. A well-designed contract evolves by adding capabilities around stable foundations. Why Events Matter Smart contracts do not push information to applications. They simply modify state. If a frontend wants to display active OTC offers, it needs an efficient way to discover changes. Reading every offer ID and querying storage directly does not scale. Events solve this problem. When emitted, events become part of transaction logs that external systems can index. Popular indexing systems such as The Graph and custom backend services rely heavily on this pattern. For our registry, we need events for: offer creation, offer acceptance, cancellation, settlement. Let's add the first one. event OfferCreated( uint256 indexed offerId, address indexed maker, address tokenOffered, address tokenRequested, uint256 amountOffered, uint256 minimumAmountRequested, uint256 expiry ); Enter fullscreen mode Exit fullscreen mode The indexed keyword is important. Indexed fields become searchable topics. For example, a frontend can efficiently query: "Show me every offer created by this address." without scanning the entire blockchain. Emitting Offer Creation Events Now we update our creation function. function createOffer( address tokenOffered, address tokenRequested, uint256 amountOffered, uint256 minimumAmountRequested, uint256 expiry ) external returns (uint256 offerId) { if (expiry Sapphire TraderB --> Sapphire Sapphire --> SignedSettlement SignedSettlement --> Base Base --> FinalSettlement Enter fullscreen mode Exit fullscreen mode Sapphire handles the private coordination. Base provides transparent final settlement. Neither chain is replacing the other. Each handles the part it is best suited for. Next Section The registry now has: structured storage, custom errors, event indexing, typed signatures, replay protection foundations. The next step is implementing the acceptance flow. We will build: counterparty quote submission, signature verification, offer acceptance, escrow state transitions, settlement preparation, and the bridge between confidential negotiation and public Base settlement. Building the Acceptance Flow The registry can now create offers and verify signed trade intents. However, an OTC protocol is not complete when an offer exists. An offer represents only one side of the agreement. The critical moment happens when another participant decides: "I accept these terms, and I am willing to proceed toward settlement." This transition is where many financial protocols become complicated. A naive implementation might look like this: function acceptOffer(uint256 offerId) external { offers[offerId].status = OfferStatus.Accepted; } Enter fullscreen mode Exit fullscreen mode This appears simple, but it introduces several problems. Who is accepting? Can anyone accept? Was the offer already cancelled? Has the offer expired? Did the counterparty actually provide the requested asset? Can the same offer be accepted twice? In financial infrastructure, changing a single enum value is not the operation. The operation is a state transition with strict conditions. Our goal is to make every transition explicit. Designing the Acceptance Lifecycle Before writing code, let's define what acceptance actually means. The lifecycle currently looks like: Open | | v Accepted | | v Escrowed | | v Settled Enter fullscreen mode Exit fullscreen mode There are also failure paths: Open | +----> Cancelled | +----> Expired Enter fullscreen mode Exit fullscreen mode The important observation is that acceptance is not settlement. This distinction matters. A counterparty agreeing to trade does not mean assets have moved. The protocol must first verify: The offer still exists. The offer is still open. The acceptance satisfies the original conditions. Both parties can proceed to escrow. Settlement can happen deterministically later. Separating these phases prevents a common class of bugs where negotiation state and asset movement become tightly coupled. Reading Offers Safely Before adding acceptance logic, we need a way for external applications to inspect offers. Currently: mapping(uint256 => Offer) private offers; Enter fullscreen mode Exit fullscreen mode The storage is private. This is intentional. In a confidential environment, exposing raw storage defeats the purpose of keeping negotiation details protected. However, applications still need controlled access. We can create a view function. function getOffer(uint256 offerId) external view returns (Offer memory) { return offers[offerId]; } Enter fullscreen mode Exit fullscreen mode This works for a public chain. However, because our final architecture uses Oasis Sapphire for confidential negotiation, this function becomes an important design decision. On Sapphire, the contract can expose information selectively. The difference is subtle but important: A public Ethereum contract assumes: "Everything is visible unless encrypted externally." A confidential execution environment assumes: "The contract decides what information leaves the private state." The same Solidity pattern behaves differently depending on where it executes. Validating State Transitions Let's add a helper function. function _requireOpenOffer( Offer storage offer ) internal view { if (offer.status != OfferStatus.Open) { revert OfferNotOpen(); } if (block.timestamp > offer.expiry) { revert OfferExpired(); } } Enter fullscreen mode Exit fullscreen mode This function centralizes the rules for an active offer. Why not repeat these checks everywhere? Because duplicated state validation creates drift. Imagine one function checks expiry: block.timestamp >= expiry Enter fullscreen mode Exit fullscreen mode while another checks: block.timestamp > expiry Enter fullscreen mode Exit fullscreen mode That one-character difference creates inconsistent behavior. Financial protocols should have one definition of validity. Accepting an Offer Now we can implement the acceptance transition. function acceptOffer( uint256 offerId ) external { Offer storage offer = offers[offerId]; _requireOpenOffer(offer); offer.status = OfferStatus.Accepted; } Enter fullscreen mode Exit fullscreen mode The function is intentionally simple. The complexity comes from the guarantees around it. Before changing state, the contract proves: the offer exists, the offer is active, the offer has not expired. Only then does the transition occur. But Who Can Accept? This is the first major design decision. There are several possible models. Open Acceptance Anyone can accept an offer. Example: Alice creates: Sell: 500,000 USDC Receive: 165 ETH Enter fullscreen mode Exit fullscreen mode Any participant can accept. Advantages: simple, permissionless, similar to public exchange orders. Disadvantages: poor fit for institutional OTC, no counterparty negotiation, creates competition around acceptance. Whitelisted Counterparties The maker specifies who can accept. Example: Maker: DAO Treasury Allowed Counterparty: Market Maker X Enter fullscreen mode Exit fullscreen mode Advantages: closer to institutional OTC, predictable counterparties. Disadvantages: requires identity management. Signed Acceptance The most flexible approach is requiring both sides to sign the agreement. The final transaction contains: Maker Signature + Counterparty Signature Enter fullscreen mode Exit fullscreen mode The contract verifies both signatures before allowing settlement. This is the approach we will move toward. Adding Counterparty Information Our current Offer struct only knows the maker. For bilateral OTC trading, we need another participant. Update the structure: struct Offer { address maker; address taker; address tokenOffered; address tokenRequested; uint256 amountOffered; uint256 minimumAmountRequested; uint256 expiry; uint256 nonce; OfferStatus status; } Enter fullscreen mode Exit fullscreen mode The new field: address taker; Enter fullscreen mode Exit fullscreen mode represents the counterparty who accepted the trade. Before acceptance: maker = Alice taker = address(0) Enter fullscreen mode Exit fullscreen mode After acceptance: maker = Alice taker = Bob Enter fullscreen mode Exit fullscreen mode This gives the protocol an explicit record of both parties. Updating Acceptance Now acceptance records the counterparty. function acceptOffer( uint256 offerId ) external { Offer storage offer = offers[offerId]; _requireOpenOffer(offer); offer.taker = msg.sender; offer.status = OfferStatus.Accepted; emit OfferAccepted( offerId, msg.sender ); } Enter fullscreen mode Exit fullscreen mode The state transition is now clearer. Before: Open maker: Alice taker: none Enter fullscreen mode Exit fullscreen mode After: Accepted maker: Alice taker: Bob Enter fullscreen mode Exit fullscreen mode The contract now knows exactly who negotiated the trade. Adding Acceptance Events Just like offer creation, acceptance needs an event. event OfferAccepted( uint256 indexed offerId, address indexed taker ); Enter fullscreen mode Exit fullscreen mode This allows external systems to react. For example: A backend service can listen for: OfferAccepted Enter fullscreen mode Exit fullscreen mode and automatically begin escrow preparation. A frontend can update: Status: Accepted Waiting for escrow Enter fullscreen mode Exit fullscreen mode Events become the communication layer between blockchain state and applications. Preventing Double Acceptance Consider this sequence: Transaction 1: Bob accepts offer #10 Transaction 2: Charlie accepts offer #10 Enter fullscreen mode Exit fullscreen mode Only one should succeed. Our _requireOpenOffer check already prevents this. After Bob's transaction: offer.status = OfferStatus.Accepted; Enter fullscreen mode Exit fullscreen mode The second transaction sees: OfferStatus.Accepted Enter fullscreen mode Exit fullscreen mode and reverts. This is why explicit state machines are powerful. The contract does not need complicated logic. The state itself prevents invalid actions. Why This Matters for Confidential OTC At first glance, acceptance looks like a simple update. It is not. Acceptance is the point where private negotiation becomes a binding agreement. In traditional OTC markets, this moment happens after: price discovery, counterparty verification, risk checks, legal confirmation. On-chain, the smart contract replaces much of that coordination. The contract becomes the neutral agreement layer. With Sapphire, this process can remain confidential: Trader A | | Private Offer | v Oasis Sapphire | | Accepted Agreement | v Base Settlement Enter fullscreen mode Exit fullscreen mode The public chain does not need to observe every negotiation step. It only needs proof that settlement conditions were satisfied. Preparing for Escrow Acceptance alone is still not enough. A malicious counterparty could accept an offer and then disappear. Example: Alice: Locks: 500,000 USDC Enter fullscreen mode Exit fullscreen mode Bob: Accepts trade Enter fullscreen mode Exit fullscreen mode Then Bob never provides ETH. The protocol must solve this problem. The next stage introduces escrow. We will build: ERC-20 token transfers, secure custody, allowance handling, escrow state tracking, settlement authorization, withdrawal rules, failure recovery. This is where the protocol starts becoming an actual OTC settlement system rather than a negotiation registry. Building the Escrow Layer The acceptance flow gives us something important. We now have agreement. But agreement is not settlement. This distinction is fundamental in financial protocols. A traditional OTC desk does not send millions of dollars simply because two traders verbally agree on a price. There is usually a custody layer, a clearing process, or a settlement mechanism that guarantees both parties perform. Smart contracts need the same separation. The registry answers: "Who agreed to trade?" The escrow contract answers: "Are the assets actually secured?" Only after both questions have a valid answer should settlement begin. Why Escrow Exists Consider a simple failure scenario. Alice creates an offer: Sell: 500,000 USDC Receive: 165 ETH Enter fullscreen mode Exit fullscreen mode Bob accepts. The registry now contains: status = Accepted; Enter fullscreen mode Exit fullscreen mode But nothing forces Bob to provide ETH. The protocol has created an agreement without enforcement. This is the difference between: Negotiation Layer Enter fullscreen mode Exit fullscreen mode and: Settlement Layer Enter fullscreen mode Exit fullscreen mode The escrow contract connects them. Separating Escrow From the Registry A common beginner design is putting everything into one contract. Something like: contract OTCProtocol { createOffer() acceptOffer() deposit() settle() cancel() } Enter fullscreen mode Exit fullscreen mode This looks convenient. However, it creates several problems. The contract now handles: offer management, token custody, settlement, permissions, refunds. Every additional responsibility increases audit complexity. Instead, we separate concerns. Our architecture becomes: OTCRegistry "What was agreed?" | | v Escrow "Are assets locked?" | | v SettlementCoordinator "Execute final trade" Enter fullscreen mode Exit fullscreen mode Each contract has one job. Creating Escrow.sol Inside: src/Escrow.sol Enter fullscreen mode Exit fullscreen mode we create our custody contract. // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract Escrow { } Enter fullscreen mode Exit fullscreen mode The first dependency is ERC-20 support. The escrow contract does not care whether the token is USDC, WETH, or another asset. It only needs the standard interface. Modeling Escrow Positions An escrow position represents one side of a trade. We need to store: who deposited, which token, how much, which offer it belongs to, current state. Let's define it. contract Escrow { enum EscrowStatus { Pending, Deposited, Released, Refunded } struct Deposit { address depositor; address token; uint256 amount; uint256 offerId; EscrowStatus status; } } Enter fullscreen mode Exit fullscreen mode The structure is intentionally small. Escrow contracts should avoid unnecessary state. Every stored variable creates another invariant that must remain correct forever. Tracking Deposits Now we need storage. mapping(uint256 => Deposit) public deposits; uint256 public depositCounter; Enter fullscreen mode Exit fullscreen mode Each deposit receives an identifier. Example: Deposit #42 Offer: #100 Depositor: Alice Token: USDC Amount: 500000 Enter fullscreen mode Exit fullscreen mode This identifier becomes useful during settlement. Depositing Assets Now we can implement the first important function. function deposit( uint256 offerId, address token, uint256 amount ) external returns (uint256 depositId) { require( amount > 0, "Invalid amount" ); IERC20(token).transferFrom( msg.sender, address(this), amount ); depositId = ++depositCounter; deposits[depositId] = Deposit({ depositor: msg.sender, token: token, amount: amount, offerId: offerId, status: EscrowStatus.Deposited }); } Enter fullscreen mode Exit fullscreen mode The flow is: User approves the escrow contract. Escrow pulls tokens using transferFrom. A deposit record is created. The protocol now knows assets are secured. Why Approval Comes First ERC-20 tokens do not allow arbitrary contracts to move funds. The user must first approve spending. The sequence is: User Wallet approve() | v Escrow Contract transferFrom() | v Escrow Balance Enter fullscreen mode Exit fullscreen mode This is standard ERC-20 behavior. For production systems, frontends usually combine these steps into a single user experience. The user sees: "Deposit 500,000 USDC" rather than manually performing approval and transfer operations. Safe Transfer Handling The previous example uses: IERC20(token).transferFrom() Enter fullscreen mode Exit fullscreen mode However, production contracts should handle tokens that do not properly return boolean values. OpenZeppelin provides: SafeERC20 Enter fullscreen mode Exit fullscreen mode Let's update the contract. import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract Escrow { using SafeERC20 for IERC20; } Enter fullscreen mode Exit fullscreen mode Now deposits become: IERC20(token).safeTransferFrom( msg.sender, address(this), amount ); Enter fullscreen mode Exit fullscreen mode This protects against non-standard ERC-20 implementations. Adding Events Like the registry, escrow needs events. event Deposited( uint256 indexed depositId, uint256 indexed offerId, address indexed depositor, address token, uint256 amount ); Enter fullscreen mode Exit fullscreen mode Then after creating the deposit: emit Deposited( depositId, offerId, msg.sender, token, amount ); Enter fullscreen mode Exit fullscreen mode External systems can now monitor escrow activity. For example: Offer Accepted | v Waiting for Deposits | v Both Sides Funded | v Settlement Ready Enter fullscreen mode Exit fullscreen mode The Escrow Invariant The most important property of this contract is: Escrowed funds = Funds controlled by protocol rules Enter fullscreen mode Exit fullscreen mode Users should never be able to bypass the settlement process. After depositing: Alice | | 500,000 USDC v Escrow Contract Enter fullscreen mode Exit fullscreen mode Alice cannot simply withdraw. Bob cannot withdraw. The only valid paths are: Successful Settlement or Authorized Refund Enter fullscreen mode Exit fullscreen mode This is the core security property. Connecting Escrow to Acceptance Right now the escrow contract does not know whether an offer was actually accepted. That is intentional. Remember our separation of responsibilities. The registry knows: Offer #100 Maker: Alice Taker: Bob Status: Accepted Enter fullscreen mode Exit fullscreen mode The escrow knows: Deposit #55 Offer: #100 Amount: 500,000 USDC Status: Deposited Enter fullscreen mode Exit fullscreen mode The settlement coordinator will connect these pieces. Introducing the Settlement Coordinator The final architecture becomes: Sapphire OTCRegistry Negotiation + Agreement | v Escrow Asset Security Layer | v SettlementCoordinator Cross-chain Execution | v Base Public Settlement Enter fullscreen mode Exit fullscreen mode The coordinator becomes the bridge between confidential negotiation and public execution. Why This Architecture Fits Sapphire + Base This separation lets each network do what it does best. Sapphire handles: private offers, confidential quotes, negotiation state. Escrow handles: asset custody, enforcement. Base handles: transparent settlement, final ownership changes. The protocol does not attempt to make one chain solve every problem. Instead, it assigns responsibilities. Next Section The escrow layer now provides: ERC-20 custody, deposit tracking, safe token transfers, event indexing, clear ownership boundaries. The next part will implement the final settlement path. We will cover: SettlementCoordinator.sol cross-chain settlement design proving escrow conditions releasing funds safely Base integration patterns replay protection across chains handling failures and cancellations This is where the complete OTC protocol architecture comes together. Building the Settlement Coordinator At this point, the protocol has three important capabilities. The registry can create and accept agreements. The escrow contract can lock assets. What remains is the most important operation: Moving from a private agreement into final settlement. This is where the architecture becomes interesting. The protocol intentionally separates: Private negotiation | v Verified agreement | v Public settlement Enter fullscreen mode Exit fullscreen mode The negotiation does not happen on Base. The final ownership transfer does. Why Settlement Should Be Separate A common mistake is allowing the escrow contract to directly perform settlement. Something like: function settle() external { transferTokens(); } Enter fullscreen mode Exit fullscreen mode This couples custody and execution. The escrow contract now needs to understand: who receives funds, which chain settlement happens on, whether signatures are valid, whether external messages are trusted. That creates unnecessary complexity. Instead, escrow only answers: "Are the required assets secured?" The settlement coordinator answers: "Should the trade execute?" Creating SettlementCoordinator.sol Inside: src/SettlementCoordinator.sol Enter fullscreen mode Exit fullscreen mode we create: // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract SettlementCoordinator { } Enter fullscreen mode Exit fullscreen mode The coordinator will eventually communicate with: OTCRegistry | | Escrow | | Base Settlement Layer Enter fullscreen mode Exit fullscreen mode Defining Settlement State A settlement has its own lifecycle. It is different from an offer lifecycle. The offer lifecycle: Open | v Accepted | v Escrowed | v Settled Enter fullscreen mode Exit fullscreen mode The settlement lifecycle: Created | v Verified | v Executed | v Completed Enter fullscreen mode Exit fullscreen mode Let's model this. enum SettlementStatus { Pending, Ready, Executed, Failed } Enter fullscreen mode Exit fullscreen mode This prevents ambiguous states. Settlement Records The coordinator needs to know: which offer is being settled, which escrow positions are involved, who receives what, whether execution happened. Create the structure: struct Settlement { uint256 offerId; uint256 makerDeposit; uint256 takerDeposit; address maker; address taker; SettlementStatus status; } Enter fullscreen mode Exit fullscreen mode A settlement is not an offer. An offer describes intention. A settlement describes execution. Keeping those separate avoids mixing negotiation state with financial state. Storing Settlements mapping(uint256 => Settlement) public settlements; uint256 public settlementCounter; Enter fullscreen mode Exit fullscreen mode Example: Settlement #7 Offer: #421 Maker: Alice Taker: Bob Maker Deposit: USDC Taker Deposit: ETH Status: Ready Enter fullscreen mode Exit fullscreen mode Creating a Settlement Once both sides have escrowed funds, we create a settlement. function createSettlement( uint256 offerId, uint256 makerDeposit, uint256 takerDeposit, address maker, address taker ) external returns(uint256 settlementId) { settlementId = ++settlementCounter; settlements[settlementId] = Settlement({ offerId: offerId, makerDeposit: makerDeposit, takerDeposit: takerDeposit, maker: maker, taker: taker, status: SettlementStatus.Pending }); } Enter fullscreen mode Exit fullscreen mode This function does not move funds. That is intentional. Settlement creation is a coordination step. Verifying Escrow Conditions Before execution, we need proof that both sides funded the trade. The coordinator should never trust user input. A malicious user could attempt: makerDeposit = fakeDepositId Enter fullscreen mode Exit fullscreen mode The coordinator must verify directly with escrow. The pattern becomes: Escrow "Is deposit X valid?" | v SettlementCoordinator "Proceed or reject" Enter fullscreen mode Exit fullscreen mode Escrow Interface Instead of importing the entire escrow contract, define a small interface. interface IEscrow { function getDeposit( uint256 depositId ) external view returns( address depositor, address token, uint256 amount, uint256 offerId, uint8 status ); } Enter fullscreen mode Exit fullscreen mode The coordinator only knows what it needs. This reduces coupling. Making Settlement Ready Now we can verify both deposits. function markReady( uint256 settlementId ) external { Settlement storage settlement = settlements[settlementId]; require( settlement.status == SettlementStatus.Pending, "Invalid state" ); settlement.status = SettlementStatus.Ready; } Enter fullscreen mode Exit fullscreen mode In production, this function would perform full escrow validation. The simplified version demonstrates the state machine. Executing Settlement Execution is where assets finally move. The coordinator instructs escrow: Release maker asset + Release taker asset Enter fullscreen mode Exit fullscreen mode The important rule: Both sides execute atomically. Either: Alice receives ETH Bob receives USDC Enter fullscreen mode Exit fullscreen mode or: Nothing happens Enter fullscreen mode Exit fullscreen mode Partial settlement is dangerous. Atomic Settlement Pattern A bad implementation: releaseAliceFunds(); releaseBobFunds(); Enter fullscreen mode Exit fullscreen mode If the second operation fails: Alice receives funds Bob receives nothing Enter fullscreen mode Exit fullscreen mode A production system requires transactional atomicity. The better pattern: function settle( uint256 settlementId ) external { Settlement storage settlement = settlements[settlementId]; require( settlement.status == SettlementStatus.Ready, "Not ready" ); // execute both transfers settlement.status = SettlementStatus.Executed; } Enter fullscreen mode Exit fullscreen mode Because Solidity transactions revert entirely on failure, the operation is atomic. Cross-Chain Settlement Design Now we reach the interesting architectural question. How does Sapphire communicate with Base? The answer is not: "Move the whole application to Base." The answer is: Create a settlement message. The flow becomes: Sapphire Private Trade Agreement | v Settlement Message | v Base Public Execution Enter fullscreen mode Exit fullscreen mode The settlement message contains only what is necessary. For example: struct SettlementMessage { bytes32 tradeId; address receiver; address token; uint256 amount; uint256 nonce; } Enter fullscreen mode Exit fullscreen mode Notice what is missing. No negotiation history. No quotes. No private strategy. Only the information required for final execution. Why This Matters The privacy boundary becomes very clear. Before settlement: Confidential Enter fullscreen mode Exit fullscreen mode After settlement: Public Enter fullscreen mode Exit fullscreen mode This mirrors traditional finance. A hedge fund may privately negotiate a block trade. But once settlement occurs, ownership changes become visible. The blockchain is not replacing OTC markets. It is improving the settlement layer. Adding Replay Protection Cross-chain systems introduce another risk. A valid settlement message could potentially be submitted twice. Example: Message: Pay Alice 500 ETH Enter fullscreen mode Exit fullscreen mode Executed once. Then replayed. Executed again. This is why every settlement message needs uniqueness. The simplest mechanism: mapping(bytes32 => bool) processedMessages; Enter fullscreen mode Exit fullscreen mode Before execution: require( !processedMessages[id], "Already executed" ); Enter fullscreen mode Exit fullscreen mode After: processedMessages[id] = true; Enter fullscreen mode Exit fullscreen mode One small mapping prevents catastrophic duplication. Current Architecture We now have: Oasis Sapphire Confidential Negotiation | v Settlement Message | v Base Transparent Final Settlement Enter fullscreen mode Exit fullscreen mode Each layer has a clear responsibility. Integrating Oasis Sapphire Confidential Execution The settlement architecture is now complete. However, we have not yet fully answered the original question: How do we actually make OTC negotiation confidential? So far, we have built a conventional Solidity protocol: offers, signatures, escrow, settlement coordination. Those components work on any EVM-compatible chain. The reason Oasis Sapphire is interesting is that it changes the privacy model of the application. Instead of asking: "How do we encrypt everything before putting it on-chain?" we can ask: "Which parts of the application should execute privately?" That distinction is extremely important. Public Blockchains and Information Leakage On a traditional EVM chain, contract state is transparent. If a contract stores: struct Offer { address maker; address tokenOffered; uint256 amount; uint256 price; } Enter fullscreen mode Exit fullscreen mode then anyone can eventually inspect: who created the offer, what asset they want, how much they are trading, the conditions. Even if the frontend hides this information, the blockchain does not. For OTC trading, that is a problem. The negotiation itself is valuable information. What Sapphire Changes Oasis Sapphire provides confidential EVM execution. The developer experience remains familiar: pragma solidity ^0.8.24; contract OTCRegistry { } Enter fullscreen mode Exit fullscreen mode but execution happens inside a confidential environment. Sensitive state can remain protected while the contract continues using Solidity patterns. The important architectural shift is: Before Sapphire: User | | Encrypted Data | | Public Smart Contract | | Everyone Observes State Enter fullscreen mode Exit fullscreen mode After Sapphire: User | | Confidential Contract Execution | | Protected State | | Only Authorized Outputs Revealed Enter fullscreen mode Exit fullscreen mode Moving the Registry to Sapphire The OTC Registry is the natural candidate for Sapphire. Why? Because the registry contains negotiation information. The escrow and final settlement layer do not need the same privacy guarantees. A better architecture becomes: Oasis Sapphire OTCRegistry Private Offers + Quotes | | Settlement Proof | | Base Final Asset Settlement Enter fullscreen mode Exit fullscreen mode The registry becomes the private negotiation engine. Confidential Offer Storage Previously we stored: mapping(uint256 => Offer) private offers; Enter fullscreen mode Exit fullscreen mode On a public chain, private storage is only private by convention. On Sapphire, confidential state is enforced by the execution environment. This means the contract can safely maintain information such as: struct PrivateOffer { address maker; address requestedCounterparty; uint256 amount; uint256 price; bytes encryptedTerms; } Enter fullscreen mode Exit fullscreen mode without exposing every field publicly. Private Quotes Institutional OTC trading rarely happens with a single offer. Usually the process looks like: Trader: "I want to sell 10 million USDC." Market makers: "Here are three quotes." Trader: "I accept quote #2." A public blockchain would reveal every quote. That creates unnecessary information leakage. Sapphire allows this flow: Trader | | Private Request | | Sapphire Contract | | Market Makers Submit Quotes | | Trader Accepts Best Quote Enter fullscreen mode Exit fullscreen mode Only the final agreement needs to leave the confidential environment. What Sapphire Does Not Hide It is important not to overstate confidentiality. Sapphire does not magically make everything invisible. The final settlement on Base is public. Observers can see: settlement transactions, transferred assets, destination addresses, execution timing. The privacy boundary is: Negotiation Private Settlement Public Enter fullscreen mode Exit fullscreen mode This is exactly the same separation used in traditional OTC markets. Confidential Signatures Our earlier EIP-712 flow becomes even more useful here. Instead of broadcasting every signed message: Offer Created Quote Received Counter Offer Revised Quote Enter fullscreen mode Exit fullscreen mode the application can keep those interactions confidential. Only the final signed commitment becomes relevant. The protocol effectively changes from: Every negotiation step is public Enter fullscreen mode Exit fullscreen mode to: Only the final agreement is verifiable Enter fullscreen mode Exit fullscreen mode Sapphire Design Principles When building confidential contracts, the same engineering principles still apply. Privacy does not replace good architecture. We still need: explicit state machines, replay protection, authorization checks, safe token handling, deterministic settlement. Confidential execution protects information. It does not protect incorrect logic. Building the SDK and Production Workflow The smart contracts are complete. Now we need users to interact with them. A professional protocol rarely exposes raw contract calls directly. Instead, applications use an SDK layer. Our repository already contains: sdk/ otcClient.ts signer.ts quotes.ts Enter fullscreen mode Exit fullscreen mode This layer handles: wallet connections, typed messages, signatures, contract calls, settlement tracking. Creating the SDK Client Example: class OTCClient { constructor( private registryContract, private signer ) {} async createOffer(params) { return this.registryContract .createOffer( params.tokenOffered, params.tokenRequested, params.amount, params.minimum, params.expiry ); } } Enter fullscreen mode Exit fullscreen mode The frontend should not know Solidity details. It should think in business operations: createOffer() acceptOffer() deposit() settle() Enter fullscreen mode Exit fullscreen mode Signing Offers The SDK also handles EIP-712. Example: const signature = await signer.signTypedData( domain, types, offer ); Enter fullscreen mode Exit fullscreen mode The user sees: Sell: 500,000 USDC Receive: 165 ETH Expires: Tomorrow Enter fullscreen mode Exit fullscreen mode not: 0x83ab29ff.... Enter fullscreen mode Exit fullscreen mode Readable signatures reduce user mistakes. Frontend Flow A complete user journey looks like: Trader Opens App | Creates OTC Request | Signs Private Intent | Market Makers Submit Quotes | Trader Accepts Quote | Assets Escrowed | Settlement Message Created | Base Transaction Executed | Trade Complete Enter fullscreen mode Exit fullscreen mode Testing the Protocol Financial protocols require more than happy-path testing. We should test: invalid signatures, expired offers, double acceptance, failed deposits, replayed settlement messages, unauthorized withdrawals. Example Foundry Test function testCannotAcceptExpiredOffer() public { vm.warp(block.timestamp + 2 days); vm.expectRevert( OfferExpired.selector ); registry.acceptOffer( offerId ); } Enter fullscreen mode Exit fullscreen mode The goal is not simply proving success. The goal is proving invalid behavior is impossible. Fuzz Testing OTC protocols have many numeric inputs: amounts, timestamps, nonces, token quantities. Fuzz testing helps discover unexpected edge cases. Example: function testAmounts( uint256 amount ) public { vm.assume(amount > 0); createOffer(amount); } Enter fullscreen mode Exit fullscreen mode The framework automatically explores many values. Security Checklist Before deploying a protocol handling valuable assets: Access Control Verify: only makers cancel offers, only accepted counterparties settle, only authorized coordinators release escrow. Replay Protection Verify: signatures cannot be reused, settlement messages cannot execute twice, nonces increment correctly. State Transitions Verify: Invalid: Open → Settled Enter fullscreen mode Exit fullscreen mode Valid: Open | Accepted | Escrowed | Settled Enter fullscreen mode Exit fullscreen mode Token Safety Use: SafeERC20, allowance checks, zero address validation. Failure Recovery Every financial protocol needs failure paths. Examples: expired offers, abandoned settlements, cancelled negotiations. A protocol is not defined only by successful trades. It is defined by how safely it handles failure. Final Architecture The completed system looks like this: Trader A | | Oasis Sapphire Confidential OTC Registry - Private Offers - Private Quotes - Signed Agreements | | Settlement Message | | Base Public Settlement Layer - Asset Transfer - Final Ownership | | Trader B Enter fullscreen mode Exit fullscreen mode Conclusion Large trades require different infrastructure from ordinary swaps. Automated market makers are excellent for transparent liquidity. They are not designed for confidential institutional negotiation. An OTC protocol needs a different approach. The architecture we built separates the problem into three layers: Confidential Negotiation Handled by Oasis Sapphire. This protects sensitive trading information while preserving smart contract coordination. Asset Security Handled through escrow. This ensures both parties commit funds before execution. Public Settlement Handled by Base. This provides transparent final ownership transfer. The result is not a replacement for decentralized exchanges. It is a complementary system designed for a different class of trading. Large financial transactions require both privacy and trust minimization. Confidential execution provides the privacy. Smart contracts provide the enforcement. Public settlement provides the transparency. Together, they create a practical foundation for the next generation of decentralized OTC trading systems.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.