Skip to content

Instantly share code, notes, and snippets.

@imjameshall
Created February 13, 2026 21:44
Show Gist options
  • Select an option

  • Save imjameshall/10b973115a8dd2f5a52063b04d073f38 to your computer and use it in GitHub Desktop.

Select an option

Save imjameshall/10b973115a8dd2f5a52063b04d073f38 to your computer and use it in GitHub Desktop.
Otherside Marketplace Integration

Otherside Marketplace Integration Guide

Integration guide for buying and listing NFTs on the Otherside MarketplaceOrderRegistry, built on top of LimitBreak's Payment Processor v3.

Contract Addresses

Contract ApeChain Curtis (Testnet)
MarketplaceOrderRegistry 0x0E22dc442f31b423b4Ca2A563D33690d342d9196 0xC94339e93194e3978e81c4cdA5e3Ccce05A26486
Payment Processor (LimitBreak) 0x9a1D00000000fC540e2000560054812452eB5366 0x9a1D00000000fC540e2000560054812452eB5366
Payment Processor Encoder 0x9A1D00C3a699f491037745393a0592AC6b62421D 0x9A1D00C3a699f491037745393a0592AC6b62421D

ApeChain RPC: https://rpc.apechain.com Curtis RPC: https://rpc.curtis.apechain.com

Architecture Overview

The marketplace uses a hybrid model:

  1. EIP-712 signature (off-chain) — seller signs a SaleApproval typed message authorizing the sale via LimitBreak's Payment Processor
  2. On-chain registry — signed order is submitted to MarketplaceOrderRegistry.addOrdersToRegistry() for discoverability
  3. On-chain fulfillment — buyer calls fulfillListing() on the registry, which forwards to Payment Processor via a TrustedForwarder

The registry contract is the order book. The Payment Processor is the settlement engine.

Order Protocols

0 = ERC721_FILL_OR_KILL    — ERC721, must buy the whole listing
1 = ERC1155_FILL_OR_KILL   — ERC1155, must buy the full listed amount
2 = ERC1155_FILL_PARTIAL   — ERC1155, can buy a subset of the listed amount

Reading Orders Without The Graph

Since you don't have a subgraph subscription, there are two approaches:

Approach A: Enumerate On-Chain (simple, but slow for large order books)

import { createPublicClient, http, getContract } from "viem";
import { apechain } from "viem/chains"; // or define custom chain

const REGISTRY_ADDRESS = "0x0E22dc442f31b423b4Ca2A563D33690d342d9196";

const client = createPublicClient({
  chain: apechain,
  transport: http("https://rpc.apechain.com"),
});

const registry = getContract({
  address: REGISTRY_ADDRESS,
  abi: MarketplaceRegistryABI, // see ABI section below
  client,
});

// Get total number of orders ever created
const totalOrders = await registry.read.totalOrders();

// Iterate and filter for active (non-executed, non-expired) orders
for (let i = 0n; i < totalOrders; i++) {
  const [signedOrder, isExecuted] = await registry.read.getSignedOrder([i]);

  if (isExecuted) continue;
  if (signedOrder.saleDetails.expiration <= BigInt(Math.floor(Date.now() / 1000))) continue;

  // This order is active — use it
  console.log(`Order ${i}:`, signedOrder.saleDetails);
}

Approach B: Index OrderAdded / OrderExecuted Events (recommended)

Listen for events and build your own local index. This is the production approach.

