Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/api/cron/blockchain-sync/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
63 changes: 63 additions & 0 deletions lib/blockchain-sync.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { neon } from "@neondatabase/serverless";
import crypto from "crypto";

let sql: ReturnType<typeof neon> | 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<void> {
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<any[]> {
return getSql()`
SELECT id, owner, updated_at
FROM snippets
`;
}

async getSnippetByid(snippetId: string): Promise<any> {
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<void> {
await getSql()`
UPDATE snippets
SET owner = ${newOwner}, updated_at = ${updatedAt.toISOString()}
WHERE id = ${snippetId}
`;
}
}
85 changes: 85 additions & 0 deletions lib/blockchain-sync.service.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<void> {
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<void> {
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}`);
}
}
}
7 changes: 7 additions & 0 deletions lib/stellar-auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { validateStellarAddress } from "./stellar-wallet-validation";

/**
* Verify wallet signature using Stellar SDK
*/
Expand All @@ -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");

Expand Down
35 changes: 35 additions & 0 deletions lib/stellar-wallet-validation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 21 additions & 0 deletions lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
Expand Down Expand Up @@ -61,6 +62,16 @@ export async function submitTransactionWithConfirmation({
memo?: StellarSdk.Memo;
metadata?: Record<string, unknown>;
}): Promise<StellarSubmitResult> {
// 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) {
Expand Down Expand Up @@ -160,6 +171,16 @@ export async function submitOwnershipTransferMemoToStellar({
oldOwnerWalletAddress: string;
newOwnerWalletAddress: string;
}): Promise<StellarSubmitResult> {
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.
Expand Down
8 changes: 8 additions & 0 deletions scripts/add-blockchain-sync-log.sql
Original file line number Diff line number Diff line change
@@ -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()
);
62 changes: 62 additions & 0 deletions validiation/stellar-wallet-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
4 changes: 4 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
{
"path": "/api/cron/stellar-recovery",
"schedule": "*/2 * * * *"
},
{
"path": "/api/cron/blockchain-sync",
"schedule": "*/15 * * * *"
}
]
}