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
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
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/
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
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
Now create a new project.
forge init confidential-otc-protocol
cd confidential-otc-protocol
We'll also install OpenZeppelin contracts.
forge install OpenZeppelin/openzeppelin-contracts
Our project now looks like this.
confidential-otc-protocol/
├── lib/
│
├── script/
│
├── src/
│
├── test/
│
├── foundry.toml
│
└── README.md
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;
}
}
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;
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;
This represents the asset currently owned by the maker.
For example,
USDC
or
WETH
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;
This is the asset the maker expects in return.
Together,
tokenOffered
↓
tokenRequested
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;
This field specifies how many units of the offered token the maker wishes to trade.
For example,
500,000 USDC
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;
This field acts as protection against unfavorable execution.
Suppose Alice wants to exchange
500,000 USDC
for at least
165 ETH
If a counterparty proposes
160 ETH
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;
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;
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;
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...
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
});
}
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"
);
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();
Now validation becomes:
if (expiry <= block.timestamp) {
revert OfferExpired();
}
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;
}
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
);
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
);
}
The function now has three clear responsibilities:
- Validate the offer.
- Store protocol state.
- 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...
users sign something understandable:
Sell:
500000 USDC
Receive:
165 ETH
Expiry:
July 20 2026
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;
}
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";
Our contract now inherits:
contract OTCRegistry is EIP712 {
}
The constructor initializes the signing domain.
constructor()
EIP712(
"Confidential OTC Protocol",
"1"
)
{}
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)"
);
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:
- Create an offer locally.
- Sign it.
- Share it privately.
- Receive counterparty acceptance.
- 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
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;
}
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
There are also failure paths:
Open
|
+----> Cancelled
|
+----> Expired
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;
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];
}
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();
}
}
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
while another checks:
block.timestamp > expiry
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;
}
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
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
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
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;
}
The new field:
address taker;
represents the counterparty who accepted the trade.
Before acceptance:
maker = Alice
taker = address(0)
After acceptance:
maker = Alice
taker = Bob
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
);
}
The state transition is now clearer.
Before:
Open
maker:
Alice
taker:
none
After:
Accepted
maker:
Alice
taker:
Bob
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
);
This allows external systems to react.
For example:
A backend service can listen for:
OfferAccepted
and automatically begin escrow preparation.
A frontend can update:
Status:
Accepted
Waiting for escrow
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
Only one should succeed.
Our _requireOpenOffer check already prevents this.
After Bob's transaction:
offer.status = OfferStatus.Accepted;
The second transaction sees:
OfferStatus.Accepted
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
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
Bob:
Accepts trade
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
Bob accepts.
The registry now contains:
status = Accepted;
But nothing forces Bob to provide ETH.
The protocol has created an agreement without enforcement.
This is the difference between:
Negotiation Layer
and:
Settlement Layer
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()
}
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"
Each contract has one job.
Creating Escrow.sol
Inside:
src/Escrow.sol
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 {
}
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;
}
}
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;
Each deposit receives an identifier.
Example:
Deposit #42
Offer:
#100
Depositor:
Alice
Token:
USDC
Amount:
500000
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
});
}
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
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()
However, production contracts should handle tokens that do not properly return boolean values.
OpenZeppelin provides:
SafeERC20
Let's update the contract.
import {
SafeERC20
} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract Escrow {
using SafeERC20 for IERC20;
}
Now deposits become:
IERC20(token).safeTransferFrom(
msg.sender,
address(this),
amount
);
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
);
Then after creating the deposit:
emit Deposited(
depositId,
offerId,
msg.sender,
token,
amount
);
External systems can now monitor escrow activity.
For example:
Offer Accepted
|
v
Waiting for Deposits
|
v
Both Sides Funded
|
v
Settlement Ready
The Escrow Invariant
The most important property of this contract is:
Escrowed funds
=
Funds controlled by protocol rules
Users should never be able to bypass the settlement process.
After depositing:
Alice
|
| 500,000 USDC
v
Escrow Contract
Alice cannot simply withdraw.
Bob cannot withdraw.
The only valid paths are:
Successful Settlement
or
Authorized Refund
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
The escrow knows:
Deposit #55
Offer:
#100
Amount:
500,000 USDC
Status:
Deposited
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
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
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();
}
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
we create:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SettlementCoordinator {
}
The coordinator will eventually communicate with:
OTCRegistry
|
|
Escrow
|
|
Base Settlement Layer
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
The settlement lifecycle:
Created
|
v
Verified
|
v
Executed
|
v
Completed
Let's model this.
enum SettlementStatus {
Pending,
Ready,
Executed,
Failed
}
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;
}
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;
Example:
Settlement #7
Offer:
#421
Maker:
Alice
Taker:
Bob
Maker Deposit:
USDC
Taker Deposit:
ETH
Status:
Ready
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
});
}
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
The coordinator must verify directly with escrow.
The pattern becomes:
Escrow
"Is deposit X valid?"
|
v
SettlementCoordinator
"Proceed or reject"
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
);
}
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;
}
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
The important rule:
Both sides execute atomically.
Either:
Alice receives ETH
Bob receives USDC
or:
Nothing happens
Partial settlement is dangerous.
Atomic Settlement Pattern
A bad implementation:
releaseAliceFunds();
releaseBobFunds();
If the second operation fails:
Alice receives funds
Bob receives nothing
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;
}
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
The settlement message contains only what is necessary.
For example:
struct SettlementMessage {
bytes32 tradeId;
address receiver;
address token;
uint256 amount;
uint256 nonce;
}
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
After settlement:
Public
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
Executed once.
Then replayed.
Executed again.
This is why every settlement message needs uniqueness.
The simplest mechanism:
mapping(bytes32 => bool)
processedMessages;
Before execution:
require(
!processedMessages[id],
"Already executed"
);
After:
processedMessages[id] = true;
One small mapping prevents catastrophic duplication.
Current Architecture
We now have:
Oasis Sapphire
Confidential Negotiation
|
v
Settlement Message
|
v
Base
Transparent Final Settlement
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;
}
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 {
}
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
After Sapphire:
User
|
|
Confidential Contract Execution
|
|
Protected State
|
|
Only Authorized Outputs Revealed
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
The registry becomes the private negotiation engine.
Confidential Offer Storage
Previously we stored:
mapping(uint256 => Offer)
private offers;
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;
}
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
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
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
the application can keep those interactions confidential.
Only the final signed commitment becomes relevant.
The protocol effectively changes from:
Every negotiation step is public
to:
Only the final agreement is verifiable
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
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
);
}
}
The frontend should not know Solidity details.
It should think in business operations:
createOffer()
acceptOffer()
deposit()
settle()
Signing Offers
The SDK also handles EIP-712.
Example:
const signature =
await signer.signTypedData(
domain,
types,
offer
);
The user sees:
Sell:
500,000 USDC
Receive:
165 ETH
Expires:
Tomorrow
not:
0x83ab29ff....
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
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
);
}
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);
}
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
Valid:
Open
|
Accepted
|
Escrowed
|
Settled
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
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.
Top comments (1)
I like the way you separate negotiation, escrow, and settlement into distinct responsibilities instead of building one monolithic contract. Starting from the data model and state transitions before writing functions is also a good engineering practice. One thing I’d be interested in seeing is how the architecture evolves beyond the happy path. Production OTC systems usually spend more effort on failure handling than on successful settlement: partial funding, escrow timeouts, replay protection across domains, dispute recovery, concurrent acceptance attempts, bridge failures, and reconciliation after cross-chain interruptions. I’d also love to see an observability section. For financial protocols, events, auditability, settlement traces, replay diagnostics, and operational metrics are just as important as the contracts themselves. The architecture is promising, but showing how it behaves under failure scenarios would make it even more compelling.