// Fetch historical OrderAdded events
const addedLogs = await client.getLogs({
  address: REGISTRY_ADDRESS,
  event: {
    type: "event",
    name: "OrderAdded",
    inputs: [
      { name: "maker", type: "address", indexed: true },
      { name: "idx", type: "uint256", indexed: true },
      { name: "order", type: "tuple", indexed: false, components: [
        { name: "protocol", type: "uint256" },
        { name: "maker", type: "address" },
        { name: "beneficiary", type: "address" },
        { name: "marketplace", type: "address" },
        { name: "fallbackRoyaltyRecipient", type: "address" },
        { name: "paymentMethod", type: "address" },
        { name: "tokenAddress", type: "address" },
        { name: "tokenId", type: "uint256" },
        { name: "amount", type: "uint256" },
        { name: "itemPrice", type: "uint256" },
        { name: "nonce", type: "uint256" },
        { name: "expiration", type: "uint256" },
        { name: "marketplaceFeeNumerator", type: "uint256" },
        { name: "maxRoyaltyFeeNumerator", type: "uint256" },
        { name: "requestedFillAmount", type: "uint256" },
        { name: "minimumFillAmount", type: "uint256" },
        { name: "protocolFeeVersion", type: "uint256" },
      ]},
    ],
  },
  fromBlock: 0n, // use contract deployment block for efficiency
  toBlock: "latest",
});

// Fetch executed orders to filter them out
const executedLogs = await client.getLogs({
  address: REGISTRY_ADDRESS,
  event: {
    type: "event",
    name: "OrderExecuted",
    inputs: [
      { name: "idx", type: "uint256", indexed: true },
    ],
  },
  fromBlock: 0n,
  toBlock: "latest",
});

const executedIds = new Set(executedLogs.map(log => log.args.idx));
const activeOrders = addedLogs.filter(log => !executedIds.has(log.args.idx));

For production, persist the last synced block and only fetch new events incrementally.

Listing Flow (Seller)

Three steps: approve token → sign EIP-712 message → submit to registry.

Step 1: Approve Payment Processor

The seller must approve the Payment Processor to transfer their tokens.

import { erc721Abi, erc1155Abi } from "viem";

const PAYMENT_PROCESSOR = "0x9a1D00000000fC540e2000560054812452eB5366";

// ERC721
await walletClient.writeContract({
  address: tokenAddress,
  abi: erc721Abi,
  functionName: "setApprovalForAll",
  args: [PAYMENT_PROCESSOR, true],
  account: sellerAddress,
});

// ERC1155
await walletClient.writeContract({
  address: tokenAddress,
  abi: erc1155Abi,
  functionName: "setApprovalForAll",
  args: [PAYMENT_PROCESSOR, true],
  account: sellerAddress,
});

Step 2: Sign the EIP-712 Sale Approval

The seller signs a typed data message that authorizes the Payment Processor to execute the trade.

// 1. Read marketplace config
const [
  trustedForwarder,
  feeRecipient,
  marketplaceFeeNumerator,
  minimumListingPrice,
  minimumListingLength,
  supportedOrderProtocolIds,
  supportedPaymentMethods,
] = await registry.read.getMarketplaceParams();

// 2. Read dynamic values
const orderNonce = await registry.read.getMakerNonce([sellerAddress]);
const masterNonce = await publicClient.readContract({
  address: PAYMENT_PROCESSOR,
  abi: PaymentProcessorABI,
  functionName: "masterNonces",
  args: [sellerAddress],
});
const protocolFeeVersion = await publicClient.readContract({
  address: PAYMENT_PROCESSOR,
  abi: PaymentProcessorABI,
  functionName: "getProtocolFeeVersion",
});
const maxRoyaltyFeeNumerator = await registry.read.getMaxRoyaltyFeeNumerator([
  tokenAddress,
  tokenId,
  itemPrice,
]);

// 3. Construct the order message
const order = {
  protocol: isERC1155 ? 2 : 0, // ERC1155_FILL_PARTIAL or ERC721_FILL_OR_KILL
  cosigner: "0x0000000000000000000000000000000000000000",
  seller: sellerAddress,
  marketplace: feeRecipient,
  fallbackRoyaltyRecipient: feeRecipient,
  paymentMethod: paymentMethod, // address(0) for native APE, or ERC20 address
  tokenAddress: tokenAddress,
  tokenId: tokenId,
  amount: amount, // 1n for ERC721, any quantity for ERC1155
  itemPrice: itemPrice, // total price in wei
  expiration: expiration, // unix timestamp
  marketplaceFeeNumerator: marketplaceFeeNumerator,
  maxRoyaltyFeeNumerator: maxRoyaltyFeeNumerator,
  nonce: orderNonce,
  masterNonce: masterNonce,
  protocolFeeVersion: protocolFeeVersion,
};

