A production-ready UTXO-based blockchain indexer built with TypeScript, Fastify, and PostgreSQL. This indexer tracks address balances and supports blockchain rollbacks.
- UTXO Model Implementation - Tracks unspent transaction outputs
- Balance Tracking - Real-time balance calculation for all addresses
- Block Validation - Comprehensive validation including height, hash, and transaction integrity
- Rollback Support - Revert blockchain state to any previous height
- Clean Architecture - Separation of concerns with repositories, services, and routes
- Type Safety - Full TypeScript implementation
- Comprehensive Tests - Extensive test coverage for all operations
- Docker Support - Easy deployment with Docker Compose
src/
├── interfaces/
│ └── index.ts # TypeScript interfaces and types
├── database/
│ ├── connection.ts # Database connection pool management
│ ├── schema.ts # Database schema definitions
│ └── repositories/
│ ├── blockRepository.ts # Block data access layer
│ ├── outputRepository.ts # Output (UTXO) data access layer
│ └── balanceRepository.ts # Balance data access layer
├── services/
│ └── blockService.ts # Business logic and validations
├── utils/
│ └── crypto.ts # Cryptographic utilities (SHA-256)
├── routes/
│ └── index.ts # API route definitions
└── server.ts # Application entry point
- Repository Pattern: Database operations are encapsulated in repository classes
- Service Layer: Business logic is separated from route handlers
- Type Safety: Comprehensive TypeScript interfaces for all data structures
- Transaction Management: PostgreSQL transactions ensure data consistency
- Error Handling: Proper validation and error responses
- Node.js 20+
- PostgreSQL 12+ (or use Docker)
- npm or yarn
# Clone and run setup script
git clone <repository-url>
cd blockchain-indexer
chmod +x setup.sh
./setup.shThe setup script will:
- Check Node.js version
- Install dependencies
- Create .env file
- Let you choose Docker or local setup
- Start the services
- Clone the repository
git clone <repository-url>
cd blockchain-indexer- Install dependencies
npm install
npm install dotenv- Install and configure PostgreSQL
# Install PostgreSQL
sudo apt update
sudo apt install -y postgresql postgresql-contrib
# Start PostgreSQL
sudo systemctl start postgresql
sudo systemctl enable postgresql- Configure PostgreSQL authentication
Edit the pg_hba.conf file:
# Find your PostgreSQL version
ls /etc/postgresql/
# Edit configuration (replace XX with your version, e.g., 12)
sudo nano /etc/postgresql/XX/main/pg_hba.confChange authentication methods to trust for local development:
# Database administrative login by Unix domain socket
local all postgres trust
# "local" is for Unix domain socket connections only
local all all trust
# IPv4 local connections:
host all all 127.0.0.1/32 trust
# IPv6 local connections:
host all all ::1/128 trust
Restart PostgreSQL:
sudo systemctl restart postgresql- Create database
sudo -u postgres psql -c "CREATE DATABASE blockchain_indexer;"- Find your PostgreSQL port
sudo -u postgres psql
# Inside psql:
\conninfo
# Note the port number, then exit:
\q- Set up environment variables
Create .env file:
# If using default port 5432
DATABASE_URL=postgresql://postgres@/blockchain_indexer?host=/var/run/postgresql
# If using different port (e.g., 5433)
DATABASE_URL=postgresql://postgres@/blockchain_indexer?host=/var/run/postgresql&port=5433
# Application Configuration
NODE_ENV=development
PORT=3000
API_URL=http://localhost:3000- Update server.ts to load environment variables
Add this import at the very top of src/server.ts:
import 'dotenv/config';- Run in development mode
npm run devThe easiest way to run the entire stack:
# Start both API and PostgreSQL
docker-compose up -d
# Or: make docker-up
# View logs
docker-compose logs -f api
# Or: make docker-logs
# Stop services
docker-compose down
# Or: make docker-down
# Stop and remove volumes (clears database)
docker-compose down -v
# Or: make docker-cleanFor convenience, common commands are available via Makefile:
make help # Show all available commands
make install # Install dependencies
make dev # Run development server
make test # Run tests
make test-coverage # Run tests with coverage
make lint # Lint code
make format # Format code
make docker-up # Start Docker services
make docker-down # Stop Docker services
make db-shell # Connect to PostgreSQLAdd a new block to the blockchain.
Request Body:
{
"id": "block_hash",
"height": 1,
"transactions": [
{
"id": "tx1",
"inputs": [
{
"txId": "previous_tx_id",
"index": 0
}
],
"outputs": [
{
"address": "addr1",
"value": 100
}
]
}
]
}Validations:
- Height must be exactly one unit higher than current height
- Block ID must be
sha256(height + tx1.id + tx2.id + ... + txN.id) - Sum of input values must equal sum of output values (except for coinbase transactions with no inputs)
- Inputs must reference unspent outputs
Response:
{
"success": true
}Error Response (400):
{
"error": "Invalid height. Expected 5, got 3"
}Get the current balance for an address.
Response:
{
"address": "addr1",
"balance": 100
}Rollback the blockchain to a specific height.
Query Parameters:
height(required): Target height to rollback to
Response:
{
"success": true,
"height": 5
}Error Response (400):
{
"error": "Target height is greater than current height"
}The block ID must be the SHA-256 hash of the concatenation of the block height and all transaction IDs.
Formula: sha256(height + tx1.id + tx2.id + ... + txN.id)
# For block 1 with transaction "tx1"
echo -n "1tx1" | sha256sum
# Output: d1582b9e2cac15e170c39ef2e85855ffd7e6a820550a8ca16a2f016d366503dc
# For block 2 with transaction "tx2"
echo -n "2tx2" | sha256sum
# For block 3 with multiple transactions
echo -n "3tx3tx4tx5" | sha256sumCreate hash.sh:
#!/bin/bash
echo -n "$1" | sha256sum | awk '{print $1}'Make it executable and use:
chmod +x hash.sh
./hash.sh "1tx1"
./hash.sh "2tx2"Create calculate_hash.js:
import crypto from 'crypto';
const height = process.argv[2];
const txIds = process.argv.slice(3);
const data = `${height}${txIds.join('')}`;
const hash = crypto.createHash('sha256').update(data).digest('hex');
console.log(hash);Use it:
node calculate_hash.js 1 tx1
node calculate_hash.js 2 tx2
node calculate_hash.js 3 tx3 tx4Block 1: Create initial output
# Calculate hash
echo -n "1tx1" | sha256sum
# Result: d1582b9e2cac15e170c39ef2e85855ffd7e6a820550a8ca16a2f016d366503dc
curl -X POST http://localhost:3000/blocks \
-H "Content-Type: application/json" \
-d '{
"id": "d1582b9e2cac15e170c39ef2e85855ffd7e6a820550a8ca16a2f016d366503dc",
"height": 1,
"transactions": [{
"id": "tx1",
"inputs": [],
"outputs": [{
"address": "alice",
"value": 100
}]
}]
}'Check Alice's balance
curl http://localhost:3000/balance/alice
# Returns: {"address":"alice","balance":100}Block 2: Alice sends 60 to Bob, 40 to Charlie
# Calculate hash
echo -n "2tx2" | sha256sum
# Result: 5fa457accfc342e701f7dfe71c45c6347790f4c75e4cd8ca37e9fccd23e47aa5
curl -X POST http://localhost:3000/blocks \
-H "Content-Type: application/json" \
-d '{
"id": "5fa457accfc342e701f7dfe71c45c6347790f4c75e4cd8ca37e9fccd23e47aa5",
"height": 2,
"transactions": [{
"id": "tx2",
"inputs": [{
"txId": "tx1",
"index": 0
}],
"outputs": [
{"address": "bob", "value": 60},
{"address": "charlie", "value": 40}
]
}]
}'Check balances
curl http://localhost:3000/balance/alice
# Returns: {"address":"alice","balance":0}
curl http://localhost:3000/balance/bob
# Returns: {"address":"bob","balance":60}
curl http://localhost:3000/balance/charlie
# Returns: {"address":"charlie","balance":40}Rollback to block 1
curl -X POST "http://localhost:3000/rollback?height=1"
# Returns: {"success":true,"height":1}Verify rollback worked
curl http://localhost:3000/balance/alice
# Returns: {"address":"alice","balance":100}
curl http://localhost:3000/balance/bob
# Returns: {"address":"bob","balance":0}npm testnpm run test:watchnpm run test:coverageThis project uses Vitest - a blazing fast unit test framework powered by Vite. It provides:
- Native ESM support
- Fast execution with smart watch mode
- Compatible API with Jest
- Great TypeScript support
The test suite includes:
-
Basic Functionality Tests
- Block acceptance
- Sequential block processing
- Multiple transactions per block
-
Validation Tests
- Invalid height rejection
- Block ID verification
- Input/output sum validation
- Double-spending prevention
-
Balance Tests
- Balance tracking
- Multiple transactions
- Change addresses
- Multiple outputs to same address
-
Rollback Tests
- Rollback to specific height
- Balance restoration
- Re-adding blocks after rollback
- Edge cases (height 0, invalid heights)
-
Edge Cases
- Multiple inputs
- Empty transaction lists
- Long transaction chains
- Complex spending patterns
CREATE TABLE blocks (
id TEXT PRIMARY KEY,
height INTEGER UNIQUE NOT NULL
);CREATE TABLE outputs (
tx_id TEXT NOT NULL,
output_index INTEGER NOT NULL,
address TEXT NOT NULL,
value NUMERIC NOT NULL,
block_height INTEGER NOT NULL,
spent BOOLEAN DEFAULT false,
PRIMARY KEY (tx_id, output_index),
FOREIGN KEY (block_height) REFERENCES blocks(height) ON DELETE CASCADE
);CREATE TABLE balances (
address TEXT PRIMARY KEY,
balance NUMERIC NOT NULL DEFAULT 0
);idx_outputs_block_height- For efficient rollback queriesidx_outputs_spent- For finding unspent outputsidx_outputs_address- For address-based queries
npm run buildnpm run lintnpm run formatThe Unspent Transaction Output (UTXO) model is used by Bitcoin and similar blockchains:
-
Transactions have inputs and outputs
- Outputs: Create new UTXOs that assign value to addresses
- Inputs: Reference and spend previous outputs
-
Balance Calculation
- An address's balance = sum of unspent outputs - sum of spent outputs
- When an output is used as input, it's marked as "spent"
-
Transaction Validation
- Inputs must reference existing, unspent outputs
- Sum of input values must equal sum of output values
- This prevents double-spending and ensures conservation of value
-
Example Flow
Block 1: [] -> [Alice: 100] Alice's balance: 100 Block 2: [Alice: 100] -> [Bob: 60, Charlie: 40] Alice's balance: 0 (spent 100) Bob's balance: 60 Charlie's balance: 40 Block 3: [Bob: 60] -> [Dave: 30, Bob: 30] Bob's balance: 30 (spent 60, received 30 change) Dave's balance: 30
If you see "password authentication failed" or "peer authentication failed":
- Edit pg_hba.conf:
sudo nano /etc/postgresql/12/main/pg_hba.conf- Change all
peerandmd5totrust:
local all postgres trust
local all all trust
host all all 127.0.0.1/32 trust
- Restart PostgreSQL:
sudo systemctl restart postgresqlIf PostgreSQL is running on a non-standard port:
- Find your port:
sudo -u postgres psql
\conninfo
\q- Update .env with correct port:
DATABASE_URL=postgresql://postgres@/blockchain_indexer?host=/var/run/postgresql&port=YOUR_PORTIf you see "SASL" or password parsing errors, use Unix socket connection:
DATABASE_URL=postgresql:///blockchain_indexer?host=/var/run/postgresql&port=5433If you see version errors:
# Using NVM (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20
nvm alias default 20If environment variables aren't loading:
npm install dotenvAdd to top of src/server.ts:
import 'dotenv/config';- Database Indexes: Optimized for common queries (balance lookups, UTXO searches)
- Connection Pooling: Efficient database connection management
- Transaction Batching: All block operations in single database transaction
- Rollback Limit: Designed for rollbacks up to 2000 blocks
The API returns appropriate HTTP status codes:
200- Success400- Validation error (invalid block, double-spend, etc.)500- Internal server error
All error responses include a descriptive error message:
{
"error": "Descriptive error message here"
}| Variable | Description | Example |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | postgresql:///blockchain_indexer?host=/var/run/postgresql&port=5433 |
NODE_ENV |
Environment (development/production) | development |
PORT |
API server port | 3000 |
API_URL |
API URL for tests | http://localhost:3000 |
- Follow the existing code structure
- Add tests for new features
- Ensure all tests pass before submitting
- Use TypeScript strict mode
- Follow the repository pattern for database access
MIT
For issues or questions, please open an issue on the repository.
src/
├── types/
│ └── index.ts # TypeScript interfaces and types
├── database/
│ ├── connection.ts # Database connection pool management
│ ├── schema.ts # Database schema definitions
│ └── repositories/
│ ├── blockRepository.ts # Block data access layer
│ ├── outputRepository.ts # Output (UTXO) data access layer
│ └── balanceRepository.ts # Balance data access layer
├── services/
│ └── blockService.ts # Business logic and validations
├── utils/
│ └── crypto.ts # Cryptographic utilities (SHA-256)
├── routes/
│ └── index.ts # API route definitions
└── server.ts # Application entry point
- Repository Pattern: Database operations are encapsulated in repository classes
- Service Layer: Business logic is separated from route handlers
- Type Safety: Comprehensive TypeScript interfaces for all data structures
- Transaction Management: PostgreSQL transactions ensure data consistency
- Error Handling: Proper validation and error responses
- Node.js 20+
- PostgreSQL 16+ (or use Docker)
- npm or yarn
# Clone and run setup script
git clone <repository-url>
cd blockchain-indexer
chmod +x setup.sh
./setup.shThe setup script will:
- ✅ Check Node.js version
- ✅ Install dependencies
- ✅ Create .env file
- ✅ Let you choose Docker or local setup
- ✅ Start the services
- Clone the repository
git clone <repository-url>
cd blockchain-indexer- Install dependencies
npm install- Set up environment variables
# Create .env file
echo "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/blockchain_indexer" > .env- Start PostgreSQL (if not using Docker)
# Using Docker for PostgreSQL only
docker run -d \
--name postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=blockchain_indexer \
-p 5432:5432 \
postgres:16-alpine- Run in development mode
npm run devThe easiest way to run the entire stack:
# Start both API and PostgreSQL
docker-compose up -d
# Or use: make docker-up
# View logs
docker-compose logs -f api
# Or use: make docker-logs
# Stop services
docker-compose down
# Or use: make docker-down
# Stop and remove volumes (clears database)
docker-compose down -v
# Or use: make docker-cleanFor convenience, common commands are available via Makefile:
make help # Show all available commands
make install # Install dependencies
make dev # Run development server
make test # Run tests
make test-coverage # Run tests with coverage
make lint # Lint code
make format # Format code
make docker-up # Start Docker services
make docker-down # Stop Docker services
make db-shell # Connect to PostgreSQLAdd a new block to the blockchain.
Request Body:
{
"id": "block_hash",
"height": 1,
"transactions": [
{
"id": "tx1",
"inputs": [
{
"txId": "previous_tx_id",
"index": 0
}
],
"outputs": [
{
"address": "addr1",
"value": 100
}
]
}
]
}Validations:
- Height must be exactly one unit higher than current height
- Block ID must be
sha256(height + tx1.id + tx2.id + ... + txN.id) - Sum of input values must equal sum of output values (except for coinbase transactions with no inputs)
- Inputs must reference unspent outputs
Response:
{
"success": true
}Error Response (400):
{
"error": "Invalid height. Expected 5, got 3"
}Get the current balance for an address.
Response:
{
"address": "addr1",
"balance": 100
}Rollback the blockchain to a specific height.
Query Parameters:
height(required): Target height to rollback to
Response:
{
"success": true,
"height": 5
}Error Response (400):
{
"error": "Target height is greater than current height"
}# Block 1: Create initial output
curl -X POST http://localhost:3000/blocks \
-H "Content-Type: application/json" \
-d '{
"id": "calculated_hash_here",
"height": 1,
"transactions": [{
"id": "tx1",
"inputs": [],
"outputs": [{
"address": "alice",
"value": 100
}]
}]
}'
# Check Alice's balance
curl http://localhost:3000/balance/alice
# Returns: {"address":"alice","balance":100}
# Block 2: Alice sends 60 to Bob, 40 to Charlie
curl -X POST http://localhost:3000/blocks \
-H "Content-Type: application/json" \
-d '{
"id": "calculated_hash_here",
"height": 2,
"transactions": [{
"id": "tx2",
"inputs": [{
"txId": "tx1",
"index": 0
}],
"outputs": [
{"address": "bob", "value": 60},
{"address": "charlie", "value": 40}
]
}]
}'
# Check balances
curl http://localhost:3000/balance/alice
# Returns: {"address":"alice","balance":0}
curl http://localhost:3000/balance/bob
# Returns: {"address":"bob","balance":60}
# Rollback to block 1
curl -X POST "http://localhost:3000/rollback?height=1"
# Check Alice's balance again
curl http://localhost:3000/balance/alice
# Returns: {"address":"alice","balance":100}npm testnpm run test:watchnpm run test:coverageThis project uses Vitest - a blazing fast unit test framework powered by Vite. It provides:
- Native ESM support
- Fast execution with smart watch mode
- Compatible API with Jest
- Great TypeScript support
The test suite includes:
-
Basic Functionality Tests
- Block acceptance
- Sequential block processing
- Multiple transactions per block
-
Validation Tests
- Invalid height rejection
- Block ID verification
- Input/output sum validation
- Double-spending prevention
-
Balance Tests
- Balance tracking
- Multiple transactions
- Change addresses
- Multiple outputs to same address
-
Rollback Tests
- Rollback to specific height
- Balance restoration
- Re-adding blocks after rollback
- Edge cases (height 0, invalid heights)
-
Edge Cases
- Multiple inputs
- Empty transaction lists
- Long transaction chains
- Complex spending patterns
CREATE TABLE blocks (
id TEXT PRIMARY KEY,
height INTEGER UNIQUE NOT NULL
);CREATE TABLE outputs (
tx_id TEXT NOT NULL,
output_index INTEGER NOT NULL,
address TEXT NOT NULL,
value NUMERIC NOT NULL,
block_height INTEGER NOT NULL,
spent BOOLEAN DEFAULT false,
PRIMARY KEY (tx_id, output_index),
FOREIGN KEY (block_height) REFERENCES blocks(height) ON DELETE CASCADE
);CREATE TABLE balances (
address TEXT PRIMARY KEY,
balance NUMERIC NOT NULL DEFAULT 0
);idx_outputs_block_height- For efficient rollback queriesidx_outputs_spent- For finding unspent outputsidx_outputs_address- For address-based queries
npm run buildnpm run lintnpm run formatThe Unspent Transaction Output (UTXO) model is used by Bitcoin and similar blockchains:
-
Transactions have inputs and outputs
- Outputs: Create new UTXOs that assign value to addresses
- Inputs: Reference and spend previous outputs
-
Balance Calculation
- An address's balance = sum of unspent outputs - sum of spent outputs
- When an output is used as input, it's marked as "spent"
-
Transaction Validation
- Inputs must reference existing, unspent outputs
- Sum of input values must equal sum of output values
- This prevents double-spending and ensures conservation of value
-
Example Flow
Block 1: [] -> [Alice: 100] Alice's balance: 100 Block 2: [Alice: 100] -> [Bob: 60, Charlie: 40] Alice's balance: 0 (spent 100) Bob's balance: 60 Charlie's balance: 40 Block 3: [Bob: 60] -> [Dave: 30, Bob: 30] Bob's balance: 30 (spent 60, received 30 change) Dave's balance: 30
- Database Indexes: Optimized for common queries (balance lookups, UTXO searches)
- Connection Pooling: Efficient database connection management
- Transaction Batching: All block operations in single database transaction
- Rollback Limit: Designed for rollbacks up to 2000 blocks
The API returns appropriate HTTP status codes:
200- Success400- Validation error (invalid block, double-spend, etc.)500- Internal server error
All error responses include a descriptive error message:
{
"error": "Descriptive error message here"
}| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | Required |
NODE_ENV |
Environment (development/production) | development |
- Follow the existing code structure
- Add tests for new features
- Ensure all tests pass before submitting
- Use TypeScript strict mode
- Follow the repository pattern for database access
MIT
For issues or questions, please open an issue on the repository.