diff --git a/app/api/cron/blockchain-sync/route.ts b/app/api/cron/blockchain-sync/route.ts new file mode 100644 index 0000000..6622fc3 --- /dev/null +++ b/app/api/cron/blockchain-sync/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { BlockchainSyncService } from "@/lib/blockchain-sync.service"; + +export const maxDuration = 300; // max Vercel timeout 5 minutes + +export async function GET(request: Request) { + try { + // Optionally verify cron secret here if configured + const authHeader = request.headers.get("authorization"); + if ( + process.env.CRON_SECRET && + authHeader !== `Bearer ${process.env.CRON_SECRET}` + ) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const service = new BlockchainSyncService(); + await service.runSyncJob(); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[Cron/BlockchainSync] Error running job:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/lib/blockchain-sync.repository.ts b/lib/blockchain-sync.repository.ts new file mode 100644 index 0000000..503b911 --- /dev/null +++ b/lib/blockchain-sync.repository.ts @@ -0,0 +1,63 @@ +import { neon } from "@neondatabase/serverless"; +import crypto from "crypto"; + +let sql: ReturnType | null = null; + +function getSql() { + if (!sql) { + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL environment variable is not set"); + } + sql = neon(process.env.DATABASE_URL); + } + return sql; +} + +export interface SyncLogParams { + snippetId: string; + transactionHash?: string | null; + discrepancyType: string; + resolution: string; +} + +export class BlockchainSyncRepository { + async logSyncDiscrepancy(params: SyncLogParams): Promise { + await getSql()` + INSERT INTO blockchain_sync_log ( + snippet_id, + transaction_hash, + discrepancy_type, + resolution + ) VALUES ( + ${params.snippetId}, + ${params.transactionHash ?? null}, + ${params.discrepancyType}, + ${params.resolution} + ) + `; + } + + async getAllSnippets(): Promise { + return getSql()` + SELECT id, owner, updated_at + FROM snippets + `; + } + + async getSnippetByid(snippetId: string): Promise { + const result = await getSql()` + SELECT id, owner, updated_at + FROM snippets + WHERE id = ${snippetId} + `; + return result[0]; + } + + async updateSnippetOwner(snippetId: string, newOwner: string, updatedAt: Date): Promise { + await getSql()` + UPDATE snippets + SET owner = ${newOwner}, updated_at = ${updatedAt.toISOString()} + WHERE id = ${snippetId} + `; + } +} diff --git a/lib/blockchain-sync.service.ts b/lib/blockchain-sync.service.ts new file mode 100644 index 0000000..2b9a294 --- /dev/null +++ b/lib/blockchain-sync.service.ts @@ -0,0 +1,85 @@ +import { BlockchainSyncRepository } from "./blockchain-sync.repository"; +import { BlockchainEventListenerRepository } from "./blockchain-event-listener.repository"; + +export class BlockchainSyncService { + constructor( + private readonly syncRepo = new BlockchainSyncRepository(), + private readonly eventRepo = new BlockchainEventListenerRepository() + ) {} + + /** + * Fetches the latest on-chain state for a snippet. + * Includes a retry mechanism for transient network failures. + */ + async fetchBlockchainState(snippetId: string, retries = 3): Promise { + for (let i = 0; i < retries; i++) { + try { + // In a real scenario, this queries Stellar Horizon for the latest transaction + // referencing the snippetId. + // We mock the network call for illustration. + await new Promise(res => setTimeout(res, 100)); // Simulate network latency + + // Mock response + return { + owner: "mock-onchain-owner-wallet", + timestamp: new Date().toISOString(), + transactionHash: "mock-tx-hash", + isAuthoritative: true + }; + } catch (error) { + if (i === retries - 1) { + console.error(`[BlockchainSyncService] Permanent failure fetching state for ${snippetId}`, error); + throw error; + } + console.warn(`[BlockchainSyncService] Transient failure fetching state for ${snippetId}. Retrying...`); + await new Promise(res => setTimeout(res, 500 * (i + 1))); // Exponential backoff + } + } + } + + async runSyncJob(): Promise { + console.log("[BlockchainSyncService] Starting sync job..."); + const snippets = await this.syncRepo.getAllSnippets(); + + for (const snippet of snippets) { + try { + await this.reconcileSnippet(snippet); + } catch (error) { + console.error(`[BlockchainSyncService] Failed to reconcile snippet ${snippet.id}:`, error); + // Log permanent failure or just continue to be resilient + } + } + console.log("[BlockchainSyncService] Sync job completed."); + } + + async reconcileSnippet(snippet: any): Promise { + const onChainState = await this.fetchBlockchainState(snippet.id); + if (!onChainState) return; + + // Detect discrepancy + if (snippet.owner !== onChainState.owner) { + const dbUpdatedAt = new Date(snippet.updated_at).getTime(); + const onChainUpdatedAt = new Date(onChainState.timestamp).getTime(); + + let resolution = ""; + + if (onChainUpdatedAt > dbUpdatedAt && onChainState.isAuthoritative) { + // Safe reconciliation: on-chain data is newer and authoritative + await this.syncRepo.updateSnippetOwner(snippet.id, onChainState.owner, new Date(onChainState.timestamp)); + resolution = "UPDATED_DB"; + } else { + // Do not overwrite newer application state + resolution = "PRESERVED_APP_STATE"; + } + + await this.syncRepo.logSyncDiscrepancy({ + snippetId: snippet.id, + transactionHash: onChainState.transactionHash, + discrepancyType: "OWNERSHIP_MISMATCH", + resolution + }); + + console.log(`[BlockchainSyncService] Discrepancy resolved for ${snippet.id}: ${resolution}`); + } + } +} diff --git a/lib/stellar-auth.ts b/lib/stellar-auth.ts index 556abc4..c58f77b 100644 --- a/lib/stellar-auth.ts +++ b/lib/stellar-auth.ts @@ -1,3 +1,5 @@ +import { validateStellarAddress } from "./stellar-wallet-validation"; + /** * Verify wallet signature using Stellar SDK */ @@ -7,6 +9,11 @@ export async function verifyWalletSignature( publicKey: string, ): Promise<{ valid: boolean; error?: string }> { try { + // Validate the Stellar wallet address first + if (!validateStellarAddress(publicKey)) { + return { valid: false, error: "Invalid Stellar wallet address" }; + } + // Import Stellar SDK const StellarSdk = require("stellar-sdk"); diff --git a/lib/stellar-wallet-validation.ts b/lib/stellar-wallet-validation.ts new file mode 100644 index 0000000..e5f8926 --- /dev/null +++ b/lib/stellar-wallet-validation.ts @@ -0,0 +1,35 @@ +import * as StellarSdk from 'stellar-sdk'; + +/** + * Validates a Stellar wallet address (public key). + * + * @param address - The Stellar wallet address to validate (e.g., G...) + * @returns true if valid + * @throws Error if the address is malformed or invalid + */ +export function validateStellarAddress(address: string): boolean { + if (!address || typeof address !== 'string') { + throw new Error("Invalid Stellar wallet address"); + } + + if (!StellarSdk.StrKey.isValidEd25519PublicKey(address)) { + throw new Error("Invalid Stellar wallet address"); + } + + return true; +} + +/** + * Validates a Stellar wallet address (public key) safely without throwing. + * + * @param address - The Stellar wallet address to validate (e.g., G...) + * @returns true if valid, false otherwise + */ +export function isValidStellarAddress(address: string): boolean { + try { + if (!address || typeof address !== 'string') return false; + return StellarSdk.StrKey.isValidEd25519PublicKey(address); + } catch { + return false; + } +} diff --git a/lib/stellar.ts b/lib/stellar.ts index fde9b2d..5587d87 100644 --- a/lib/stellar.ts +++ b/lib/stellar.ts @@ -11,6 +11,7 @@ import { buildSnippetMemo, generateSnippetMemoRef, } from "@/lib/snippet-memo"; +import { validateStellarAddress } from "@/lib/stellar-wallet-validation"; const STELLAR_NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK || "testnet"; const STELLAR_SECRET_KEY = process.env.STELLAR_SECRET_KEY || ""; @@ -61,6 +62,16 @@ export async function submitTransactionWithConfirmation({ memo?: StellarSdk.Memo; metadata?: Record; }): Promise { + // Validate the wallet address + try { + validateStellarAddress(walletAddress); + } catch (error: any) { + return { + success: false, + error: `Invalid Stellar wallet address: ${error.message}`, + }; + } + const key = secretKey || STELLAR_SECRET_KEY; if (!key) { @@ -160,6 +171,16 @@ export async function submitOwnershipTransferMemoToStellar({ oldOwnerWalletAddress: string; newOwnerWalletAddress: string; }): Promise { + try { + validateStellarAddress(oldOwnerWalletAddress); + validateStellarAddress(newOwnerWalletAddress); + } catch (error: any) { + return { + success: false, + error: `Invalid Stellar wallet address: ${error.message}`, + }; + } + const key = secretKey || STELLAR_SECRET_KEY; // Fall back to deterministic mock when no key configured. diff --git a/scripts/add-blockchain-sync-log.sql b/scripts/add-blockchain-sync-log.sql new file mode 100644 index 0000000..4df0b53 --- /dev/null +++ b/scripts/add-blockchain-sync-log.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS blockchain_sync_log ( + id SERIAL PRIMARY KEY, + snippet_id VARCHAR(64) NOT NULL, + transaction_hash VARCHAR(64), + discrepancy_type VARCHAR(50), + resolution VARCHAR(50), + synced_at TIMESTAMP DEFAULT NOW() +); diff --git a/validiation/stellar-wallet-validation.test.ts b/validiation/stellar-wallet-validation.test.ts new file mode 100644 index 0000000..f6ca208 --- /dev/null +++ b/validiation/stellar-wallet-validation.test.ts @@ -0,0 +1,62 @@ +import { validateStellarAddress, isValidStellarAddress } from '../lib/stellar-wallet-validation'; + +describe('Stellar Wallet Validation', () => { + const validAddress = 'GB3T5Y73X7P54R3F2R2L63K3627HXYF475V5RKTF5ZQ22J4LZRVKZ2B5'; + const invalidAddress = 'GB3T5Y73X7P54R3F2R2L63K3627HXYF475V5RKTF5ZQ22J4LZRVKZ2B'; // Too short + const malformedAddress = 'invalid-address'; + const secretKey = 'SA3T5Y73X7P54R3F2R2L63K3627HXYF475V5RKTF5ZQ22J4LZRVKZ2B5'; // Starting with S + + describe('validateStellarAddress', () => { + it('should return true for a valid Stellar address', () => { + expect(validateStellarAddress(validAddress)).toBe(true); + }); + + it('should throw for an invalid Stellar address length', () => { + expect(() => validateStellarAddress(invalidAddress)).toThrow('Invalid Stellar wallet address'); + }); + + it('should throw for a malformed address', () => { + expect(() => validateStellarAddress(malformedAddress)).toThrow('Invalid Stellar wallet address'); + }); + + it('should throw if secret key is passed instead of public key', () => { + expect(() => validateStellarAddress(secretKey)).toThrow('Invalid Stellar wallet address'); + }); + + it('should throw for empty string', () => { + expect(() => validateStellarAddress('')).toThrow('Invalid Stellar wallet address'); + }); + + it('should throw for null or undefined', () => { + expect(() => validateStellarAddress(null as any)).toThrow('Invalid Stellar wallet address'); + expect(() => validateStellarAddress(undefined as any)).toThrow('Invalid Stellar wallet address'); + }); + }); + + describe('isValidStellarAddress', () => { + it('should return true for a valid Stellar address', () => { + expect(isValidStellarAddress(validAddress)).toBe(true); + }); + + it('should return false for an invalid Stellar address length', () => { + expect(isValidStellarAddress(invalidAddress)).toBe(false); + }); + + it('should return false for a malformed address', () => { + expect(isValidStellarAddress(malformedAddress)).toBe(false); + }); + + it('should return false if secret key is passed instead of public key', () => { + expect(isValidStellarAddress(secretKey)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isValidStellarAddress('')).toBe(false); + }); + + it('should return false for null or undefined', () => { + expect(isValidStellarAddress(null as any)).toBe(false); + expect(isValidStellarAddress(undefined as any)).toBe(false); + }); + }); +}); diff --git a/vercel.json b/vercel.json index 8bf0afb..484d91e 100644 --- a/vercel.json +++ b/vercel.json @@ -15,6 +15,10 @@ { "path": "/api/cron/stellar-recovery", "schedule": "*/2 * * * *" + }, + { + "path": "/api/cron/blockchain-sync", + "schedule": "*/15 * * * *" } ] }