// 4. EIP-712 domain (Payment Processor, NOT the registry)
const domain = {
  name: "PaymentProcessor",
  version: "3.0.0",
  chainId: 33139n, // ApeChain. Use 33111n for Curtis.
  verifyingContract: "0x9a1D00000000fC540e2000560054812452eB5366",
};

// 5. EIP-712 types
const SaleApprovalTypes = {
  SaleApproval: [
    { name: "protocol", type: "uint8" },
    { name: "cosigner", type: "address" },
    { name: "seller", type: "address" },
    { name: "marketplace", type: "address" },
    { name: "fallbackRoyaltyRecipient", type: "address" },
    { name: "paymentMethod", type: "address" },
    { name: "tokenAddress", type: "address" },
    { name: "tokenId", type: "uint256" },
    { name: "amount", type: "uint256" },
    { name: "itemPrice", type: "uint256" },
    { name: "expiration", type: "uint256" },
    { name: "marketplaceFeeNumerator", type: "uint256" },
    { name: "maxRoyaltyFeeNumerator", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "masterNonce", type: "uint256" },
    { name: "protocolFeeVersion", type: "uint256" },
  ],
};

// 6. Sign
const signature = await walletClient.signTypedData({
  account: sellerAddress,
  domain,
  types: SaleApprovalTypes,
  primaryType: "SaleApproval",
  message: order,
});

// 7. Parse signature into v, r, s
import { parseSignature } from "viem";
const { v, r, s } = parseSignature(signature);

Step 3: Submit Signed Order to Registry

const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000";

const signedOrder = {
  saleDetails: {
    protocol: BigInt(order.protocol),
    maker: sellerAddress,
    beneficiary: ZERO_ADDRESS, // address(0) = anyone can buy. Set specific address to restrict buyer.
    marketplace: feeRecipient,
    fallbackRoyaltyRecipient: feeRecipient,
    paymentMethod: paymentMethod,
    tokenAddress: tokenAddress,
    tokenId: tokenId,
    amount: amount,
    itemPrice: itemPrice,
    nonce: orderNonce,
    expiration: expiration,
    marketplaceFeeNumerator: marketplaceFeeNumerator,
    maxRoyaltyFeeNumerator: maxRoyaltyFeeNumerator,
    requestedFillAmount: amount,
    minimumFillAmount: amount,
    protocolFeeVersion: protocolFeeVersion,
  },
  sellerSignature: { v: BigInt(v), r, s },
  cosignature: {
    signer: ZERO_ADDRESS,
    taker: ZERO_ADDRESS,
    expiration: 0n,
    v: 0n,
    r: ZERO_BYTES32,
    s: ZERO_BYTES32,
  },
  feeOnTop: {
    recipient: ZERO_ADDRESS,
    amount: 0n,
  },
};

// Submit to on-chain registry (this is the on-chain tx)
const txHash = await walletClient.writeContract({
  address: REGISTRY_ADDRESS,
  abi: MarketplaceRegistryABI,
  functionName: "addOrdersToRegistry",
  args: [[signedOrder]],
  account: sellerAddress,
});

Listing Constraints

Constraint Source
maker == msg.sender Only the seller can submit their own listing
marketplace == feeRecipient Order must reference the marketplace's fee recipient
marketplaceFeeNumerator must match contract config Read from getMarketplaceParams()
itemPrice >= minimumListingPrice Read from getMarketplaceParams()
expiration >= block.timestamp + minimumListingLength Read from getMarketplaceParams()
Rate limit: one listing per minTimeBetweenListingsInSeconds (default 5 min) Per-maker cooldown
Seller must own the token and have approved Payment Processor Checked on-chain
feeOnTop must be zero This marketplace doesn't use fee-on-top
ERC1155 partial fill: itemPrice % amount == 0 Price must be evenly divisible

