diff --git a/docker-compose.yaml b/docker-compose.yaml index 814b825..dc94d75 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: image: postgres:15 @@ -10,7 +8,7 @@ services: volumes: - db_data:/var/lib/postgresql/data ports: - - "5432:5432" + - "5433:5432" api: build: . diff --git a/package.json b/package.json index ee783b2..ec9ba25 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/node": "^20.0.0", "@types/pg": "^8.11.8" }, "peerDependencies": { diff --git a/spec/index.spec.ts b/spec/index.spec.ts index 1aade10..2f37392 100644 --- a/spec/index.spec.ts +++ b/spec/index.spec.ts @@ -1,5 +1,610 @@ -import { expect, test } from "bun:test"; +import { expect, test, describe, beforeAll, afterAll, beforeEach } from "bun:test"; +import { createHash } from "crypto"; +import { Pool } from "pg"; +import type { Block, Transaction } from "../src/types"; -test('2 + 2', () => { - expect(2 + 2).toBe(4); +// Test configuration +const TEST_DATABASE_URL = process.env.DATABASE_URL || "postgres://myuser:mypassword@localhost:5432/mydatabase"; +const API_URL = "http://localhost:3000"; + +let pool: Pool; + +// Helper function to calculate block hash +function calculateBlockHash(block: Block): string { + const data = block.height + block.transactions.map(tx => tx.id).join(''); + return createHash('sha256').update(data).digest('hex'); +} + +// Helper function to create a valid block +function createBlock(height: number, transactions: Transaction[]): Block { + const block = { + id: '', + height, + transactions + }; + block.id = calculateBlockHash(block); + return block; +} + +// Clean database before tests +async function cleanDatabase() { + await pool.query('DELETE FROM outputs'); + await pool.query('DELETE FROM transactions'); + await pool.query('DELETE FROM blocks'); +} + +beforeAll(async () => { + pool = new Pool({ connectionString: TEST_DATABASE_URL }); + // Wait for API to be ready + await new Promise(resolve => setTimeout(resolve, 2000)); +}); + +afterAll(async () => { + await pool.end(); +}); + +beforeEach(async () => { + await cleanDatabase(); +}); + +describe('POST /blocks', () => { + test('should accept a valid first block (height 1) with coinbase transaction', async () => { + const block = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block) + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.height).toBe(1); + }); + + test('should reject block with incorrect height', async () => { + const block = createBlock(5, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block) + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain('Invalid height'); + }); + + test('should reject block with invalid hash', async () => { + const block = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + block.id = 'invalid_hash'; + + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block) + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain('Invalid block ID'); + }); + + test('should reject transaction with mismatched input/output sums', async () => { + // First, add a valid block + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + // Try to spend 10 but output 15 + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [{ address: 'addr2', value: 15 }] + }]); + + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain('mismatched input/output sums'); + }); + + test('should reject transaction referencing non-existent output', async () => { + const block = createBlock(1, [{ + id: 'tx1', + inputs: [{ txId: 'nonexistent', index: 0 }], + outputs: [{ address: 'addr1', value: 10 }] + }]); + + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block) + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain('non-existent output'); + }); + + test('should reject transaction spending already spent output', async () => { + // Block 1: coinbase + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + // Block 2: spend tx1 output + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [{ address: 'addr2', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + + // Block 3: try to spend tx1 output again (double spend) + const block3 = createBlock(3, [{ + id: 'tx3', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [{ address: 'addr3', value: 10 }] + }]); + + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block3) + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain('already spent'); + }); + + test('should process valid sequential blocks correctly', async () => { + // Block 1 + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + const res1 = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + expect(res1.status).toBe(200); + + // Block 2 + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [ + { address: 'addr2', value: 4 }, + { address: 'addr3', value: 6 } + ] + }]); + const res2 = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + expect(res2.status).toBe(200); + }); +}); + +describe('GET /balance/:address', () => { + test('should return 0 for address with no transactions', async () => { + const response = await fetch(`${API_URL}/balance/addr_unknown`); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.balance).toBe(0); + }); + + test('should return correct balance after receiving funds', async () => { + // Add block with coinbase + const block = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block) + }); + + const response = await fetch(`${API_URL}/balance/addr1`); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.address).toBe('addr1'); + expect(data.balance).toBe(10); + }); + + test('should return correct balance after spending funds', async () => { + // Block 1: addr1 receives 10 + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + // Block 2: addr1 spends 10, addr2 and addr3 receive + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [ + { address: 'addr2', value: 4 }, + { address: 'addr3', value: 6 } + ] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + + // Check balances + const res1 = await fetch(`${API_URL}/balance/addr1`); + const data1 = await res1.json(); + expect(data1.balance).toBe(0); + + const res2 = await fetch(`${API_URL}/balance/addr2`); + const data2 = await res2.json(); + expect(data2.balance).toBe(4); + + const res3 = await fetch(`${API_URL}/balance/addr3`); + const data3 = await res3.json(); + expect(data3.balance).toBe(6); + }); + + test('should handle complex transaction scenario from README example', async () => { + // Block 1 + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + // Block 2 + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [ + { address: 'addr2', value: 4 }, + { address: 'addr3', value: 6 } + ] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + + // Block 3 + const block3 = createBlock(3, [{ + id: 'tx3', + inputs: [{ txId: 'tx2', index: 1 }], + outputs: [ + { address: 'addr4', value: 2 }, + { address: 'addr5', value: 2 }, + { address: 'addr6', value: 2 } + ] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block3) + }); + + // Verify final balances + const checks = [ + { addr: 'addr1', expected: 0 }, + { addr: 'addr2', expected: 4 }, + { addr: 'addr3', expected: 0 }, + { addr: 'addr4', expected: 2 }, + { addr: 'addr5', expected: 2 }, + { addr: 'addr6', expected: 2 } + ]; + + for (const check of checks) { + const response = await fetch(`${API_URL}/balance/${check.addr}`); + const data = await response.json(); + expect(data.balance).toBe(check.expected); + } + }); +}); + +describe('POST /rollback', () => { + test('should rollback to specified height', async () => { + // Add 3 blocks + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [ + { address: 'addr2', value: 4 }, + { address: 'addr3', value: 6 } + ] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + + const block3 = createBlock(3, [{ + id: 'tx3', + inputs: [{ txId: 'tx2', index: 1 }], + outputs: [ + { address: 'addr4', value: 2 }, + { address: 'addr5', value: 4 } + ] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block3) + }); + + // Rollback to height 2 + const response = await fetch(`${API_URL}/rollback?height=2`, { + method: 'POST' + }); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.height).toBe(2); + + // Verify balances after rollback + const res1 = await fetch(`${API_URL}/balance/addr1`); + const data1 = await res1.json(); + expect(data1.balance).toBe(0); + + const res2 = await fetch(`${API_URL}/balance/addr2`); + const data2 = await res2.json(); + expect(data2.balance).toBe(4); + + const res3 = await fetch(`${API_URL}/balance/addr3`); + const data3 = await res3.json(); + expect(data3.balance).toBe(6); + + // addr4 and addr5 should have 0 balance + const res4 = await fetch(`${API_URL}/balance/addr4`); + const data4 = await res4.json(); + expect(data4.balance).toBe(0); + }); + + test('should allow adding new block after rollback', async () => { + // Add 2 blocks + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [{ address: 'addr2', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + + // Rollback to height 1 + await fetch(`${API_URL}/rollback?height=1`, { + method: 'POST' + }); + + // Add new block at height 2 + const newBlock2 = createBlock(2, [{ + id: 'tx2_new', + inputs: [{ txId: 'tx1', index: 0 }], + outputs: [{ address: 'addr3', value: 10 }] + }]); + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newBlock2) + }); + expect(response.status).toBe(200); + + // Verify addr3 has balance, addr2 doesn't + const res2 = await fetch(`${API_URL}/balance/addr2`); + const data2 = await res2.json(); + expect(data2.balance).toBe(0); + + const res3 = await fetch(`${API_URL}/balance/addr3`); + const data3 = await res3.json(); + expect(data3.balance).toBe(10); + }); + + test('should reject rollback with invalid height parameter', async () => { + const response = await fetch(`${API_URL}/rollback?height=invalid`, { + method: 'POST' + }); + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain('Invalid height parameter'); + }); + + test('should handle rollback to height 0', async () => { + // Add a block + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + // Rollback to height 0 (genesis) + const response = await fetch(`${API_URL}/rollback?height=0`, { + method: 'POST' + }); + expect(response.status).toBe(200); + + // Verify all balances are 0 + const res = await fetch(`${API_URL}/balance/addr1`); + const data = await res.json(); + expect(data.balance).toBe(0); + }); +}); + +describe('Edge cases', () => { + test('should handle multiple outputs to same address', async () => { + const block = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [ + { address: 'addr1', value: 5 }, + { address: 'addr1', value: 3 }, + { address: 'addr1', value: 2 } + ] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block) + }); + + const response = await fetch(`${API_URL}/balance/addr1`); + const data = await response.json(); + expect(data.balance).toBe(10); + }); + + test('should handle transaction with multiple inputs', async () => { + // Block 1: create multiple outputs + const block1 = createBlock(1, [{ + id: 'tx1', + inputs: [], + outputs: [ + { address: 'addr1', value: 5 }, + { address: 'addr1', value: 3 } + ] + }]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block1) + }); + + // Block 2: spend both outputs + const block2 = createBlock(2, [{ + id: 'tx2', + inputs: [ + { txId: 'tx1', index: 0 }, + { txId: 'tx1', index: 1 } + ], + outputs: [{ address: 'addr2', value: 8 }] + }]); + const response = await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block2) + }); + expect(response.status).toBe(200); + + const res1 = await fetch(`${API_URL}/balance/addr1`); + const data1 = await res1.json(); + expect(data1.balance).toBe(0); + + const res2 = await fetch(`${API_URL}/balance/addr2`); + const data2 = await res2.json(); + expect(data2.balance).toBe(8); + }); + + test('should handle block with multiple transactions', async () => { + const block = createBlock(1, [ + { + id: 'tx1', + inputs: [], + outputs: [{ address: 'addr1', value: 10 }] + }, + { + id: 'tx2', + inputs: [], + outputs: [{ address: 'addr2', value: 5 }] + } + ]); + await fetch(`${API_URL}/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(block) + }); + + const res1 = await fetch(`${API_URL}/balance/addr1`); + const data1 = await res1.json(); + expect(data1.balance).toBe(10); + + const res2 = await fetch(`${API_URL}/balance/addr2`); + const data2 = await res2.json(); + expect(data2.balance).toBe(5); + }); }); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 1d686e8..f0bb781 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,42 +1,317 @@ -import Fastify from 'fastify'; +import Fastify, { FastifyRequest, FastifyReply } from 'fastify'; import { Pool } from 'pg'; -import { randomUUID } from 'crypto'; +import { createHash } from 'crypto'; +import type { Block, Transaction, Input, Output } from './types'; const fastify = Fastify({ logger: true }); +let pool: Pool; -fastify.get('/', async (request, reply) => { - return { hello: 'world' }; -}); +// Database schema creation +async function createTables(dbPool: Pool) { + // Store blocks + await dbPool.query(` + CREATE TABLE IF NOT EXISTS blocks ( + id TEXT PRIMARY KEY, + height INTEGER UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + + // Store transactions + await dbPool.query(` + CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + block_id TEXT NOT NULL REFERENCES blocks(id) ON DELETE CASCADE + ); + `); -async function testPostgres(pool: Pool) { - const id = randomUUID(); - const name = 'Satoshi'; - const email = 'Nakamoto'; + // Store outputs (UTXOs) + await dbPool.query(` + CREATE TABLE IF NOT EXISTS outputs ( + tx_id TEXT NOT NULL, + output_index INTEGER NOT NULL, + address TEXT NOT NULL, + value NUMERIC NOT NULL, + spent BOOLEAN DEFAULT FALSE, + PRIMARY KEY (tx_id, output_index), + FOREIGN KEY (tx_id) REFERENCES transactions(id) ON DELETE CASCADE + ); + `); - await pool.query(`DELETE FROM users;`); + // Store inputs (references to outputs being spent) + await dbPool.query(` + CREATE TABLE IF NOT EXISTS inputs ( + tx_id TEXT NOT NULL, + input_index INTEGER NOT NULL, + spent_tx_id TEXT NOT NULL, + spent_output_index INTEGER NOT NULL, + PRIMARY KEY (tx_id, input_index), + FOREIGN KEY (tx_id) REFERENCES transactions(id) ON DELETE CASCADE + ); + `); - await pool.query(` - INSERT INTO users (id, name, email) - VALUES ($1, $2, $3); - `, [id, name, email]); + // Create index for faster lookups + await dbPool.query(` + CREATE INDEX IF NOT EXISTS idx_outputs_address ON outputs(address); + `); + await dbPool.query(` + CREATE INDEX IF NOT EXISTS idx_outputs_spent ON outputs(spent); + `); +} - const { rows } = await pool.query(` - SELECT * FROM users; +// Get current blockchain height +async function getCurrentHeight(): Promise { + const result = await pool.query(` + SELECT COALESCE(MAX(height), 0) as height FROM blocks; `); + return parseInt(result.rows[0].height); +} - console.log('USERS', rows); +// Calculate block hash +function calculateBlockHash(block: Block): string { + const data = block.height + block.transactions.map(tx => tx.id).join(''); + return createHash('sha256').update(data).digest('hex'); } -async function createTables(pool: Pool) { - await pool.query(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - email TEXT NOT NULL +// Validate block +async function validateBlock(block: Block): Promise<{ valid: boolean; error?: string }> { + const currentHeight = await getCurrentHeight(); + + // Validate height + if (block.height !== currentHeight + 1) { + return { + valid: false, + error: `Invalid height. Expected ${currentHeight + 1}, got ${block.height}` + }; + } + + // Validate block ID + const expectedHash = calculateBlockHash(block); + if (block.id !== expectedHash) { + return { + valid: false, + error: `Invalid block ID. Expected ${expectedHash}, got ${block.id}` + }; + } + + // Validate each transaction's inputs/outputs balance + for (const tx of block.transactions) { + let inputSum = 0; + let outputSum = 0; + + // Calculate input sum by looking up previous outputs + for (const input of tx.inputs) { + const result = await pool.query( + `SELECT value, spent FROM outputs WHERE tx_id = $1 AND output_index = $2`, + [input.txId, input.index] + ); + + if (result.rows.length === 0) { + return { + valid: false, + error: `Input references non-existent output: txId=${input.txId}, index=${input.index}` + }; + } + + if (result.rows[0].spent) { + return { + valid: false, + error: `Input references already spent output: txId=${input.txId}, index=${input.index}` + }; + } + + inputSum += parseFloat(result.rows[0].value); + } + + // Calculate output sum + outputSum = tx.outputs.reduce((sum, output) => sum + output.value, 0); + + // Validate input sum equals output sum (skip for coinbase transactions with no inputs) + if (tx.inputs.length > 0 && inputSum !== outputSum) { + return { + valid: false, + error: `Transaction ${tx.id} has mismatched input/output sums. Inputs: ${inputSum}, Outputs: ${outputSum}` + }; + } + } + + return { valid: true }; +} + +// Process and store a block +async function processBlock(block: Block): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Insert block + await client.query( + `INSERT INTO blocks (id, height) VALUES ($1, $2)`, + [block.id, block.height] ); - `); + + // Process each transaction + for (const tx of block.transactions) { + // Insert transaction + await client.query( + `INSERT INTO transactions (id, block_id) VALUES ($1, $2)`, + [tx.id, block.id] + ); + + // Store inputs and mark referenced outputs as spent + for (let i = 0; i < tx.inputs.length; i++) { + const input = tx.inputs[i]; + + // Store the input reference + await client.query( + `INSERT INTO inputs (tx_id, input_index, spent_tx_id, spent_output_index) VALUES ($1, $2, $3, $4)`, + [tx.id, i, input.txId, input.index] + ); + + // Mark the output as spent + await client.query( + `UPDATE outputs SET spent = TRUE WHERE tx_id = $1 AND output_index = $2`, + [input.txId, input.index] + ); + } + + // Insert outputs + for (let i = 0; i < tx.outputs.length; i++) { + const output = tx.outputs[i]; + await client.query( + `INSERT INTO outputs (tx_id, output_index, address, value, spent) VALUES ($1, $2, $3, $4, FALSE)`, + [tx.id, i, output.address, output.value] + ); + } + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } } +// Get balance for an address +async function getBalance(address: string): Promise { + const result = await pool.query( + `SELECT COALESCE(SUM(value), 0) as balance + FROM outputs + WHERE address = $1 AND spent = FALSE`, + [address] + ); + return parseFloat(result.rows[0].balance); +} + +// Rollback to a specific height +async function rollbackToHeight(targetHeight: number): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Get current height + const currentHeight = await getCurrentHeight(); + + if (targetHeight > currentHeight) { + throw new Error(`Cannot rollback to height ${targetHeight}, current height is ${currentHeight}`); + } + + if (targetHeight < 0) { + throw new Error(`Invalid target height: ${targetHeight}`); + } + + // Before deleting blocks, restore the spent status of outputs that were consumed by + // transactions in blocks we're about to delete + await client.query(` + UPDATE outputs SET spent = FALSE + WHERE (tx_id, output_index) IN ( + SELECT i.spent_tx_id, i.spent_output_index + FROM inputs i + JOIN transactions t ON i.tx_id = t.id + JOIN blocks b ON t.block_id = b.id + WHERE b.height > $1 + ) + `, [targetHeight]); + + // Delete blocks higher than target height + // Cascade will handle: transactions -> inputs and outputs + await client.query( + `DELETE FROM blocks WHERE height > $1`, + [targetHeight] + ); + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +// Routes +fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { + return { hello: 'world' }; +}); + +fastify.post<{ Body: Block }>('/blocks', async (request: FastifyRequest<{ Body: Block }>, reply: FastifyReply) => { + try { + const block = request.body; + + // Validate block structure + if (!block || !block.id || typeof block.height !== 'number' || !Array.isArray(block.transactions)) { + return reply.status(400).send({ error: 'Invalid block structure' }); + } + + // Validate block + const validation = await validateBlock(block); + if (!validation.valid) { + return reply.status(400).send({ error: validation.error }); + } + + // Process block + await processBlock(block); + + return reply.status(200).send({ success: true, height: block.height }); + } catch (error) { + fastify.log.error(error); + return reply.status(500).send({ error: 'Internal server error' }); + } +}); + +fastify.get<{ Params: { address: string } }>('/balance/:address', async (request: FastifyRequest<{ Params: { address: string } }>, reply: FastifyReply) => { + try { + const { address } = request.params; + const balance = await getBalance(address); + return reply.status(200).send({ address, balance }); + } catch (error) { + fastify.log.error(error); + return reply.status(500).send({ error: 'Internal server error' }); + } +}); + +fastify.post<{ Querystring: { height: string } }>('/rollback', async (request: FastifyRequest<{ Querystring: { height: string } }>, reply: FastifyReply) => { + try { + const height = parseInt(request.query.height); + + if (isNaN(height)) { + return reply.status(400).send({ error: 'Invalid height parameter' }); + } + + await rollbackToHeight(height); + return reply.status(200).send({ success: true, height }); + } catch (error) { + fastify.log.error(error); + if (error instanceof Error) { + return reply.status(400).send({ error: error.message }); + } + return reply.status(500).send({ error: 'Internal server error' }); + } +}); + +// Bootstrap function async function bootstrap() { console.log('Bootstrapping...'); const databaseUrl = process.env.DATABASE_URL; @@ -44,21 +319,26 @@ async function bootstrap() { throw new Error('DATABASE_URL is required'); } - const pool = new Pool({ + pool = new Pool({ connectionString: databaseUrl }); await createTables(pool); - await testPostgres(pool); + console.log('Database tables created successfully'); } +// Start server try { await bootstrap(); await fastify.listen({ port: 3000, host: '0.0.0.0' - }) + }); + console.log('Server listening on port 3000'); } catch (err) { - fastify.log.error(err) - process.exit(1) -}; \ No newline at end of file + fastify.log.error(err); + process.exit(1); +} + +// Export for testing +export { fastify, pool, getBalance, getCurrentHeight, calculateBlockHash }; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..57bcc9c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,21 @@ +export interface Output { + address: string; + value: number; +} + +export interface Input { + txId: string; + index: number; +} + +export interface Transaction { + id: string; + inputs: Array; + outputs: Array; +} + +export interface Block { + id: string; + height: number; + transactions: Array; +}