How I cut user-facing gas costs on an NFT marketplace by moving listings and bids off-chain with ECDSA signatures, settling only on sale. Covers the hybrid architecture, the storage-packing micro-optimizations, and the trade-offs that come with running off-chain infrastructure.
On-chain NFT auctions are expensive because everything that should be ephemeral (a listing, a bid, a cancellation) gets paid for at on-chain prices. I built an NFT marketplace where the hot path is signed off-chain and only settles on-chain at the moment of sale. This is what came out the other side: roughly an order of magnitude cheaper on the user-facing operations and surprisingly tractable to reason about.
Traditional NFT marketplaces require on-chain operations for every listing, bid, and sale. Each interaction costs gas, and with Ethereum's fluctuating gas prices, this can become prohibitively expensive for users.
Our goal was to create a marketplace that:
We implemented a hybrid architecture that combines:
Instead of storing all auction data on-chain, we use off-chain coordination for:
This data is signed cryptographically and verified on-chain only when necessary.
Using Ethereum's ECDSA signature scheme, we enable:
function verifySignature(
address nftContract,
uint256 tokenId,
uint256 price,
bytes memory signature
) internal view returns (bool) {
bytes32 messageHash = keccak256(
abi.encodePacked(nftContract, tokenId, price)
);
bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
return ethSignedMessageHash.recover(signature) == owner;
}
This allows users to create and sign listings without any on-chain transaction.
Instead of processing each transaction individually, we batch settlements:
Before:
struct Auction {
address seller;
address nftContract;
uint256 tokenId;
uint256 startPrice;
uint256 currentBid;
address currentBidder;
uint256 endTime;
bool active;
}
After:
struct Auction {
address seller; // 160 bits
uint96 currentBid; // 96 bits (fits in same slot)
address nftContract; // 160 bits
uint96 startPrice; // 96 bits (fits in same slot)
uint256 tokenId;
uint32 endTime; // 32 bits
bool active; // 8 bits (packed with endTime)
}
By packing variables deliberately, the struct dropped from 8 slots to 4: a 50% reduction in storage costs per auction.
Replace expensive storage with indexed events for historical data:
event BidPlaced(
address indexed bidder,
uint256 indexed auctionId,
uint256 amount,
uint256 timestamp
);
Our optimizations yielded impressive results:
The win on cost shows up immediately. The cost shows up later: you now have off-chain infrastructure to run, a signature-verification surface that has to be defended at the contract layer, and a signed-listing format that the marketplace and the wallet both have to agree on. The implementation is meaningfully more complex than a vanilla on-chain auction, and the security review has to cover both the on-chain settlement code and the off-chain signing flow. If you're optimizing for engineering simplicity over user gas spend, this is the wrong design. If you're building anything that touches a long-tail of small-value listings, it pays back fast.
Based on our experience, here are key recommendations:
We used Foundry extensively for gas profiling:
forge test --gas-report
This helped us identify expensive operations and track improvements over time.