diff --git a/land-registry-indexer/.env.example b/land-registry-indexer/.env.example new file mode 100644 index 00000000..61688101 --- /dev/null +++ b/land-registry-indexer/.env.example @@ -0,0 +1,4 @@ +STARTING_BLOCK=0 +LAND_REGISTRY_ADDRESS=0x5a4054a1b1389dcd48b650637977280d32f1ad8b3027bc6c7eb606bf7e28bf5 +DATABASE_URL=postgresql://username:password@localhost:5432/land_registry +APIBARA_URL= \ No newline at end of file diff --git a/land-registry-indexer/Dockerfile b/land-registry-indexer/Dockerfile new file mode 100644 index 00000000..6801bf78 --- /dev/null +++ b/land-registry-indexer/Dockerfile @@ -0,0 +1,11 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +CMD ["npm", "start"] \ No newline at end of file diff --git a/land-registry-indexer/README.md b/land-registry-indexer/README.md new file mode 100644 index 00000000..2f574b8e --- /dev/null +++ b/land-registry-indexer/README.md @@ -0,0 +1,18 @@ +# Land Registry Indexer + +An Apibara indexer for tracking and storing Land Registry smart contract events on StarkNet. This indexer maintains a complete history of land registrations, transfers, verifications, and marketplace activities in a PostgreSQL database. + +## Features + +- Tracks all Land Registry contract events +- Stores event data in a normalized PostgreSQL database +- Maintains relationships between lands, inspectors, and listings +- Provides complete history of all land-related transactions +- Supports Docker deployment + +## Prerequisites + +- Node.js >= 18 +- PostgreSQL >= 14 +- Docker and Docker Compose (for containerized deployment) +- StarkNet node access (via Apibara) diff --git a/land-registry-indexer/docker-compose.yml b/land-registry-indexer/docker-compose.yml new file mode 100644 index 00000000..574edbe8 --- /dev/null +++ b/land-registry-indexer/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + postgres: + image: postgres:14 + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-land_registry} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql + ports: + - "5432:5432" + + indexer: + build: . + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-land_registry} + - LAND_REGISTRY_ADDRESS=${LAND_REGISTRY_ADDRESS} + - STARTING_BLOCK=${STARTING_BLOCK:-0} + - APIBARA_URL=${APIBARA_URL} + depends_on: + - postgres + +volumes: + postgres_data: \ No newline at end of file diff --git a/land-registry-indexer/package.json b/land-registry-indexer/package.json new file mode 100644 index 00000000..0d9eaf63 --- /dev/null +++ b/land-registry-indexer/package.json @@ -0,0 +1,24 @@ +{ + "name": "land-registry-indexer", + "version": "1.0.0", + "description": "Apibara indexer for Land Registry events", + "main": "src/index.ts", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "@apibara/indexer": "^0.3.0", + "@apibara/protocol": "^0.4.0", + "@apibara/starknet": "^0.3.0", + "dotenv": "^16.0.3", + "pg": "^8.11.0", + "typescript": "^5.0.4" + }, + "devDependencies": { + "@types/node": "^20.2.5", + "@types/pg": "^8.10.1", + "ts-node": "^10.9.1" + } +} \ No newline at end of file diff --git a/land-registry-indexer/schema.sql b/land-registry-indexer/schema.sql new file mode 100644 index 00000000..2d26aca6 --- /dev/null +++ b/land-registry-indexer/schema.sql @@ -0,0 +1,100 @@ +CREATE TABLE IF NOT EXISTS lands ( + land_id VARCHAR PRIMARY KEY, + owner_address VARCHAR NOT NULL, + location_latitude NUMERIC, + location_longitude NUMERIC, + area NUMERIC, + land_use VARCHAR, + status VARCHAR, + inspector_address VARCHAR, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS land_transfers ( + id SERIAL PRIMARY KEY, + land_id VARCHAR NOT NULL REFERENCES lands(land_id), + from_address VARCHAR NOT NULL, + to_address VARCHAR NOT NULL, + transaction_hash VARCHAR NOT NULL, + block_number BIGINT NOT NULL, + timestamp TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS land_verifications ( + id SERIAL PRIMARY KEY, + land_id VARCHAR NOT NULL REFERENCES lands(land_id), + inspector_address VARCHAR NOT NULL, + transaction_hash VARCHAR NOT NULL, + block_number BIGINT NOT NULL, + timestamp TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS land_updates ( + id SERIAL PRIMARY KEY, + land_id VARCHAR NOT NULL REFERENCES lands(land_id), + area NUMERIC, + land_use VARCHAR, + transaction_hash VARCHAR NOT NULL, + block_number BIGINT NOT NULL, + timestamp TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS inspectors ( + address VARCHAR PRIMARY KEY, + is_active BOOLEAN DEFAULT true, + added_at TIMESTAMP NOT NULL, + removed_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS inspector_assignments ( + id SERIAL PRIMARY KEY, + land_id VARCHAR NOT NULL REFERENCES lands(land_id), + inspector_address VARCHAR NOT NULL REFERENCES inspectors(address), + transaction_hash VARCHAR NOT NULL, + block_number BIGINT NOT NULL, + timestamp TIMESTAMP NOT NULL +); + +-- CREATE TABLE IF NOT EXISTS fee_updates ( +-- id SERIAL PRIMARY KEY, +-- old_fee NUMERIC NOT NULL, +-- new_fee NUMERIC NOT NULL, +-- transaction_hash VARCHAR NOT NULL, +-- block_number BIGINT NOT NULL, +-- timestamp TIMESTAMP NOT NULL +-- ); + +CREATE TABLE IF NOT EXISTS listings ( + id SERIAL PRIMARY KEY, + land_id VARCHAR NOT NULL REFERENCES lands(land_id), + seller_address VARCHAR NOT NULL, + price NUMERIC NOT NULL, + status VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + transaction_hash VARCHAR NOT NULL, + block_number BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS listing_price_updates ( + id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES listings(id), + old_price NUMERIC NOT NULL, + new_price NUMERIC NOT NULL, + transaction_hash VARCHAR NOT NULL, + block_number BIGINT NOT NULL, + timestamp TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS land_sales ( + id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES listings(id), + land_id VARCHAR NOT NULL REFERENCES lands(land_id), + seller_address VARCHAR NOT NULL, + buyer_address VARCHAR NOT NULL, + price NUMERIC NOT NULL, + transaction_hash VARCHAR NOT NULL, + block_number BIGINT NOT NULL, + timestamp TIMESTAMP NOT NULL +); \ No newline at end of file diff --git a/land-registry-indexer/src/config.ts b/land-registry-indexer/src/config.ts new file mode 100644 index 00000000..59b4d7d5 --- /dev/null +++ b/land-registry-indexer/src/config.ts @@ -0,0 +1,16 @@ +/** + * Configuration settings for the Land Registry Indexer + * + * This module loads environment variables from a .env file and provides + * configuration constants used throughout the application. + */ + +import dotenv from 'dotenv'; +dotenv.config(); + +export const config = { + startingBlock: Number(process.env.STARTING_BLOCK || 0), + landRegistryAddress: process.env.LAND_REGISTRY_ADDRESS || '', + pgConnection: process.env.DATABASE_URL || '', + apibaraUrl: process.env.APIBARA_URL || '', +}; diff --git a/land-registry-indexer/src/eventHandlers.ts b/land-registry-indexer/src/eventHandlers.ts new file mode 100644 index 00000000..e1440bdb --- /dev/null +++ b/land-registry-indexer/src/eventHandlers.ts @@ -0,0 +1,339 @@ +/** + * Event handler functions for the Land Registry smart contract + * + * These functions process events emitted by the contract and store them in PostgreSQL. + * Each handler receives: + * - client: PostgreSQL client for database operations + * - data: Event data from the contract + * - cursor: Block/transaction metadata + * + * The handlers maintain the state of: + * - Land parcels (registration, transfers, verification) + * - Inspectors (adding/removing) + * - Marketplace listings (creation, updates, sales) + * + * All monetary values are stored as strings to preserve precision. + * Addresses are stored as strings in their full StarkNet format. + * Timestamps are converted from Unix seconds to JavaScript Date objects. + */ + +import { PoolClient } from 'pg'; +import { StarkNetCursor } from '@apibara/protocol'; + +export async function handleLandRegistered( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { land_id, owner, location, area, land_use, fee } = data; + + await client.query( + `INSERT INTO lands ( + land_id, owner_address, location_latitude, + location_longitude, area, land_use, status, fee + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + land_id.toString(), + owner.toString(), + location.latitude, + location.longitude, + area.toString(), + land_use, + 'PENDING', + fee.toString() + ] + ); +} + +export async function handleLandTransferred( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { land_id, from_owner, to_owner } = data; + + await client.query( + `INSERT INTO land_transfers ( + land_id, from_address, to_address, + transaction_hash, block_number, timestamp + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + land_id.toString(), + from_owner.toString(), + to_owner.toString(), + cursor.transactionHash, + cursor.blockNumber, + new Date(cursor.timestamp * 1000) + ] + ); + + await client.query( + `UPDATE lands SET owner_address = $1, updated_at = NOW() + WHERE land_id = $2`, + [to_owner.toString(), land_id.toString()] + ); +} + +export async function handleLandVerified( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { land_id, inspector } = data; + + await client.query( + `INSERT INTO land_verifications ( + land_id, inspector_address, transaction_hash, + block_number, timestamp + ) VALUES ($1, $2, $3, $4, $5)`, + [ + land_id.toString(), + inspector.toString(), + cursor.transactionHash, + cursor.blockNumber, + new Date(cursor.timestamp * 1000) + ] + ); + + await client.query( + `UPDATE lands SET + status = 'VERIFIED', + inspector_address = $1, + updated_at = NOW() + WHERE land_id = $2`, + [inspector.toString(), land_id.toString()] + ); +} + +export async function handleLandUpdated( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { land_id, area, land_use } = data; + + await client.query( + `INSERT INTO land_updates ( + land_id, area, land_use, transaction_hash, + block_number, timestamp + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + land_id.toString(), + area.toString(), + land_use, + cursor.transactionHash, + cursor.blockNumber, + new Date(cursor.timestamp * 1000) + ] + ); + + await client.query( + `UPDATE lands SET + area = $1, + land_use = $2, + updated_at = NOW() + WHERE land_id = $3`, + [area.toString(), land_use, land_id.toString()] + ); +} + +export async function handleInspectorAdded( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { inspector } = data; + + await client.query( + `INSERT INTO inspectors ( + address, is_active, added_at + ) VALUES ($1, $2, $3)`, + [ + inspector.toString(), + true, + new Date(cursor.timestamp * 1000) + ] + ); +} + +export async function handleInspectorRemoved( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { inspector } = data; + + await client.query( + `UPDATE inspectors SET + is_active = false, + removed_at = $1 + WHERE address = $2`, + [new Date(cursor.timestamp * 1000), inspector.toString()] + ); +} + +export async function handleLandInspectorSet( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { land_id, inspector } = data; + + await client.query( + `INSERT INTO inspector_assignments ( + land_id, inspector_address, transaction_hash, + block_number, timestamp + ) VALUES ($1, $2, $3, $4, $5)`, + [ + land_id.toString(), + inspector.toString(), + cursor.transactionHash, + cursor.blockNumber, + new Date(cursor.timestamp * 1000) + ] + ); + + await client.query( + `UPDATE lands SET + inspector_address = $1, + updated_at = NOW() + WHERE land_id = $2`, + [inspector.toString(), land_id.toString()] + ); +} + +export async function handleFeeUpdated( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { old_fee, new_fee } = data; + + await client.query( + `INSERT INTO fee_updates ( + old_fee, new_fee, transaction_hash, + block_number, timestamp + ) VALUES ($1, $2, $3, $4, $5)`, + [ + old_fee.toString(), + new_fee.toString(), + cursor.transactionHash, + cursor.blockNumber, + new Date(cursor.timestamp * 1000) + ] + ); +} + +export async function handleListingCreated( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { listing_id, land_id, seller, price } = data; + + await client.query( + `INSERT INTO listings ( + id, land_id, seller_address, price, status, + created_at, updated_at, transaction_hash, block_number + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + listing_id.toString(), + land_id.toString(), + seller.toString(), + price.toString(), + 'ACTIVE', + new Date(cursor.timestamp * 1000), + new Date(cursor.timestamp * 1000), + cursor.transactionHash, + cursor.blockNumber + ] + ); +} + +export async function handleListingPriceUpdated( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { listing_id, old_price, new_price } = data; + + await client.query( + `INSERT INTO listing_price_updates ( + listing_id, old_price, new_price, transaction_hash, + block_number, timestamp + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + listing_id.toString(), + old_price.toString(), + new_price.toString(), + cursor.transactionHash, + cursor.blockNumber, + new Date(cursor.timestamp * 1000) + ] + ); + + await client.query( + `UPDATE listings SET + price = $1, + updated_at = NOW() + WHERE id = $2`, + [new_price.toString(), listing_id.toString()] + ); +} + +export async function handleListingCancelled( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { listing_id } = data; + + await client.query( + `UPDATE listings SET + status = 'CANCELLED', + updated_at = NOW() + WHERE id = $1`, + [listing_id.toString()] + ); +} + +export async function handleLandSold( + client: PoolClient, + data: any, + cursor: StarkNetCursor +) { + const { listing_id, land_id, seller, buyer, price } = data; + + await client.query( + `INSERT INTO land_sales ( + listing_id, land_id, seller_address, buyer_address, + price, transaction_hash, block_number, timestamp + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + listing_id.toString(), + land_id.toString(), + seller.toString(), + buyer.toString(), + price.toString(), + cursor.transactionHash, + cursor.blockNumber, + new Date(cursor.timestamp * 1000) + ] + ); + + await client.query( + `UPDATE listings SET + status = 'SOLD', + updated_at = NOW() + WHERE id = $1`, + [listing_id.toString()] + ); + + await client.query( + `UPDATE lands SET + owner_address = $1, + updated_at = NOW() + WHERE land_id = $2`, + [buyer.toString(), land_id.toString()] + ); +} \ No newline at end of file diff --git a/land-registry-indexer/src/index.ts b/land-registry-indexer/src/index.ts new file mode 100644 index 00000000..c0a11173 --- /dev/null +++ b/land-registry-indexer/src/index.ts @@ -0,0 +1,123 @@ +/** + * Main entry point for the Land Registry Indexer + * + * This script initializes the indexer, connects to the PostgreSQL database, + * and starts processing events from the StarkNet node using Apibara. + + */ + +import { Indexer, IndexerRunner } from '@apibara/indexer'; +import { StarkNetCursor, Filter } from '@apibara/protocol'; +import { Pool } from 'pg'; +import { config } from './config'; +import { + handleLandRegistered, + handleLandTransferred, + handleLandVerified, + handleLandUpdated, + handleInspectorAdded, + handleInspectorRemoved, + handleLandInspectorSet, + handleListingCreated, + handleListingPriceUpdated, + handleListingCancelled, + handleLandSold +} from './eventHandlers'; + +const pool = new Pool({ + connectionString: config.pgConnection, +}); + +class LandRegistryIndexer implements Indexer { + async handleData(cursor: StarkNetCursor, data: any[]): Promise { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + for (const event of data) { + const { name, data: eventData } = event; + + switch (name) { + case 'LandRegistered': + await handleLandRegistered(client, eventData, cursor); + break; + case 'LandTransferred': + await handleLandTransferred(client, eventData, cursor); + break; + case 'LandVerified': + await handleLandVerified(client, eventData, cursor); + break; + case 'LandUpdated': + await handleLandUpdated(client, eventData, cursor); + break; + case 'InspectorAdded': + await handleInspectorAdded(client, eventData, cursor); + break; + case 'InspectorRemoved': + await handleInspectorRemoved(client, eventData, cursor); + break; + case 'LandInspectorSet': + await handleLandInspectorSet(client, eventData, cursor); + break; + + case 'ListingCreated': + await handleListingCreated(client, eventData, cursor); + break; + case 'ListingPriceUpdated': + await handleListingPriceUpdated(client, eventData, cursor); + break; + case 'ListingCancelled': + await handleListingCancelled(client, eventData, cursor); + break; + case 'LandSold': + await handleLandSold(client, eventData, cursor); + break; + } + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + getFilter(): Filter { + return { + header: { weak: true }, + events: [{ + fromAddress: config.landRegistryAddress, + keys: [ + ['LandRegistered'], + ['LandTransferred'], + ['LandVerified'], + ['LandUpdated'], + ['InspectorAdded'], + ['InspectorRemoved'], + ['LandInspectorSet'], + ['FeeUpdated'], + ['ListingCreated'], + ['ListingPriceUpdated'], + ['ListingCancelled'], + ['LandSold'] + ], + }], + }; + } +} + +const runner = new IndexerRunner({ + indexer: new LandRegistryIndexer(), + startingBlock: config.startingBlock, + network: { + url: config.apibaraUrl, + }, +}); + +runner.start().catch((error) => { + console.error('Indexer failed:', error); + process.exit(1); +}); \ No newline at end of file