Integration guide for buying and listing NFTs on the Otherside MarketplaceOrderRegistry, built on top of LimitBreak's Payment Processor v3.
| 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
The marketplace uses a hybrid model:
- EIP-712 signature (off-chain) — seller signs a
SaleApprovaltyped message authorizing the sale via LimitBreak's Payment Processor - On-chain registry — signed order is submitted to
MarketplaceOrderRegistry.addOrdersToRegistry()for discoverability - 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.
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
Since you don't have a subgraph subscription, there are two approaches:
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);
}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.
Three steps: approve token → sign EIP-712 message → submit to registry.
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,
});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);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,
});| 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 is permissionless — no access control, anyone can call fulfillListing.
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,
});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,
});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.
Only the original maker or a contract admin can cancel.
await walletClient.writeContract({
address: REGISTRY_ADDRESS,
abi: MarketplaceRegistryABI,
functionName: "cancelListing",
args: [orderId],
account: sellerAddress,
});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;| 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 |
- Listing: The
LISTERfeature is open by default, meaning any address can calladdOrdersToRegistry. 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.