DEV Community

Cabledex
Cabledex

Posted on

Building Gas-Efficient OTC Orders With EIP-712: A Cabledex Case Study

Building Gas-Efficient OTC Orders With EIP-712: A Cabledex Case Study

Creating every OTC order directly onchain is simple, but not always efficient.

A maker publishes an order, the contract stores it, and the counterparty fills it later. The problem is that every order consumes gas, including quotes that expire or are never accepted.

For an RFQ-based protocol, many quotes may exist for only a few minutes.

A more efficient model is:

  1. Create the order offchain.

  2. Sign the order using EIP-712.

  3. Send it to the intended counterparty.

  4. Submit it onchain only when settlement is ready.

This article uses Cabledex as a practical example of fixed-price OTC settlement with offchain signatures.

We will cover:

  • EIP-712 typed orders

  • Signature verification

  • Nonce-based replay protection

  • Restricted taker wallets

  • Atomic ERC-20 settlement

Note: The following code is educational and should not be used in production without extensive testing and an independent security audit.


Why move quotes offchain?

Imagine that a DAO requests quotes from five market makers.

Only one quote is accepted.

If every quote is created onchain, all five require gas even though four of them are never used.

With signed offchain orders, only the accepted quote reaches the blockchain.

The workflow becomes:

Maker creates a quote
        ↓
Maker signs the order
        ↓
Taker receives the signed quote
        ↓
Taker submits it onchain
        ↓
The contract verifies and settles
Enter fullscreen mode Exit fullscreen mode

This is particularly useful for short-lived RFQ orders.


Defining the order

A simplified Cabledex order may contain:

struct Order {
    address maker;
    address taker;
    address sellToken;
    address buyToken;
    uint256 sellAmount;
    uint256 buyAmount;
    uint256 nonce;
    uint256 deadline;
}
Enter fullscreen mode Exit fullscreen mode

The fields specify:

  • Maker: the wallet offering the asset

  • Taker: the wallet allowed to fill the order

  • Sell token: the asset offered by the maker

  • Buy token: the asset requested in return

  • Amounts: the exact quantities being exchanged

  • Nonce: a unique order identifier

  • Deadline: the expiration timestamp

The order does not need to be stored onchain when it is created.

The maker signs these parameters instead.


Why EIP-712?

EIP-712 allows wallets to sign structured data rather than an unreadable hexadecimal message.

The signing domain can bind the order to:

  • the Cabledex protocol;

  • a specific protocol version;

  • one blockchain network;

  • one settlement contract.

A frontend domain may look like this:

const domain = {
  name: "Cabledex",
  version: "1",
  chainId: 42161,
  verifyingContract: "0xSettlementContract"
};
Enter fullscreen mode Exit fullscreen mode

This reduces the risk of using the same signature in a different context.

However, EIP-712 does not automatically prevent replay attacks. The settlement contract still needs to track used nonces.


Simplified settlement contract

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

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

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

import {
    IERC20
} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import {
    SafeERC20
} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

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

