Development of Virtual Real Estate Marketplace
Virtual real estate is an NFT representing coordinates or a plot in digital space. Decentraland LAND, Sandbox LAND, Otherside Otherdeed are the most well-known. Each has a marketplace for secondary trading, rental, and development. Development of a custom virtual real estate marketplace is an intersection of NFT marketplace infrastructure, on-chain rental mechanics, and spatial data management.
Technical specifics compared to a generic NFT marketplace: parcels have coordinates (x, y), possible adjacency and adjacency bonuses, rental is temporary with rights return, development creates a metadata relationship between LAND NFT and content NFT.
LAND NFT: Data Specifics
Coordinate system on-chain
Each parcel is an NFT with coordinates in a grid. Standard approach: tokenId encodes coordinates.
contract VirtualLand is ERC721 {
struct Parcel {
int256 x;
int256 y;
address tenant; // current renter (if rented)
uint256 leaseExpiry; // timestamp of lease end
string contentURI; // what is built on the parcel
uint8 zoneType; // 0=residential, 1=commercial, 2=plaza
}
mapping(uint256 => Parcel) public parcels;
mapping(int256 => mapping(int256 => uint256)) public coordToTokenId;
// coordToTokenId[x][y] = tokenId
int256 public constant GRID_MIN = -150;
int256 public constant GRID_MAX = 150;
// tokenId = unique index from coordinates
function coordsToTokenId(int256 x, int256 y) public pure returns (uint256) {
// Shift to non-negative values
uint256 ux = uint256(x - GRID_MIN);
uint256 uy = uint256(y - GRID_MIN);
uint256 size = uint256(GRID_MAX - GRID_MIN + 1);
return ux * size + uy;
}
function tokenIdToCoords(uint256 tokenId) public pure returns (int256 x, int256 y) {
uint256 size = uint256(GRID_MAX - GRID_MIN + 1);
x = int256(tokenId / size) + GRID_MIN;
y = int256(tokenId % size) + GRID_MIN;
}
}
Estate: Combined Parcels
Estate = multiple adjacent parcels combined into one asset. This is significant: a large developed plot is worth more than the sum of its parts. Mechanics:
contract EstateRegistry is ERC721 {
struct Estate {
uint256[] parcels; // array of tokenId of included parcels
address landContract;
}
mapping(uint256 => Estate) public estates;
// parcel → estate (if it's part of an estate)
mapping(uint256 => uint256) public parcelToEstate;
function createEstate(uint256[] calldata parcelIds) external returns (uint256 estateId) {
// Check adjacency
require(_areAdjacent(parcelIds), "Parcels must be adjacent");
// Check ownership of all parcels
for (uint i = 0; i < parcelIds.length; i++) {
require(landNft.ownerOf(parcelIds[i]) == msg.sender, "Not owner");
}
estateId = ++_estateIdCounter;
// Transfer parcels to escrow of this contract
for (uint i = 0; i < parcelIds.length; i++) {
landNft.transferFrom(msg.sender, address(this), parcelIds[i]);
parcelToEstate[parcelIds[i]] = estateId;
}
estates[estateId] = Estate({ parcels: parcelIds, landContract: address(landNft) });
_mint(msg.sender, estateId);
}
}
On-chain Rental (Rental Protocol)
Rental of virtual real estate is a significant use case: the owner holds LAND as an investment, the renter uses it for development/events. Key question: how to divide ownership (NFT with owner) and usage rights (with renter)?
ERC-4907: Rentable NFT Standard
ERC-4907 adds a user role to ERC-721 — a temporary user with an expiry timestamp. Contracts can check userOf(tokenId) instead of ownerOf for access.
contract RentalMarketplace {
struct RentalOffer {
uint256 tokenId;
address landContract;
uint256 pricePerDay;
uint256 minDays;
uint256 maxDays;
address paymentToken; // ERC-20 or address(0) for native
bool active;
}
mapping(bytes32 => RentalOffer) public rentalOffers;
function createRentalOffer(
uint256 tokenId,
address landContract,
uint256 pricePerDay,
uint256 minDays,
uint256 maxDays,
address paymentToken
) external {
require(IERC721(landContract).ownerOf(tokenId) == msg.sender, "Not owner");
bytes32 offerId = keccak256(abi.encode(tokenId, landContract, msg.sender, block.timestamp));
rentalOffers[offerId] = RentalOffer({
tokenId: tokenId,
landContract: landContract,
pricePerDay: pricePerDay,
minDays: minDays,
maxDays: maxDays,
paymentToken: paymentToken,
active: true
});
}
function rent(bytes32 offerId, uint256 days) external payable {
RentalOffer storage offer = rentalOffers[offerId];
require(offer.active, "Offer not active");
require(days >= offer.minDays && days <= offer.maxDays, "Invalid duration");
uint256 totalCost = offer.pricePerDay * days;
uint256 expiry = block.timestamp + days * 1 days;
// Payment
if (offer.paymentToken == address(0)) {
require(msg.value >= totalCost, "Insufficient payment");
} else {
IERC20(offer.paymentToken).safeTransferFrom(msg.sender, address(this), totalCost);
}
// Set user via ERC-4907
IERC4907(offer.landContract).setUser(offer.tokenId, msg.sender, uint64(expiry));
// Pay owner (minus protocol fee)
uint256 fee = totalCost * PROTOCOL_FEE_BPS / 10000;
_transferPayment(offer.paymentToken, IERC721(offer.landContract).ownerOf(offer.tokenId), totalCost - fee);
emit Rented(offerId, msg.sender, days, expiry);
}
}
Collateral Rental (without ERC-4907)
If LAND contract doesn't support ERC-4907: temporary NFT transfer with collateral. Renter deposits collateral (equal to or greater than LAND value), NFT is transferred, at the end — automatic return via keeper or manual claim.
Problem: landlord loses physical ownership of NFT during rental (though has the right to return it). Risk: renter sells NFT despite collateral. Solution: NFT is transferred to escrow contract, not the renter.
Marketplace Mechanics
Listing and Auctions
enum SaleType { FIXED_PRICE, ENGLISH_AUCTION, DUTCH_AUCTION }
struct Listing {
uint256 tokenId;
address seller;
SaleType saleType;
address paymentToken;
uint256 startPrice;
uint256 endPrice; // for Dutch auction: final price
uint256 startTime;
uint256 endTime;
uint256 highestBid; // for English auction
address highestBidder;
}
Dutch Auction is particularly relevant for primary sale of LAND: price starts high, automatically decreases to reserve. Eliminates gas wars on mint.
English Auction for secondary market of rare Estates: bidding with outbid protection (minimum bid increase by X%).
Royalties and Fee Structure
ERC-2981 for on-chain royalties. Standard structure for virtual real estate marketplace:
| Fee | Recipient | Amount |
|---|---|---|
| Marketplace fee | Protocol treasury | 2-2.5% |
| Creator royalty | Original metaverse creator | 2.5-5% |
| Referral | If referral program exists | 0.5-1% |
| Seller | LAND owner | Remainder |
Royalties for virtual real estate — controversial topic. Platforms like Blur undermined enforcement. Solution: royalty enforcement via contract (independent of marketplace), or royalty-free model with different type of revenue sharing.
Adjacency Premium and Bundle Pricing
Unique feature of land marketplaces: adjacent parcels are worth more together than separately. Adjacency search algorithm:
function findAdjacentParcels(parcels: Parcel[], targetParcel: Parcel): Parcel[] {
const adjacent: Parcel[] = []
const directions = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,1],[-1,1],[1,-1]]
for (const parcel of parcels) {
for (const [dx, dy] of directions) {
if (parcel.x === targetParcel.x + dx && parcel.y === targetParcel.y + dy) {
adjacent.push(parcel)
break
}
}
}
return adjacent
}
Frontend displays highlighted adjacent plots on the map on hover over one — users see potential bundle purchases.
Spatial Data and Map Interface
Interactive map — the main UI of the marketplace. Requirements: display thousands of parcels with color coding (for sale, for rent, occupied), smooth zoom/pan, click on parcel → detailed information.
Mapbox GL JS / deck.gl — most performant options for spatial rendering of thousands of objects. deck.gl (Uber) optimized for geodata and works with WebGL.
import { DeckGL } from '@deck.gl/react'
import { ScatterplotLayer } from '@deck.gl/layers'
const parcelLayer = new ScatterplotLayer({
data: parcels,
getPosition: (d) => [d.x * PARCEL_SIZE, d.y * PARCEL_SIZE, 0],
getFillColor: (d) => {
if (d.forSale) return [0, 200, 100] // green — for sale
if (d.forRent) return [0, 100, 200] // blue — for rent
if (d.hasContent) return [150, 100, 200] // purple — developed
return [100, 100, 100] // gray — empty
},
getRadius: PARCEL_SIZE / 2,
pickable: true,
onClick: ({ object }) => setSelectedParcel(object),
})
Data Indexing. The Graph subgraph for on-chain events (Transfer, Rented, Listed). PostgreSQL for off-chain metadata and fast spatial queries. Postgis extension for geospatial queries:
-- Find all parcels within radius from coordinate
SELECT * FROM parcels
WHERE ST_DWithin(
ST_MakePoint(x, y)::geometry,
ST_MakePoint($1, $2)::geometry,
$3 -- radius
)
AND for_sale = true;
Content Layer: What is Built on LAND
LAND development — a separate data layer. Standard formats:
Decentraland SDK scene. Babylon.js-based 3D scene. Described in TypeScript, deployed to content server. Tied to LAND coordinates.
GLTF / GLB assets. 3D objects loaded into spatial content. NFTs can represent specific 3D objects (wearables, buildings).
Iframe-based content. Simple web content in VR-overlay. Less immersive, but easy to create.
Content URI is stored in LAND NFT metadata. When development changes, owner updates contentURI via setContentURI(tokenId, newURI). This is an on-chain transaction, history of changes is preserved.
Analytics and Price Discovery
A marketplace without analytics — not competitive. Essential minimum:
- Floor price by zones (residential vs commercial vs plaza adjacency)
- Price history per parcel (via on-chain event indexing)
- Volume by days/weeks
- Activity heatmap: which areas trade more
- Rental yield calculator: annual rental income / current floor price
interface PriceAnalytics {
floorPrice: bigint
avgPrice: bigint
volumeLast7d: bigint
salesCountLast7d: number
priceChange7d: number // %
estateFloorPrice?: bigint // separate floor for Estate
}
async function getZoneAnalytics(zoneId: number): Promise<PriceAnalytics> {
// From subgraph or PostgreSQL
const sales = await db.query(`
SELECT price, timestamp FROM sales
WHERE zone_id = $1 AND timestamp > NOW() - INTERVAL '7 days'
ORDER BY price ASC
`, [zoneId])
return {
floorPrice: sales[0]?.price ?? 0n,
avgPrice: average(sales.map(s => s.price)),
volumeLast7d: sum(sales.map(s => s.price)),
salesCountLast7d: sales.length,
priceChange7d: calculateChange(sales),
}
}
Development Stack
| Component | Technology |
|---|---|
| LAND NFT | Solidity ERC-721 + ERC-4907 |
| Estate contract | Solidity with adjacency validation |
| Rental contract | Solidity + ERC-4907 |
| Marketplace contract | Solidity + ERC-2981 |
| Indexer | The Graph (subgraph) |
| Spatial DB | PostgreSQL + PostGIS |
| Map frontend | deck.gl / Mapbox GL JS + React |
| 3D preview | Three.js / Babylon.js |
| Backend API | Node.js + Fastify |
| Storage | IPFS (Pinata) + Arweave |
Development Process
Product design (1-2 weeks). World map, zoning, primary sale model (Dutch auction?), rental model, fee structure.
Smart contracts (4-6 weeks). LAND NFT with coordinate system, Estate contract with adjacency logic, Rental marketplace with ERC-4907, Sale marketplace with auctions. Audit mandatory.
Backend and indexer (3-4 weeks). Subgraph for events, REST/GraphQL API, spatial queries in PostGIS, price analytics.
Map Frontend (4-6 weeks). Interactive map (deck.gl), parcel detail page, listing and rental UI, analytics dashboard.
3D Content preview (2-3 weeks, optional). GLTF preview for developed parcels, basic 3D viewer.
Testing and launch. End-to-end test of full flow (mint → list → buy → rent → build), load test of map (5000+ parcels in viewport).
MVP without Estate and 3D content — 3-4 months. Full marketplace with Estate, rental, analytics, and 3D preview — 6-8 months.