Buying Flow (Buyer)

Buying is permissionless — no access control, anyone can call fulfillListing.

Buy a Full Listing (ERC721 or ERC1155 Fill-or-Kill)

const orderIds = [orderId]; // bigint[] — the on-chain order index

// For native APE payment, attach the exact item price as msg.value
// For ERC20 payment, approve the Payment Processor first, then value = 0
const order = (await registry.read.getSignedOrder([orderId]))[0];
const isNativePayment = order.saleDetails.paymentMethod === ZERO_ADDRESS;

const txHash = await walletClient.writeContract({
  address: REGISTRY_ADDRESS,
  abi: MarketplaceRegistryABI,
  functionName: "fulfillListing",
  args: [buyerAddress, orderIds],
  value: isNativePayment ? order.saleDetails.itemPrice : 0n,
  account: buyerAddress,
});

Buy a Partial ERC1155 Listing

Only works for orders with protocol 2 (ERC1155_FILL_PARTIAL).

const requestedQuantity = 5n; // how many tokens to buy

// Calculate proportional payment
const unitPrice = order.saleDetails.itemPrice / order.saleDetails.amount;
const totalPayment = unitPrice * requestedQuantity;

const txHash = await walletClient.writeContract({
  address: REGISTRY_ADDRESS,
  abi: MarketplaceRegistryABI,
  functionName: "partiallyFulfillListing",
  args: [buyerAddress, [orderId], [requestedQuantity]],
  value: isNativePayment ? totalPayment : 0n,
  account: buyerAddress,
});

Buying with ERC20 (e.g., ApeUSD)

If the listing's paymentMethod is an ERC20, the buyer must approve the Payment Processor to spend the token before calling fulfillListing:

import { erc20Abi } from "viem";

await walletClient.writeContract({
  address: paymentMethodAddress, // the ERC20 token
  abi: erc20Abi,
  functionName: "approve",
  args: [PAYMENT_PROCESSOR, itemPrice],
  account: buyerAddress,
});

The registry also has a built-in swapAPEToApeUSD() function. When buying an ApeUSD-denominated listing, if the buyer doesn't have enough ApeUSD, the contract will auto-swap native APE to ApeUSD via Camelot DEX. The buyer just needs to send enough native APE as msg.value.

Cancelling a Listing

Only the original maker or a contract admin can cancel.

await walletClient.writeContract({
  address: REGISTRY_ADDRESS,
  abi: MarketplaceRegistryABI,
  functionName: "cancelListing",
  args: [orderId],
  account: sellerAddress,
});

Minimal ABI