contract CabledexSignedOrders is EIP712, ReentrancyGuard {
    using SafeERC20 for IERC20;

    struct Order {
        address maker;
        address taker;
        address sellToken;
        address buyToken;
        uint256 sellAmount;
        uint256 buyAmount;
        uint256 nonce;
        uint256 deadline;
    }

    bytes32 private constant ORDER_TYPEHASH =
        keccak256(
            "Order("
            "address maker,"
            "address taker,"
            "address sellToken,"
            "address buyToken,"
            "uint256 sellAmount,"
            "uint256 buyAmount,"
            "uint256 nonce,"
            "uint256 deadline"
            ")"
        );

    mapping(address => mapping(uint256 => bool))
        public nonceUsed;

    event OrderFilled(
        bytes32 indexed orderHash,
        address indexed maker,
        address indexed taker,
        uint256 nonce
    );

    error InvalidOrder();
    error OrderExpired();
    error NonceAlreadyUsed();
    error UnauthorizedTaker();
    error InvalidSignature();

    constructor() EIP712("Cabledex", "1") {}

    function hashOrder(
        Order calldata order
    ) public view returns (bytes32) {
        bytes32 structHash = keccak256(
            abi.encode(
                ORDER_TYPEHASH,
                order.maker,
                order.taker,
                order.sellToken,
                order.buyToken,
                order.sellAmount,
                order.buyAmount,
                order.nonce,
                order.deadline
            )
        );

        return _hashTypedDataV4(structHash);
    }

    function fillOrder(
        Order calldata order,
        bytes calldata signature
    ) external nonReentrant {
        if (
            order.maker == address(0) ||
            order.sellToken == address(0) ||
            order.buyToken == address(0) ||
            order.sellToken == order.buyToken ||
            order.sellAmount == 0 ||
            order.buyAmount == 0
        ) {
            revert InvalidOrder();
        }

        if (block.timestamp > order.deadline) {
            revert OrderExpired();
        }

        if (nonceUsed[order.maker][order.nonce]) {
            revert NonceAlreadyUsed();
        }

        if (
            order.taker != address(0) &&
            order.taker != msg.sender
        ) {
            revert UnauthorizedTaker();
        }

        bytes32 orderHash = hashOrder(order);

        bool validSignature =
            SignatureChecker.isValidSignatureNow(
                order.maker,
                orderHash,
                signature
            );

        if (!validSignature) {
            revert InvalidSignature();
        }

        nonceUsed[order.maker][order.nonce] = true;

        IERC20(order.buyToken).safeTransferFrom(
            msg.sender,
            order.maker,
            order.buyAmount
        );

        IERC20(order.sellToken).safeTransferFrom(
            order.maker,
            msg.sender,
            order.sellAmount
        );

        emit OrderFilled(
            orderHash,
            order.maker,
            msg.sender,
            order.nonce
        );
    }

    function cancelNonce(
        uint256 nonce
    ) external {
        if (nonceUsed[msg.sender][nonce]) {
            revert NonceAlreadyUsed();
        }

        nonceUsed[msg.sender][nonce] = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

This example uses:

  • EIP712 to construct the typed-data digest

  • SignatureChecker to support ordinary and smart-contract wallets

  • SafeERC20 for safer token transfers

  • ReentrancyGuard to block nested settlement calls


How settlement works

When the taker calls fillOrder, the contract performs several checks.

1. Validate the order

The contract rejects invalid token addresses, identical token pairs, zero amounts, and expired orders.

Frontend validation may improve user experience, but the contract must enforce the actual rules.

2. Check the nonce

if (nonceUsed[order.maker][order.nonce]) {
    revert NonceAlreadyUsed();
}
Enter fullscreen mode Exit fullscreen mode

A successful order marks its nonce as used.

This prevents the same signature from being executed multiple times.

3. Verify the taker

if (
    order.taker != address(0) &&
    order.taker != msg.sender
) {
    revert UnauthorizedTaker();
}
Enter fullscreen mode Exit fullscreen mode

When taker is the zero address, the order is public.

When it contains a specific wallet, only that wallet may fill the order.

This is useful for privately negotiated OTC quotes.

4. Verify the signature

The contract reconstructs the order hash and checks whether the maker authorized it.

Changing any signed field invalidates the signature.

A taker cannot secretly change:

500,000 USDC
Enter fullscreen mode Exit fullscreen mode

to:

490,000 USDC
Enter fullscreen mode Exit fullscreen mode

while using the original signature.

5. Transfer both assets

The payment token moves from the taker to the maker.

The offered token moves from the maker to the taker.

Both transfers occur inside one transaction.

The result is:

Both sides settle, or the complete transaction reverts.


Signing the order with Viem

A frontend may create the signature using signTypedData.

const orderTypes = {
  Order: [
    { name: "maker", type: "address" },
    { name: "taker", type: "address" },
    { name: "sellToken", type: "address" },
    { name: "buyToken", type: "address" },
    { name: "sellAmount", type: "uint256" },
    { name: "buyAmount", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" }
  ]
} as const;

const signature = await walletClient.signTypedData({
  account: maker,
  domain: {
    name: "Cabledex",
    version: "1",
    chainId,
    verifyingContract: settlementContract
  },
  primaryType: "Order",
  types: orderTypes,
  message: order
});
Enter fullscreen mode Exit fullscreen mode

The frontend and Solidity definitions must match exactly.

Check:

  • Field names

  • Field order

  • Field types

  • Protocol name and version

  • Chain ID

  • Settlement contract address

Any mismatch will produce a different digest.


A signature does not reserve liquidity

The maker keeps the offered tokens until the trade executes.

That makes order creation cheaper, but it creates an important limitation.

Before settlement, the maker may:

  • spend the tokens;

  • revoke the allowance;

  • transfer the balance elsewhere;

  • sign several orders using the same funds.

A valid signature proves authorization.

It does not prove that the maker still has enough balance and allowance.

A Cabledex interface should therefore simulate the transaction before the taker submits it.


Fixed price and execution slippage

The order contains exact quantities.

For example:

Maker sends: 100,000 TOKEN
Taker sends: 500,000 USDC
Enter fullscreen mode Exit fullscreen mode

The contract does not calculate the price through an AMM pool.

This means the trade has:

No execution slippage relative to the signed order.

However, this does not guarantee that the quote is fair or remains attractive until expiration.

The external market may move after the maker signs the order.

Cabledex enforces the agreed terms. It does not evaluate the economic quality of the deal.


What about MEV?

A standard sandwich attack depends on changing an AMM pool before and after a user’s swap.

A fixed-price Cabledex order does not use the pool to determine its output.

A bot cannot change the signed quantities by moving an external AMM price.

Still, this should not be described as complete MEV protection.

An observer may still:

  • detect the pending settlement;

  • compete for block inclusion;

  • delay the transaction;

  • identify the participating wallets;

  • trade on another venue.

The model protects the fixed execution terms, not the privacy of the transaction.


Important token edge cases

Not every ERC-20 token behaves normally.

A production protocol must consider:

  • fee-on-transfer tokens;

  • rebasing assets;

  • pausable tokens;

  • blacklisting;

  • upgradeable token contracts;

  • nonstandard return values.

For example, a transfer may request 100,000 units while the recipient receives only 98,000.

Possible solutions include:

  • supporting only reviewed tokens;

  • rejecting fee-on-transfer assets;

  • checking balance changes;

  • using token-specific adapters.

Token compatibility should be a documented protocol policy.


Signed orders versus escrow

Signed orders

The maker keeps the assets until execution.

Benefits:

  • No gas cost for unused quotes

  • No locked assets

  • Fast RFQ workflow

Trade-offs:

  • Balance or allowance may disappear

  • Several quotes may use the same funds

Escrowed orders

The maker deposits the offered asset first.

Benefits:

  • Liquidity is reserved

  • Greater certainty for the taker

Trade-offs:

  • Order creation costs gas

  • Funds remain locked

  • Unused orders still create onchain state

Cabledex could use signed orders for short-lived quotes and escrow for larger strategic trades.


Security properties to test

A production version should verify that:

  • A nonce cannot execute twice

  • A cancelled order cannot execute

  • An expired order fails

  • Changing any field invalidates the signature

  • Only the approved taker can fill a private order

  • A signature cannot be replayed on another contract or chain

  • Failed transfers revert the nonce update

  • Reentrancy cannot settle an order twice

  • Smart-contract wallet signatures work correctly

Tests should include malicious token contracts, not only standard ERC-20 mocks.


Final thoughts

EIP-712 allows Cabledex to keep OTC quote creation offchain while preserving onchain verification and settlement.

The complete flow is straightforward:

Create the order
       ↓
Sign it offchain
       ↓
Send it to the taker
       ↓
Verify it onchain
       ↓
Settle both assets atomically
Enter fullscreen mode Exit fullscreen mode

The main benefit is not simply lower gas usage.

It is the ability to separate two responsibilities:

Counterparties determine the price and trade conditions.

The smart contract verifies authorization and executes settlement.

This model can make OTC and RFQ workflows more efficient, but it still requires careful handling of nonces, allowances, token behavior, signatures, and contract security.


Disclosure: This article was prepared with AI assistance and reviewed before publication.

Top comments (0)