Learn how to fulfill (buy) NFT listings on the Immutable Orderbook. This guide covers filling both ERC-721 and ERC-1155 orders, including partial fills and fee handling.
Quick Overview
Fulfilling an order involves these steps:
- Call fulfillOrder() - SDK prepares transaction actions and validates fees
- Execute approval (if needed) - One-time ERC-20 approval for currency
- Execute fulfillment - Submit the buy transaction
- Wait for confirmation - Order status transitions to
FILLED
Understanding Actions
The fulfillOrder() call returns actions that must be executed in order:
| Action Type | Purpose | When Needed |
|---|
APPROVAL | Approve Seaport to spend ERC-20 tokens | First time using that currency, or insufficient allowance |
FULFILL_ORDER | Execute the trade | Always |
const { actions, order, expiration } = await sdk.fulfillOrder(
orderId,
`takerAddress`,
`takerFees` // optional
);
console.log({
actions: actions, // Actions to execute
order: order, // Order with CURRENT fees (may differ from query)
expiration: expiration, // Transaction expires in 3 minutes
});
3-Minute Expiration: Transaction data expires 3 minutes after generation. If the user doesn’t submit within this window, you must call fulfillOrder() again to get fresh data with re-validated fees.
Fee Validation: The order object returned contains the most current fees, which may differ from when you queried the listing (e.g., protocol fee reductions during promotions). Always display the fees from this response to users before they sign.
Filling an ERC-721 Order
ERC-721 orders are all-or-nothing—you must purchase the entire NFT.
Basic Example
import { orderbook } from '@imtbl/sdk';
import { ethers } from 'ethers';
async function buyNFT(orderId: string) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
// 1. Prepare fulfillment
const { actions, order, expiration } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[] // no taker fees for this example
);
console.log('Order expires:', new Date(expiration.toString()));
console.log('Total cost:', order.buy.amount); // Cost in wei
// 2. Execute all actions
for (const action of actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
// Build and send transaction
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
console.log(`Transaction sent: ${txResponse.hash}`);
// Wait for confirmation
const receipt = await txResponse.wait();
console.log(`Confirmed in block ${receipt.blockNumber}`);
}
}
console.log('NFT purchased successfully!');
}
With Taker Fees
Marketplaces can charge taker fees when facilitating purchases:
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[
{
recipientAddress: '0x...', // Marketplace wallet
amount: '10000000000000000', // 0.01 IMX flat fee
},
]
);
Taker Fee Flexibility: Taker fees can be different for each fulfillment. Unlike maker fees (set at listing creation), marketplaces can set custom taker fees per transaction.
Filling an ERC-1155 Order (Partial Fills)
ERC-1155 orders support partial fills—buy any quantity up to the available amount.
Full Fill
Buy all available items:
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[] // no taker fees
// amountToFill not specified = full fill
);
Partial Fill
Buy only some of the available items:
// Listing has 10 items available, buy only 3
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[], // taker fees
{
amountToFill: '3', // Buy 3 of 10 items
}
);
Best-Effort Fulfillment
If you request more items than available, the orderbook attempts a “best-effort” fill:
// Listing has 5 items available, but request 10
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[],
{
amountToFill: '10', // Request 10 items
// Will fill only 5 (max available)
}
);
Best Effort: If amountToFill exceeds available quantity, the orderbook fills up to the maximum available. No error is thrown—you get what’s available.
ERC-1155 Taker Fee Rules
For ERC-1155 orders, taker fees have special rules:
Critical: Taker Fee for Full OrderThe taker fee amount must always reflect the COMPLETE order, even for partial fills. The orderbook automatically pro-rates the fee based on quantity executed.Example:// Listing: 10 items at 1 IMX each = 10 IMX total
// Marketplace wants 1% fee
// ❌ WRONG for partial fill (3 items):
takerFees: [{
amount: '30000000000000000', // 0.03 IMX (scaled for 3 items) - INCORRECT
}]
// CORRECT for any fill (3, 5, or 10 items):
takerFees: [{
amount: '100000000000000000', // 0.1 IMX (1% of 10 IMX total) - CORRECT
}]
// Orderbook will charge: 0.03 IMX for 3 items, 0.05 IMX for 5 items, etc.
Complete ERC-1155 Example
async function buyPartialERC1155(orderId: string, quantityToBuy: string) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
// Get order details first to calculate fee
const { result: order } = await sdk.getListing(orderId);
// Calculate 1% marketplace fee on FULL order value
const fullOrderValue = BigInt(order.buy.amount);
const marketplaceFee = (fullOrderValue * 100n) / 10000n; // 1% in basis points
// Prepare fulfillment
const { actions, order: validatedOrder, expiration } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[
{
recipientAddress: MARKETPLACE_WALLET,
amount: marketplaceFee.toString(), // Full order fee
},
],
{
amountToFill: quantityToBuy, // Partial quantity
}
);
// Show user what they're buying
const itemsAvailable = order.sell.amount;
const itemsToBuy = Math.min(parseInt(quantityToBuy), parseInt(itemsAvailable));
const pricePerItem = fullOrderValue / BigInt(itemsAvailable);
const totalCost = pricePerItem * BigInt(itemsToBuy);
console.log({
itemsToBuy: itemsToBuy,
pricePerItem: ethers.utils.formatEther(pricePerItem),
totalCost: ethers.utils.formatEther(totalCost),
expiresAt: new Date(expiration.toString()),
});
// Execute actions
for (const action of actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
await txResponse.wait();
}
}
console.log(`Purchased ${itemsToBuy} items!`);
}
Approval Handling
For ERC-20 currency listings, buyers must approve Seaport to spend tokens:
const { actions } = await sdk.fulfillOrder(orderId, buyerAddress, []);
for (const action of actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
if (action.purpose === orderbook.TransactionPurpose.APPROVAL) {
console.log('Requesting ERC-20 approval...');
// User approves Seaport to spend their ERC-20 tokens
} else if (action.purpose === orderbook.TransactionPurpose.FULFILL_ORDER) {
console.log('Executing purchase...');
// The actual buy transaction
}
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
await txResponse.wait();
}
}
One-Time Approval: Users only need to approve each ERC-20 currency once (or when allowance is insufficient). Subsequent purchases with the same currency skip the approval step.Native Currency: Listings priced in NATIVE (IMX) never require approval—only a fulfillment transaction.For architectural context, see the Approval Pattern documentation.
Fill Status
After fulfillment, check the fill status to see how much has been filled:
const { result: order } = await sdk.getListing(orderId);
const fillStatus = order.fill_status;
console.log({
status: fillStatus.name, // UNFILLED, PARTIAL, or FILLED
numerator: fillStatus.numerator, // Amount filled
denominator: fillStatus.denominator, // Total amount
percentage: (parseInt(fillStatus.numerator) / parseInt(fillStatus.denominator)) * 100,
});
// Check if completely filled
if (fillStatus.numerator === fillStatus.denominator) {
console.log('Order completely filled!');
} else {
// Calculate remaining quantity (useful for ERC-1155)
const remaining = BigInt(fillStatus.denominator) - BigInt(fillStatus.numerator);
console.log(`Order partially filled, ${remaining} items still available`);
}
| Fill Status | ERC-721 | ERC-1155 Example |
|---|
| Unfilled | 0/0 | 0/10 |
| Partially filled | N/A | 3/10 (30% filled) |
| Fully filled | 1/1 | 10/10 (100% filled) |
Status Transitions: Orders transition from ACTIVE → FILLED asynchronously after the transaction confirms. For partial ERC-1155 fills, status remains ACTIVE until completely filled.
Complete Purchase Flow
Full example with error handling and user feedback:
import { config, orderbook } from '@imtbl/sdk';
import { ethers } from 'ethers';
async function completePurchaseFlow(orderId: string, quantity?: string) {
<SdkInit />
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
try {
// 1. Get order details
const { result: order } = await sdk.getListing(orderId);
console.log('Order Details:', {
seller: order.account_address,
nft: `${order.sell.contract_address}:${order.sell.token_id}`,
price: ethers.utils.formatEther(order.buy.amount),
currency: order.buy.type,
available: order.sell.amount || '1',
});
// 2. Calculate marketplace fee (1%)
const marketplaceFee = {
recipientAddress: MARKETPLACE_FEE_WALLET,
amount: (BigInt(order.buy.amount) * 100n / 10000n).toString(),
};
// 3. Prepare fulfillment
const fulfillmentParams: any = {
orderId,
takerAddress: buyerAddress,
takerFees: [marketplaceFee],
};
if (quantity && order.sell.type === 'ERC1155') {
fulfillmentParams.amountToFill = quantity;
}
const { actions, order: validatedOrder, expiration } =
await sdk.fulfillOrder(...Object.values(fulfillmentParams));
// 4. Warn user about expiration
const expirationDate = new Date(expiration.toString());
const timeLeft = expirationDate.getTime() - Date.now();
if (timeLeft < 60000) {
console.warn('⚠️ Transaction expires in less than 1 minute!');
}
// 5. Display final costs to user
console.log('Final Costs:', {
itemCost: ethers.utils.formatEther(validatedOrder.buy.amount),
protocolFee: ethers.utils.formatEther(
validatedOrder.fees.find(f => f.type === 'PROTOCOL')?.amount || '0'
),
royalty: ethers.utils.formatEther(
validatedOrder.fees.find(f => f.type === 'ROYALTY')?.amount || '0'
),
marketplaceFee: ethers.utils.formatEther(marketplaceFee.amount),
});
// 6. Execute all actions
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
if (action.type === orderbook.ActionType.TRANSACTION) {
console.log(`Step ${i + 1}/${actions.length}:`, action.purpose);
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
console.log(`Transaction sent: ${txResponse.hash}`);
const receipt = await txResponse.wait();
console.log(`Confirmed in block ${receipt.blockNumber}`);
}
}
// 7. Poll for status update
console.log('Waiting for order status to update...');
let attempts = 0;
while (attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
const { result: updatedOrder } = await sdk.getListing(orderId);
if (updatedOrder.status.name === 'FILLED' ||
updatedOrder.fill_status.numerator !== '0') {
console.log('Purchase confirmed!', {
filled: `${updatedOrder.fill_status.numerator}/${updatedOrder.fill_status.denominator}`,
status: updatedOrder.status.name,
});
break;
}
attempts++;
}
return { success: true, orderId };
} catch (error: any) {
console.error('Purchase failed:', error.message);
// Handle common errors
if (error.message.includes('insufficient funds')) {
console.error('❌ Buyer has insufficient balance');
} else if (error.message.includes('FILLED')) {
console.error('❌ Order already filled');
} else if (error.message.includes('expired')) {
console.error('❌ Transaction expired, please retry');
}
throw error;
}
}
Error Handling
Common errors during fulfillment:
| Error | Cause | Solution |
|---|
INSUFFICIENT_FUNDS | Buyer lacks currency | Show balance, request funding |
ORDER_FILLED | Order already purchased | Refresh listing, show “Sold Out” |
ORDER_EXPIRED | Listing expired | Remove from UI |
TRANSACTION_EXPIRED | Took longer than 3 minutes | Call fulfillOrder() again |
APPROVAL_FAILED | User rejected approval | Retry approval step |
INSUFFICIENT_ALLOWANCE | Approval amount too low | Request higher allowance |
Checking Token Balance
Before fulfilling orders, validate that buyers have sufficient funds:
Use ethers.js to check balance:
// Check native currency (IMX) balance
const provider = new ethers.providers.Web3Provider(window.ethereum);
const balance = await provider.getBalance(buyerAddress);
console.log('Balance:', ethers.utils.formatEther(balance), 'IMX');
// Check ERC-20 token balance
const tokenContract = new ethers.Contract(
ERC20_ADDRESS,
['function balanceOf(address) view returns (uint256)'],
provider
);
const tokenBalance = await tokenContract.balanceOf(buyerAddress);
Best Practices
- Show Expiration Timer: Display 3-minute countdown to user
- Validate Balance: Check buyer has sufficient funds before calling
fulfillOrder() (see above)
- Display All Fees: Show protocol fee, royalty, maker fee, taker fee separately
- Handle Partial Fills: For ERC-1155, show available quantity and let users choose amount
- Optimistic UI: Show “Purchase Pending” immediately, update to “Purchased” after confirmation
- Refresh Listings: Poll or use webhooks to detect when orders are filled by others
Next Steps