Building a Confidential OTC Settlement Protocol with Oasis Sapphire & Base

작성자

카테고리:

← 피드로
DEV Community · rayQu · 2026-07-20 개발(SW)

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 <= block.timestamp) {
    revert OfferExpired();
}

Enter fullscreen mode Exit fullscreen mode

The difference may seem small.

For a single transaction, the savings are minimal.

For a protocol processing thousands of interactions, these optimizations compound.

More importantly, custom errors create structured failure conditions.

Frontend applications can identify the exact failure type instead of parsing human-readable strings.

Updating the Registry Contract

Our contract now begins with clearer protocol-level failures.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract OTCRegistry {

    error OfferExpired();
    error InvalidAmount();
    error Unauthorized();
    error OfferNotOpen();

    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;

    }

    mapping(uint256 => 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 <= block.timestamp) {
        revert OfferExpired();
    }


    if (
        amountOffered == 0 ||
        minimumAmountRequested == 0
    ) {
        revert InvalidAmount();
    }


    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

    });


    emit OfferCreated(
        offerId,
        msg.sender,
        tokenOffered,
        tokenRequested,
        amountOffered,
        minimumAmountRequested,
        expiry
    );
}

Enter fullscreen mode Exit fullscreen mode

The function now has three clear responsibilities:

  1. Validate the offer.
  2. Store protocol state.
  3. Notify external systems.

Keeping those responsibilities separated makes later auditing much easier.

Introducing EIP-712 Typed Data

Direct transactions are not always ideal for OTC workflows.

Institutional traders frequently negotiate away from the blockchain.

A market maker might send a quote through a private channel.

A treasury manager might approve a trade through an internal signing system.

Broadcasting every negotiation step on-chain defeats the purpose of confidentiality.

Instead, users should be able to sign trade intents.

The blockchain only verifies the final agreement.

This is where EIP-712 becomes important.

EIP-712 defines a standard format for signing structured data.

Instead of signing an unreadable blob:

0x72af91bc...

Enter fullscreen mode Exit fullscreen mode

users sign something understandable:

Sell:

500000 USDC

Receive:

165 ETH

Expiry:

July 20 2026

Enter fullscreen mode Exit fullscreen mode

Wallets can display these fields clearly before approval.

Designing the Signed Offer

Our signed message needs a deterministic structure.

struct OfferMessage {

    address maker;

    address tokenOffered;

    address tokenRequested;

    uint256 amountOffered;

    uint256 minimumAmountRequested;

    uint256 expiry;

    uint256 nonce;

}

Enter fullscreen mode Exit fullscreen mode

Every field matters.

Removing a field creates ambiguity.

For example, if expiry is not included, an attacker could reuse a valid signature after market conditions changed.

If nonce is missing, replay attacks become possible.

Cryptographic signatures are only as strong as the data they protect.

Adding OpenZeppelin EIP712

Rather than implementing hashing manually, we’ll use OpenZeppelin’s audited implementation.

import {
    EIP712
} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";


import {
    ECDSA
} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

Enter fullscreen mode Exit fullscreen mode

Our contract now inherits:

contract OTCRegistry is EIP712 {

}

Enter fullscreen mode Exit fullscreen mode

The constructor initializes the signing domain.

constructor()
    EIP712(
        "Confidential OTC Protocol",
        "1"
    )
{}

Enter fullscreen mode Exit fullscreen mode

The domain separates signatures from other applications.

A signature created for our OTC protocol cannot accidentally be reused inside another application with different rules.

Hashing the Offer

EIP-712 requires two stages.

First, we hash the structured message.

Then we hash that result with the domain separator.

The final digest is what gets signed.

bytes32 private constant OFFER_TYPEHASH =
keccak256(
    "OfferMessage(address maker,address tokenOffered,address tokenRequested,uint256 amountOffered,uint256 minimumAmountRequested,uint256 expiry,uint256 nonce)"
);

Enter fullscreen mode Exit fullscreen mode

The type hash describes the exact structure.

Any modification changes the resulting hash.

This prevents attackers from altering individual fields without invalidating the signature.

Why This Matters for Confidential OTC

At this point, we have an important architectural improvement.

The negotiation process no longer requires every participant to submit transactions.

A trader can:

  1. Create an offer locally.
  2. Sign it.
  3. Share it privately.
  4. Receive counterparty acceptance.
  5. Submit only the final agreed trade.

This changes the privacy model dramatically.

The blockchain no longer observes every negotiation step.

It only verifies the final commitment.

This pattern is common in modern trading infrastructure.

The chain becomes a settlement layer rather than a communication layer.

Connecting This Back to Oasis Sapphire

This is where Oasis Sapphire becomes useful.

Sapphire allows the negotiation layer itself to become confidential while remaining EVM-compatible.

Instead of exposing signed offers and quotes publicly, sensitive state can remain inside confidential contract execution.

The architecture becomes:

flowchart LR

TraderA --> 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:

  1. The offer still exists.
  2. The offer is still open.
  3. The acceptance satisfies the original conditions.
  4. Both parties can proceed to escrow.
  5. 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:

  1. User approves the escrow contract.
  2. Escrow pulls tokens using transferFrom.
  3. A deposit record is created.
  4. 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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다