const MarketplaceRegistryABI = [
  // Views
  {
    type: "function",
    name: "getMarketplaceParams",
    inputs: [],
    outputs: [
      { name: "_trustedForwarder", type: "address" },
      { name: "_feeRecipient", type: "address" },
      { name: "_marketplaceFeeNumerator", type: "uint256" },
      { name: "_minimumListingPrice", type: "uint256" },
      { name: "_minimumListingLength", type: "uint256" },
      { name: "_supportedOrderProtocolIds", type: "uint256[]" },
      { name: "_supportedPaymentMethods", type: "address[]" },
    ],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "getMakerNonce",
    inputs: [{ name: "maker", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "getMaxRoyaltyFeeNumerator",
    inputs: [
      { name: "tokenAddress", type: "address" },
      { name: "tokenId", type: "uint256" },
      { name: "itemPrice", type: "uint256" },
    ],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "totalOrders",
    inputs: [],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "getSignedOrder",
    inputs: [{ name: "orderId", type: "uint256" }],
    outputs: [
      {
        name: "signedOrder",
        type: "tuple",
        components: [
          {
            name: "saleDetails",
            type: "tuple",
            components: [
              { name: "protocol", type: "uint256" },
              { name: "maker", type: "address" },
              { name: "beneficiary", type: "address" },
              { name: "marketplace", type: "address" },
              { name: "fallbackRoyaltyRecipient", type: "address" },
              { name: "paymentMethod", type: "address" },
              { name: "tokenAddress", type: "address" },
              { name: "tokenId", type: "uint256" },
              { name: "amount", type: "uint256" },
              { name: "itemPrice", type: "uint256" },
              { name: "nonce", type: "uint256" },
              { name: "expiration", type: "uint256" },
              { name: "marketplaceFeeNumerator", type: "uint256" },
              { name: "maxRoyaltyFeeNumerator", type: "uint256" },
              { name: "requestedFillAmount", type: "uint256" },
              { name: "minimumFillAmount", type: "uint256" },
              { name: "protocolFeeVersion", type: "uint256" },
            ],
          },
          {
            name: "sellerSignature",
            type: "tuple",
            components: [
              { name: "v", type: "uint256" },
              { name: "r", type: "bytes32" },
              { name: "s", type: "bytes32" },
            ],
          },
          {
            name: "cosignature",
            type: "tuple",
            components: [
              { name: "signer", type: "address" },
              { name: "taker", type: "address" },
              { name: "expiration", type: "uint256" },
              { name: "v", type: "uint256" },
              { name: "r", type: "bytes32" },
              { name: "s", type: "bytes32" },
            ],
          },
          {
            name: "feeOnTop",
            type: "tuple",
            components: [
              { name: "recipient", type: "address" },
              { name: "amount", type: "uint256" },
            ],
          },
        ],
      },
      { name: "isOrderExecuted", type: "bool" },
    ],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "totalFilledForOrder",
    inputs: [{ name: "orderId", type: "uint256" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "PAYMENT_PROCESSOR",
    inputs: [],
    outputs: [{ name: "", type: "address" }],
    stateMutability: "view",
  },
  // Writes
  {
    type: "function",
    name: "addOrdersToRegistry",
    inputs: [
      {
        name: "orders",
        type: "tuple[]",
        components: [
          {
            name: "saleDetails",
            type: "tuple",
            components: [
              { name: "protocol", type: "uint256" },
              { name: "maker", type: "address" },
              { name: "beneficiary", type: "address" },
              { name: "marketplace", type: "address" },
              { name: "fallbackRoyaltyRecipient", type: "address" },
              { name: "paymentMethod", type: "address" },
              { name: "tokenAddress", type: "address" },
              { name: "tokenId", type: "uint256" },
              { name: "amount", type: "uint256" },
              { name: "itemPrice", type: "uint256" },
              { name: "nonce", type: "uint256" },
              { name: "expiration", type: "uint256" },
              { name: "marketplaceFeeNumerator", type: "uint256" },
              { name: "maxRoyaltyFeeNumerator", type: "uint256" },
              { name: "requestedFillAmount", type: "uint256" },
              { name: "minimumFillAmount", type: "uint256" },
              { name: "protocolFeeVersion", type: "uint256" },
            ],
          },
          {
            name: "sellerSignature",
            type: "tuple",
            components: [
              { name: "v", type: "uint256" },
              { name: "r", type: "bytes32" },
              { name: "s", type: "bytes32" },
            ],
          },
          {
            name: "cosignature",
            type: "tuple",
            components: [
              { name: "signer", type: "address" },
              { name: "taker", type: "address" },
              { name: "expiration", type: "uint256" },
              { name: "v", type: "uint256" },
              { name: "r", type: "bytes32" },
              { name: "s", type: "bytes32" },
            ],
          },
          {
            name: "feeOnTop",
            type: "tuple",
            components: [
              { name: "recipient", type: "address" },
              { name: "amount", type: "uint256" },
            ],
          },
        ],
      },
    ],
    outputs: [],
    stateMutability: "nonpayable",
  },
  {
    type: "function",
    name: "fulfillListing",
    inputs: [
      { name: "beneficiary", type: "address" },
      { name: "orderIds", type: "uint256[]" },
    ],
    outputs: [],
    stateMutability: "payable",
  },
  {
    type: "function",
    name: "partiallyFulfillListing",
    inputs: [
      { name: "beneficiary", type: "address" },
      { name: "orderIds", type: "uint256[]" },
      { name: "requestedFillAmounts", type: "uint256[]" },
    ],
    outputs: [],
    stateMutability: "payable",
  },
  {
    type: "function",
    name: "cancelListing",
    inputs: [{ name: "orderId", type: "uint256" }],
    outputs: [],
    stateMutability: "nonpayable",
  },
  // Events
  {
    type: "event",
    name: "OrderAdded",
    inputs: [
      { name: "maker", type: "address", indexed: true },
      { name: "idx", type: "uint256", indexed: true },
      {
        name: "order",
        type: "tuple",
        indexed: false,
        components: [
          { name: "protocol", type: "uint256" },
          { name: "maker", type: "address" },
          { name: "beneficiary", type: "address" },
          { name: "marketplace", type: "address" },
          { name: "fallbackRoyaltyRecipient", type: "address" },
          { name: "paymentMethod", type: "address" },
          { name: "tokenAddress", type: "address" },
          { name: "tokenId", type: "uint256" },
          { name: "amount", type: "uint256" },
          { name: "itemPrice", type: "uint256" },
          { name: "nonce", type: "uint256" },
          { name: "expiration", type: "uint256" },
          { name: "marketplaceFeeNumerator", type: "uint256" },
          { name: "maxRoyaltyFeeNumerator", type: "uint256" },
          { name: "requestedFillAmount", type: "uint256" },
          { name: "minimumFillAmount", type: "uint256" },
          { name: "protocolFeeVersion", type: "uint256" },
        ],
      },
    ],
  },
  {
    type: "event",
    name: "OrderExecuted",
    inputs: [{ name: "idx", type: "uint256", indexed: true }],
  },
] as const;

// Minimal Payment Processor ABI (only what you need for listing)
const PaymentProcessorABI = [
  {
    type: "function",
    name: "masterNonces",
    inputs: [{ name: "", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
  {
    type: "function",
    name: "getProtocolFeeVersion",
    inputs: [],
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "view",
  },
] as const;

Common Errors

Error Cause
InvalidMaker msg.sender doesn't match order.saleDetails.maker
InvalidMarketplaceFeeRecipient marketplace field doesn't match contract's feeRecipient
InvalidMarketplaceFee marketplaceFeeNumerator doesn't match contract config
MinimumPriceTooLow itemPrice is below minimumListingPrice
ListingExpiresTooSoon Expiration is less than block.timestamp + minimumListingLength
InvalidMakerNonce Nonce doesn't match getMakerNonce() — refetch it
InvalidProtocolFeeVersion Stale protocolFeeVersion — refetch from Payment Processor
InvalidTokenApproval Seller hasn't approved Payment Processor for the token
InvalidOwner Seller doesn't own the token (or insufficient ERC1155 balance)
OrderAlreadyOnchain Duplicate order submission
RateLimitForAddingOrdersReached Must wait minTimeBetweenListingsInSeconds (default 5 min) between listings
OrderAlreadyExecuted Trying to buy or cancel an already-executed order
NotEnoughValueLeft Insufficient msg.value for native payment
ListingPriceNotDivisible ERC1155 partial fill: itemPrice must be divisible by amount
FeeOnTopNotSupported feeOnTop must have zero amount and zero-address recipient

Access Control Notes

  • Listing: The LISTER feature is open by default, meaning any address can call addOrdersToRegistry. However, orders are validated against marketplace config (fee recipient, fee numerator, etc.), so orders must be constructed with correct marketplace parameters.
  • Buying: Fully permissionless. No access control on fulfillListing / partiallyFulfillListing.
  • Cancelling: Only the original maker or a contract admin can cancel.
  • Rate limiting: There is a per-maker cooldown of minTimeBetweenListingsInSeconds (default 5 minutes) between listing submissions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment