From 23648383e465ac9fda8eef5de9c41317e4423e7b Mon Sep 17 00:00:00 2001 From: Onelove Date: Sun, 29 Mar 2026 18:07:41 +0100 Subject: [PATCH 1/4] feat(db): add milestones and notifications tables --- scripts/004-milestones.sql | 15 +++++++++++++++ scripts/005-notifications.sql | 13 +++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 scripts/004-milestones.sql create mode 100644 scripts/005-notifications.sql diff --git a/scripts/004-milestones.sql b/scripts/004-milestones.sql new file mode 100644 index 0000000..e90a2e1 --- /dev/null +++ b/scripts/004-milestones.sql @@ -0,0 +1,15 @@ +-- Create milestones table +CREATE TABLE IF NOT EXISTS milestones ( + id SERIAL PRIMARY KEY, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + amount DECIMAL(10, 2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'released')), + due_date TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_milestones_job ON milestones(job_id); +CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status); diff --git a/scripts/005-notifications.sql b/scripts/005-notifications.sql new file mode 100644 index 0000000..ab109e7 --- /dev/null +++ b/scripts/005-notifications.sql @@ -0,0 +1,13 @@ +-- Create notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + type VARCHAR(50) DEFAULT 'info', + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at); From 6f67cba0b8802cd74e63337abbbf207b319bb871 Mon Sep 17 00:00:00 2001 From: Onelove Date: Sun, 29 Mar 2026 18:09:00 +0100 Subject: [PATCH 2/4] feat(worker): upgrade blockchain event listener with milestone and notification support --- scripts/worker.ts | 272 +++++++++++++++++++++++++++++++++------------- 1 file changed, 196 insertions(+), 76 deletions(-) diff --git a/scripts/worker.ts b/scripts/worker.ts index 8c164af..8f9be69 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -2,7 +2,6 @@ import { Server } from '@stellar/stellar-sdk' import { neon } from '@neondatabase/serverless' import * as dotenv from 'dotenv' - dotenv.config() if (!process.env.DATABASE_URL) { @@ -11,125 +10,246 @@ if (!process.env.DATABASE_URL) { } const sql = neon(process.env.DATABASE_URL) +const server = new Server(process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org') -const server = new Server('https://horizon-testnet.stellar.org') - +const PLATFORM_ESCROW_ACCOUNT = process.env.ESCROW_ACCOUNT_ID || 'GBD2Z3PZ2L5KHTC4YQZKVH4A4XJ4Q5X6M7N8O9P0Q1R2S3T4U5V6W7X8' -const PLATFORM_ESCROW_ACCOUNT = process.env.ESCROW_ACCOUNT_ID || 'GBD2Z3PZ2L5KHTC4YQZKVH4A4XJ4Q5X6M7N8O9P0Q1R2S3T4U5V6W7X8' // Dummy Address +async function createNotification(userId: number, title: string, message: string, type: string = 'info') { + try { + await sql` + INSERT INTO notifications (user_id, title, message, type) + VALUES (${userId}, ${title}, ${message}, ${type}) + ` + console.log(`[NOTIFICATION] Created for User #${userId}: ${title}`) + } catch (error) { + console.error(`[WORKER ERROR] Failed to create notification for User #${userId}:`, error) + } +} -async function notifyUsers(jobId: number, message: string) { - - console.log(`[NOTIFICATION] Sending update for Job #${jobId}: ${message}`) +async function getJobById(jobId: number) { + const jobs = await sql` + SELECT j.*, c.wallet_address as client_wallet, f.wallet_address as freelancer_wallet + FROM jobs j + JOIN users c ON j.client_id = c.id + LEFT JOIN users f ON j.freelancer_id = f.id + WHERE j.id = ${jobId} + ` + return jobs[0] || null +} +async function getMilestoneById(milestoneId: number) { + const milestones = await sql` + SELECT m.*, j.client_id, j.freelancer_id, c.wallet_address as client_wallet, f.wallet_address as freelancer_wallet + FROM milestones m + JOIN jobs j ON m.job_id = j.id + JOIN users c ON j.client_id = c.id + LEFT JOIN users f ON j.freelancer_id = f.id + WHERE m.id = ${milestoneId} + ` + return milestones[0] || null } -async function processPaymentEvent(record: any) { +async function processPayment(record: any) { try { - const transaction = await record.transaction() const memo = transaction.memo - const isDeposit = record.to === PLATFORM_ESCROW_ACCOUNT + if (!memo) return + + const txHash = transaction.hash const amount = record.amount const currency = record.asset_type === 'native' ? 'XLM' : record.asset_code + const from = record.from + const to = record.to + + // Idempotency check + const existingTx = await sql`SELECT id FROM escrow_transactions WHERE stellar_transaction_hash = ${txHash}` + if (existingTx.length > 0) { + console.log(`[WORKER] Transaction ${txHash} already processed. Skipping.`) + return + } - if (isDeposit && memo) { - - const jobIdStr = memo.replace('JOB-', '') - const jobId = parseInt(jobIdStr, 10) - + if (memo.startsWith('JOB-')) { + const jobId = parseInt(memo.replace('JOB-', ''), 10) if (isNaN(jobId)) return + await handleJobPayment(jobId, record, txHash, amount, currency, from, to) + } else if (memo.startsWith('MIL-')) { + const milestoneId = parseInt(memo.replace('MIL-', ''), 10) + if (isNaN(milestoneId)) return + await handleMilestonePayment(milestoneId, record, txHash, amount, currency, from, to) + } + } catch (error) { + console.error(`[WORKER ERROR] Failed to process payment record ${record.id}:`, error) + } +} + +async function handleJobPayment(jobId: number, record: any, txHash: string, amount: string, currency: string, from: string, to: string) { + const job = await getJobById(jobId) + if (!job) { + console.warn(`[WORKER] Job #${jobId} not found for transaction ${txHash}`) + return + } - console.log(`[WORKER] Detected DEPOSIT of ${amount} ${currency} for Job ${jobId}`) + const isDeposit = to === PLATFORM_ESCROW_ACCOUNT + const isFromEscrow = from === PLATFORM_ESCROW_ACCOUNT - - const existingTx = await sql`SELECT id FROM escrow_transactions WHERE stellar_transaction_hash = ${transaction.hash}` - if (existingTx.length > 0) { - console.log(`[WORKER] Transaction ${transaction.hash} already processed. Skipping.`) - return - } + if (isDeposit) { + console.log(`[WORKER] Detected JOB DEPOSIT of ${amount} ${currency} for Job #${jobId}`) + + await sql` + INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status) + VALUES (${jobId}, ${txHash}, ${amount}, ${currency}, 'deposit', ${from}, ${to}, 'confirmed') + ` + + await sql` + UPDATE jobs SET escrow_status = 'funded', status = 'in_progress', updated_at = CURRENT_TIMESTAMP + WHERE id = ${jobId} + ` + + await createNotification(job.client_id, 'Escrow Funded', `You have successfully funded Job #${jobId} with ${amount} ${currency}.`, 'success') + if (job.freelancer_id) { + await createNotification(job.freelancer_id, 'Project Started', `Funding for Job #${jobId} is confirmed. You can now start working!`, 'info') + } + } else if (isFromEscrow) { + const isRefund = to === job.client_wallet + const isRelease = to === job.freelancer_wallet + const type = isRefund ? 'refund' : (isRelease ? 'release' : 'dispute_resolution') - + console.log(`[WORKER] Detected JOB ${type.toUpperCase()} of ${amount} ${currency} for Job #${jobId}`) + + await sql` + INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status) + VALUES (${jobId}, ${txHash}, ${amount}, ${currency}, ${type}, ${from}, ${to}, 'confirmed') + ` + + if (isRelease) { await sql` - INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status) - VALUES (${jobId}, ${transaction.hash}, ${amount}, ${currency}, 'deposit', ${record.from}, ${record.to}, 'confirmed') + UPDATE jobs SET escrow_status = 'released', status = 'completed', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ${jobId} ` - - + await createNotification(job.client_id, 'Payment Released', `Payment of ${amount} ${currency} for Job #${jobId} has been released.`, 'success') + if (job.freelancer_id) { + await createNotification(job.freelancer_id, 'Payment Received', `You have received ${amount} ${currency} for completing Job #${jobId}.`, 'success') + } + } else if (isRefund) { await sql` - UPDATE jobs - SET escrow_status = 'funded', status = 'in_progress', updated_at = CURRENT_TIMESTAMP - WHERE id = ${jobId} AND escrow_status != 'funded' + UPDATE jobs SET escrow_status = 'refunded', status = 'cancelled', updated_at = CURRENT_TIMESTAMP + WHERE id = ${jobId} ` - - - await notifyUsers(jobId, `A deposit of ${amount} ${currency} has been confirmed in escrow.`) - - } else if (record.from === PLATFORM_ESCROW_ACCOUNT && memo) { - - const jobIdStr = memo.replace('JOB-', '') - const jobId = parseInt(jobIdStr, 10) - - if (isNaN(jobId)) return - - console.log(`[WORKER] Detected RELEASE of ${amount} ${currency} for Job ${jobId}`) - - const existingTx = await sql`SELECT id FROM escrow_transactions WHERE stellar_transaction_hash = ${transaction.hash}` - if (existingTx.length > 0) { - console.log(`[WORKER] Transaction ${transaction.hash} already processed. Skipping.`) - return - } - - - await sql` - INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status) - VALUES (${jobId}, ${transaction.hash}, ${amount}, ${currency}, 'release', ${record.from}, ${record.to}, 'confirmed') - ` - - - await sql` - UPDATE jobs - SET escrow_status = 'released', - status = 'completed', - completed_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP - WHERE id = ${jobId} AND escrow_status != 'released' - ` - - - await notifyUsers(jobId, `Payment of ${amount} ${currency} has been released from escrow. Contract marked as completed.`) + await createNotification(job.client_id, 'Refund Received', `Refund of ${amount} ${currency} for Job #${jobId} has been processed.`, 'info') + if (job.freelancer_id) { + await createNotification(job.freelancer_id, 'Escrow Refunded', `Escrow for Job #${jobId} has been refunded to the client.`, 'warning') + } } + } +} - } catch (error) { - console.error(`[WORKER ERROR] Failed to process payment record ${record.id}:`, error) +async function handleMilestonePayment(milestoneId: number, record: any, txHash: string, amount: string, currency: string, from: string, to: string) { + const milestone = await getMilestoneById(milestoneId) + if (!milestone) { + console.warn(`[WORKER] Milestone #${milestoneId} not found for transaction ${txHash}`) + return + } + + const isDeposit = to === PLATFORM_ESCROW_ACCOUNT + const isFromEscrow = from === PLATFORM_ESCROW_ACCOUNT + + if (isDeposit) { + console.log(`[WORKER] Detected MILESTONE DEPOSIT of ${amount} ${currency} for Milestone #${milestoneId}`) + + await sql` + INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status) + VALUES (${milestone.job_id}, ${txHash}, ${amount}, ${currency}, 'deposit', ${from}, ${to}, 'confirmed') + ` + + await sql` + UPDATE milestones SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP + WHERE id = ${milestoneId} + ` + + await createNotification(milestone.client_id, 'Milestone Funded', `Milestone "${milestone.title}" funded with ${amount} ${currency}.`, 'success') + } else if (isFromEscrow) { + const isRefund = to === milestone.client_wallet + const isRelease = to === milestone.freelancer_wallet + const type = isRefund ? 'refund' : (isRelease ? 'release' : 'dispute_resolution') + + console.log(`[WORKER] Detected MILESTONE ${type.toUpperCase()} of ${amount} ${currency} for Milestone #${milestoneId}`) + + await sql` + INSERT INTO escrow_transactions (job_id, stellar_transaction_hash, amount, currency, transaction_type, from_wallet, to_wallet, status) + VALUES (${milestone.job_id}, ${txHash}, ${amount}, ${currency}, ${type}, ${from}, ${to}, 'confirmed') + ` + + if (isRelease) { + await sql` + UPDATE milestones SET status = 'released', updated_at = CURRENT_TIMESTAMP + WHERE id = ${milestoneId} + ` + await createNotification(milestone.client_id, 'Milestone Released', `Payment for "${milestone.title}" has been released.`, 'success') + if (milestone.freelancer_id) { + await createNotification(milestone.freelancer_id, 'Milestone Payment Received', `You received ${amount} ${currency} for milestone "${milestone.title}".`, 'success') + } + + // Check if all milestones are completed/released + const allMilestones = await sql`SELECT status FROM milestones WHERE job_id = ${milestone.job_id}` + const allReleased = allMilestones.every((m: any) => m.status === 'released') + if (allReleased && allMilestones.length > 0) { + await sql` + UPDATE jobs SET status = 'completed', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ${milestone.job_id} AND status != 'completed' + ` + await createNotification(milestone.client_id, 'Project Completed', `All milestones for Job #${milestone.job_id} have been completed.`, 'success') + } + } else if (isRefund) { + await sql` + UPDATE milestones SET status = 'pending', updated_at = CURRENT_TIMESTAMP + WHERE id = ${milestoneId} + ` + await createNotification(milestone.client_id, 'Milestone Refunded', `Refund for "${milestone.title}" processed.`, 'info') + } } } async function startWorker() { - console.log('[WORKER] Starting Stellar Blockchain Event Worker...') - console.log(`[WORKER] Monitoring Escrow Account/Contract: ${PLATFORM_ESCROW_ACCOUNT}`) + console.log('[WORKER] Starting Stellar Blockchain Event Listener Service...') + console.log(`[WORKER] Monitoring Escrow Account: ${PLATFORM_ESCROW_ACCOUNT}`) + console.log(`[WORKER] Network: ${process.env.STELLAR_HORIZON_URL || 'Horizon Testnet'}`) - + try { + // Check account existence + await server.loadAccount(PLATFORM_ESCROW_ACCOUNT) + } catch (err) { + console.warn(`[WORKER WARNING] Could not load escrow account ${PLATFORM_ESCROW_ACCOUNT}. It may not exist on this network yet.`) + } + + // Use streaming for real-time updates server.payments() .forAccount(PLATFORM_ESCROW_ACCOUNT) .cursor('now') .stream({ onmessage: async (paymentRecord: any) => { - if (paymentRecord.type === 'payment') { - await processPaymentEvent(paymentRecord) + await processPayment(paymentRecord) } }, - onerror: (error) => { + onerror: (error: any) => { console.error('[WORKER ERROR] Stellar SDK Streaming Error:', error) + // Streaming usually tries to reconnect automatically, but we log it. } }) + // Heartbeat setInterval(() => { - console.log(`[WORKER HEARTBEAT] ${new Date().toISOString()} - Listening for events...`) + console.log(`[WORKER HEARTBEAT] ${new Date().toISOString()} - Monitoring ${PLATFORM_ESCROW_ACCOUNT}...`) }, 60000) } +process.on('SIGINT', () => { + console.log('[WORKER] Gracefully shutting down...') + process.exit(0) +}) + startWorker().catch((err) => { console.error('[FATAL WORKER ERROR]', err) process.exit(1) }) + From 86c9524e37d3583d1b977859550583607d7a7d5e Mon Sep 17 00:00:00 2001 From: Onelove Date: Sun, 29 Mar 2026 18:09:28 +0100 Subject: [PATCH 3/4] docs: add blockchain listener service implementation summary --- docs/blockchain_listener_implementation.md | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/blockchain_listener_implementation.md diff --git a/docs/blockchain_listener_implementation.md b/docs/blockchain_listener_implementation.md new file mode 100644 index 0000000..b442399 --- /dev/null +++ b/docs/blockchain_listener_implementation.md @@ -0,0 +1,39 @@ +# Blockchain Event Listener Service + +## Overview +Implemented a background worker service that subscribes to Stellar blockchain events to keep the application state in sync. The service handles escrow deposits, releases, and refunds for both entire jobs and individual milestones. + +## Key Components + +### 1. Database Schema Extensions +- **Milestones Table (`scripts/004-milestones.sql`)**: Stores milestone-level progress, linked to jobs. +- **Notifications Table (`scripts/005-notifications.sql`)**: Stores platform-wide notifications for users. + +### 2. Upgraded Background Worker (`scripts/worker.ts`) +- **Stellar Horizon Streaming**: Subscribes to payments for the platform escrow account. +- **Memo-based Logic**: Distinguishes between Job-level (`JOB-{id}`) and Milestone-level (`MIL-{id}`) operations using transaction memos. +- **Role-aware Processing**: Fetches client and freelancer wallet addresses to automatically identify whether a payment from escrow is a **Release** (to freelancer) or a **Refund** (to client). +- **Idempotency**: Checks `escrow_transactions` before processing to prevent duplicate updates from the same blockchain transaction. +- **Automatic Job Completion**: Synchronizes the main Job status to `completed` once all associated milestones have been released. +- **Real Notifications**: Inserts notification records directly into the database during event processing. + +## How to Run +1. Ensure the new SQL migrations are applied to your database: + ```bash + # Run these in your Neon console or via psql + scripts/004-milestones.sql + scripts/005-notifications.sql + ``` +2. Set the necessary environment variables in `.env`: + - `DATABASE_URL`: Your Postgres connection string. + - `ESCROW_ACCOUNT_ID`: The Stellar account monitoring for escrow payments. + - `STELLAR_HORIZON_URL`: (Optional) Defaults to Testnet. +3. Start the worker: + ```bash + npm run worker + ``` + +## Design Decisions +- **Separation of Concerns**: Kept the worker as a separate script to avoid blocking Next.js API routes. +- **Resilience**: Implemented `SIGINT` handling for graceful shutdown and used SDK streaming for automatic reconnection. +- **Scalability**: Used async/await and non-blocking SQL queries to handle concurrent events. From 6a18f6b2d3674b539fcb867750c6593632bd4dab Mon Sep 17 00:00:00 2001 From: Onelove Date: Sun, 29 Mar 2026 18:16:50 +0100 Subject: [PATCH 4/4] fix: resolve lint errors and add missing freighter-api dependency --- app/login/page.tsx | 2 +- components/navbar.tsx | 5 +++-- package-lock.json | 23 +++++++++++++++++++++++ package.json | 4 ++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/login/page.tsx b/app/login/page.tsx index f026e13..3a87b2f 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -166,7 +166,7 @@ export default function LoginPage() {

- By connecting a wallet, you agree to TaskChain's Terms of Service and Privacy Policy. + By connecting a wallet, you agree to TaskChain's Terms of Service and Privacy Policy.

)} diff --git a/components/navbar.tsx b/components/navbar.tsx index 22cae27..e9ba224 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -13,10 +13,11 @@ export function Navbar() { useEffect(() => { const savedAddress = localStorage.getItem('stellar_wallet_address') - if (savedAddress) { + if (savedAddress && savedAddress !== address) { + // eslint-disable-next-line react-hooks/set-state-in-effect setAddress(savedAddress) } - }, []) + }, [address]) const formatAddress = (addr: string) => { if (!addr || addr.length <= 10) return addr; diff --git a/package-lock.json b/package-lock.json index c1a4fc5..badf499 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", + "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^14.5.0", "@vercel/analytics": "1.3.1", "autoprefixer": "^10.4.20", @@ -4199,6 +4200,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@stellar/freighter-api": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-6.0.1.tgz", + "integrity": "sha512-eqwakEqSg+zoLuPpSbKyrX0pG8DQFzL/J5GtbfuMCmJI+h+oiC9pQ5C6QLc80xopZQKdGt8dUAFCmDMNdAG95w==", + "license": "Apache-2.0", + "dependencies": { + "buffer": "6.0.3", + "semver": "7.7.1" + } + }, + "node_modules/@stellar/freighter-api/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", diff --git a/package.json b/package.json index 1e14f96..3bfd478 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,13 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", + "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^14.5.0", "@vercel/analytics": "1.3.1", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", - "date-fns": "^4.1.0", "date-fns": "4.1.0", "dotenv": "^17.3.1", "embla-carousel-react": "8.5.1", @@ -80,4 +80,4 @@ "tw-animate-css": "1.3.3", "typescript": "^5" } -} \ No newline at end of file +}