Skip to main content
Approve the asset token, then call createOffer to escrow collateral and register the offer atomically.

Prerequisites

Before calling createOffer, you must grant the OTCVenue contract an ERC-20 allowance for at least assetAmount of the token you intend to sell. Without this approval the contract’s transferFrom call will revert.
IERC20(asset).approve(VENUE_ADDRESS, assetAmount);
For fee-on-transfer tokens, approve the nominal amount you want to deposit. The venue measures the actual balance change after the transfer and escrows only what it receives. The assetAmount you pass sets the ceiling, not the guaranteed escrow balance.

Function Signature

function createOffer(
    address asset,
    address quote,
    uint256 assetAmount,
    uint256 pricePerUnit,
    uint64 expiry,
    uint64 unlockTime,
    address allowedBuyer
) external returns (uint256 offerId);

Parameters

asset
address
required
The ERC-20 token you are selling. This token must be approved for transfer to the venue contract before calling createOffer. Cannot be address(0) and cannot be the same as quote.
quote
address
required
The ERC-20 token you will accept as payment. Buyers must hold and approve this token to fill your offer. Cannot be address(0).
assetAmount
uint256
required
The total amount of collateral to deposit, in the asset token’s native decimals. Must be greater than zero. For standard ERC-20 tokens this equals the amount escrowed. For fee-on-transfer tokens, the actual escrowed amount may be less because the contract records the net balance received.
pricePerUnit
uint256
required
The quote-token cost per one whole unit of the asset token. One “whole unit” equals 10^assetDecimals base units. Must be greater than zero.For example, to sell a token with 18 decimals at a price of 0.43 USDC (6 decimals) per token:
  • One whole unit = 10^18 base units of the asset
  • pricePerUnit = 430000 (0.43 × 10^6 USDC base units)
The quote amount charged for any fill is calculated as:
quoteCost = mulDiv(assetUnits, pricePerUnit, 10^assetDecimals)
expiry
uint64
required
Unix timestamp after which the offer can no longer be filled. Must be in the future (expiry > block.timestamp). For deferred offers, expiry must also be less than or equal to unlockTime.
unlockTime
uint64
required
Controls the settlement mode of the offer:
  • 0 — Spot offer. Assets transfer directly to the buyer at fill time.
  • Non-zero Unix timestamp — Deferred forward offer. Assets remain in escrow until the unlock time; buyers receive ERC-20 claim tokens at fill time and redeem them for the underlying collateral after the unlock timestamp. Must be in the future and must satisfy expiry <= unlockTime.
allowedBuyer
address
required
Controls who can fill the offer:
  • address(0) — Public offer; any address may fill.
  • Specific address — Private bilateral offer; only that address may fill.

Return Value

offerId
uint256
A sequential identifier for the newly created offer, assigned from the venue’s nextOfferId counter. Store this value — you will need it to fill, query, or cancel the offer.

Events

The venue emits the following event upon successful offer creation:
event OfferCreated(
    uint256 indexed offerId,
    address indexed seller,
    address indexed asset,
    address quote,
    uint256 requestedAssetAmount,
    uint256 escrowedAssetAmount,
    uint256 pricePerUnit,
    uint64 expiry,
    uint64 unlockTime,
    address allowedBuyer
);

Validation Rules

createOffer will revert with InvalidParams() if any of the following are true:
  • asset is address(0)
  • quote is address(0)
  • asset == quote
  • assetAmount == 0
  • pricePerUnit == 0
  • expiry <= block.timestamp (expired or current block)
  • unlockTime != 0 and unlockTime <= block.timestamp (deferred but already past)
  • unlockTime != 0 and expiry > unlockTime (fills accepted after unlock)
  • Asset token’s decimals() > 77
Additionally, the call reverts with ZeroReceived() if the actual amount received by the venue after the transfer is zero.

Example: Creating a Public Spot Offer

The following TypeScript snippet uses ethers.js v6 to approve collateral and create a public spot offer selling 500,000 TECH tokens at 0.43 USDC each.
import { ethers } from "ethers";

const VENUE_ADDRESS = "0xYourVenueAddress";
const TECH_ADDRESS  = "0xTechTokenAddress";
const USDC_ADDRESS  = "0xUsdcTokenAddress";

const assetAmount = ethers.parseUnits("500000", 18);

// Price: 0.43 USDC per token. USDC has 6 decimals, so:
// pricePerUnit = 0.43 * 10^6 = 430000
const pricePerUnit = 430000n;

// Expiry: 7 days from now
const expiry = BigInt(Math.floor(Date.now() / 1000) + 7 * 86400);

// Step 1: Approve collateral transfer
const assetToken = new ethers.Contract(TECH_ADDRESS, ERC20_ABI, signer);
await assetToken.approve(VENUE_ADDRESS, assetAmount);

// Step 2: Create a public spot offer
const venue = new ethers.Contract(VENUE_ADDRESS, VENUE_ABI, signer);
const tx = await venue.createOffer(
  TECH_ADDRESS,                    // asset
  USDC_ADDRESS,                    // quote
  assetAmount,                     // assetAmount
  pricePerUnit,                    // pricePerUnit (0.43 USDC)
  expiry,                          // expiry: 7 days from now
  0n,                              // unlockTime: 0 = immediate spot
  ethers.ZeroAddress               // allowedBuyer: open to everyone
);

const receipt = await tx.wait();
console.log("Offer created. Transaction:", receipt.hash);
import { ethers } from "ethers";

const BUYER_ADDRESS = "0xBuyerWalletAddress";

// Same approve step as above, then:
const tx = await venue.createOffer(
  TECH_ADDRESS,
  USDC_ADDRESS,
  ethers.parseUnits("500000", 18),
  430000n,                           // pricePerUnit
  BigInt(Math.floor(Date.now() / 1000) + 7 * 86400), // expiry
  0n,                                // unlockTime: spot
  BUYER_ADDRESS                      // allowedBuyer: only this address can fill
);
const receipt = await tx.wait();
import { ethers } from "ethers";

const now = Math.floor(Date.now() / 1000);
const expiry = BigInt(now + 30 * 86400);      // accept fills for 30 days
const unlockTime = BigInt(now + 90 * 86400);   // claims redeemable after 90 days

const tx = await venue.createOffer(
  TECH_ADDRESS,
  USDC_ADDRESS,
  ethers.parseUnits("500000", 18),
  430000n,
  expiry,
  unlockTime,                       // deferred: claims unlock in 90 days
  ethers.ZeroAddress                // public
);
const receipt = await tx.wait();
For private bilateral offers, set allowedBuyer to the buyer’s wallet address instead of address(0). Only that address will be able to call fill on your offer — all other callers will revert with NotAllowedBuyer().
Once createOffer succeeds, the collateral is escrowed in the venue contract and is no longer in your wallet. You can only recover it by calling cancel, which returns the unfilled portion of the escrow. Any portion that has already been filled is settled and cannot be reversed.
The fee rate and fee recipient are snapshotted into the offer at creation time. Even if the admin changes the venue’s fee rate later, your offer’s fills will always use the rate that was active when you created the offer.