Разработка маркетплейса виртуальной недвижимости
Виртуальная недвижимость — NFT, представляющий координаты или участок в цифровом пространстве. Decentraland LAND, Sandbox LAND, Otherside Otherdeed — самые известные. Каждый из них имеет marketplace для вторичной торговли, аренды и застройки. Разработка собственного маркетплейса виртуальной недвижимости — это пересечение NFT marketplace инфраструктуры, on-chain rental механик и spatial data management.
Техническая специфика по сравнению с generic NFT marketplace: парцели имеют координаты (x, y), возможно соседство и adjacency бонусы, аренда временная с возвратом прав, застройка создаёт metadata relationship между LAND NFT и content NFT.
LAND NFT: специфика данных
Coordinate system on-chain
Каждый парцель — NFT с координатами в сетке. Стандартный подход: tokenId кодирует координаты.
contract VirtualLand is ERC721 {
struct Parcel {
int256 x;
int256 y;
address tenant; // текущий арендатор (если сдан)
uint256 leaseExpiry; // timestamp окончания аренды
string contentURI; // что построено на участке
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 = уникальный индекс из координат
function coordsToTokenId(int256 x, int256 y) public pure returns (uint256) {
// Сдвигаем в неотрицательные значения
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: объединённые парцели
Estate = несколько смежных парцелей, объединённых в один актив. Это значимо: большой застроенный участок ценнее суммы частей. Механика:
contract EstateRegistry is ERC721 {
struct Estate {
uint256[] parcels; // массив tokenId входящих парцелей
address landContract;
}
mapping(uint256 => Estate) public estates;
// парцель → estate (если входит в estate)
mapping(uint256 => uint256) public parcelToEstate;
function createEstate(uint256[] calldata parcelIds) external returns (uint256 estateId) {
// Проверяем adjacency
require(_areAdjacent(parcelIds), "Parcels must be adjacent");
// Проверяем ownership всех парцелей
for (uint i = 0; i < parcelIds.length; i++) {
require(landNft.ownerOf(parcelIds[i]) == msg.sender, "Not owner");
}
estateId = ++_estateIdCounter;
// Передаём парцели в escrow этого контракта
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 Protocol)
Аренда виртуальной недвижимости — значимый use case: владелец хранит LAND как инвестицию, арендатор использует для застройки/мероприятий. Ключевой вопрос: как разделить ownership (NFT у владельца) и usage rights (у арендатора)?
ERC-4907: Rentable NFT стандарт
ERC-4907 добавляет роль user к ERC-721 — временный пользователь с expiry timestamp. Контракты могут проверять userOf(tokenId) вместо ownerOf для доступа.
contract RentalMarketplace {
struct RentalOffer {
uint256 tokenId;
address landContract;
uint256 pricePerDay;
uint256 minDays;
uint256 maxDays;
address paymentToken; // ERC-20 или address(0) для 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;
// Оплата
if (offer.paymentToken == address(0)) {
require(msg.value >= totalCost, "Insufficient payment");
} else {
IERC20(offer.paymentToken).safeTransferFrom(msg.sender, address(this), totalCost);
}
// Устанавливаем user через ERC-4907
IERC4907(offer.landContract).setUser(offer.tokenId, msg.sender, uint64(expiry));
// Выплачиваем владельцу (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 (без ERC-4907)
Если LAND контракт не поддерживает ERC-4907: временная передача NFT с collateral. Арендатор вносит залог (равный или больше стоимости LAND), NFT передаётся, по окончании — автоматический возврат через keeper или manual claim.
Проблема: арендодатель теряет физическое владение NFT на время аренды (хотя имеет право вернуть). Риск: арендатор продаёт NFT несмотря на collateral. Решение: NFT передаётся в escrow контракт, не арендатору.
Marketplace механики
Listing и аукционы
enum SaleType { FIXED_PRICE, ENGLISH_AUCTION, DUTCH_AUCTION }
struct Listing {
uint256 tokenId;
address seller;
SaleType saleType;
address paymentToken;
uint256 startPrice;
uint256 endPrice; // для Dutch auction: конечная цена
uint256 startTime;
uint256 endTime;
uint256 highestBid; // для English auction
address highestBidder;
}
Dutch Auction особенно релевантен для первичной продажи LAND: цена начинается высоко, автоматически снижается до резервной. Устраняет газовую войну при mint.
English Auction для вторичного рынка редких Estate: бидинг с outbid защитой (минимальное повышение ставки на X%).
Royalties и fee структура
ERC-2981 для on-chain royalties. Стандартная структура маркетплейса виртуальной недвижимости:
| Fee | Получатель | Размер |
|---|---|---|
| Marketplace fee | Protocol treasury | 2-2.5% |
| Creator royalty | Оригинальный создатель метавселенной | 2.5-5% |
| Referral | Если есть referral program | 0.5-1% |
| Seller | Владелец LAND | Остаток |
Royalties для виртуальной недвижимости — контроверсиальная тема. Платформы типа Blur подорвали enforcement. Решение: royalties enforcement через контракт (не зависит от marketplace), или royalty-free модель с revenue sharing другого типа.
Adjacency premium и bundle pricing
Уникальная черта земельных маркетплейсов: соседние парцели стоят больше вместе, чем порознь. Алгоритм поиска adjacency:
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 отображает на карте выделенные смежные лоты при hover на один — пользователь видит потенциальные bundle покупки.
Spatial Data и Map Interface
Интерактивная карта — основной UI маркетплейса. Требования: отображение тысяч парцелей с цветовой кодировкой (продаётся, сдаётся, занято), плавный zoom/pan, клик на парцель → детальная информация.
Mapbox GL JS / deck.gl — наиболее производительные варианты для spatial rendering тысяч объектов. deck.gl (Uber) оптимизирован для геоданных и работает с 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] // зелёный — на продаже
if (d.forRent) return [0, 100, 200] // синий — в аренду
if (d.hasContent) return [150, 100, 200] // фиолетовый — застроен
return [100, 100, 100] // серый — пусто
},
getRadius: PARCEL_SIZE / 2,
pickable: true,
onClick: ({ object }) => setSelectedParcel(object),
})
Индексирование данных. The Graph subgraph для on-chain событий (Transfer, Rented, Listed). PostgreSQL для off-chain metadata и быстрых spatial queries. Postgis extension для геопространственных запросов:
-- Найти все парцели в радиусе от координаты
SELECT * FROM parcels
WHERE ST_DWithin(
ST_MakePoint(x, y)::geometry,
ST_MakePoint($1, $2)::geometry,
$3 -- radius
)
AND for_sale = true;
Content Layer: что строят на LAND
Застройка LAND — отдельный слой данных. Стандартные форматы:
Decentraland SDK scene. Babylon.js-based 3D сцена. Описывается в TypeScript, деплоится в content server. Привязана к координатам LAND.
GLTF / GLB assets. 3D объекты, загружаемые в spatial content. NFT могут представлять конкретные 3D объекты (wearables, buildings).
iframe-based content. Простой веб-контент в VR-overlay. Менее immersive, но легко создаётся.
Content URI хранится в LAND NFT metadata. При изменении застройки владелец обновляет contentURI через setContentURI(tokenId, newURI). Это on-chain транзакция, история изменений сохраняется.
Аналитика и price discovery
Маркетплейс без аналитики — не конкурентоспособен. Необходимый минимум:
- Floor price по зонам (residential vs commercial vs plaza adjacency)
- Price history per parcel (через on-chain event indexing)
- Volume по дням/неделям
- Heatmap активности: какие районы торгуются больше
- Rental yield calculator: годовой rental income / current floor price
interface PriceAnalytics {
floorPrice: bigint
avgPrice: bigint
volumeLast7d: bigint
salesCountLast7d: number
priceChange7d: number // %
estateFloorPrice?: bigint // отдельный floor для Estate
}
async function getZoneAnalytics(zoneId: number): Promise<PriceAnalytics> {
// Из subgraph или 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),
}
}
Стек разработки
| Компонент | Технология |
|---|---|
| LAND NFT | Solidity ERC-721 + ERC-4907 |
| Estate контракт | Solidity с adjacency validation |
| Rental контракт | Solidity + ERC-4907 |
| Marketplace контракт | 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 |
Процесс разработки
Product design (1-2 недели). Карта мира, зонирование, первичная продажа модель (Dutch auction?), rental модель, fee структура.
Smart contracts (4-6 недель). LAND NFT с координатной системой, Estate контракт с adjacency логикой, Rental marketplace с ERC-4907, Sale marketplace с аукционами. Аудит обязателен.
Backend и indexer (3-4 недели). Subgraph для событий, REST/GraphQL API, spatial queries в PostGIS, price analytics.
Map Frontend (4-6 недель). Интерактивная карта (deck.gl), parcel detail page, listing и rental UI, analytics dashboard.
3D Content preview (2-3 недели, опционально). GLTF preview для застроенных парцелей, basic 3D viewer.
Тестирование и launch. End-to-end тест полного flow (mint → list → buy → rent → build), нагрузочный тест карты (5000+ парцелей в viewport).
MVP без Estate и 3D content — 3-4 месяца. Полный маркетплейс с Estate, rental, аналитикой и 3D preview — 6-8 месяцев.







