From 60cad53dc947c7b4d821a1f0e10ba9bca8c846a0 Mon Sep 17 00:00:00 2001 From: teetyff Date: Sun, 28 Jun 2026 17:41:15 +0100 Subject: [PATCH] feat: add stellar transaction history integration --- __tests__/stellar/transaction-history.test.ts | 278 ++++++++++++++++++ app/api/contracts/[id]/transactions/route.ts | 83 ++++++ .../migrations/006_stellar_transactions.sql | 26 ++ lib/stellar/transaction-history.ts | 213 ++++++++++++++ 4 files changed, 600 insertions(+) create mode 100644 __tests__/stellar/transaction-history.test.ts create mode 100644 app/api/contracts/[id]/transactions/route.ts create mode 100644 lib/db/migrations/006_stellar_transactions.sql create mode 100644 lib/stellar/transaction-history.ts diff --git a/__tests__/stellar/transaction-history.test.ts b/__tests__/stellar/transaction-history.test.ts new file mode 100644 index 0000000..cd2862e --- /dev/null +++ b/__tests__/stellar/transaction-history.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +const callMock = vi.fn() + +const mockServer = { + transactions: () => ({ + forAccount: () => ({ + limit: () => ({ + call: callMock, + }), + }), + }), +} + +vi.mock('@stellar/stellar-sdk', () => { + const MockServer = vi.fn().mockImplementation(function () { + return mockServer + }) + + return { + Horizon: { + Server: MockServer, + }, + } +}) + +import { sql } from '@/lib/db' +import { Horizon } from '@stellar/stellar-sdk' +import { + stellarTransactionHistoryService, + StellarTransactionHistoryService, +} from '@/lib/stellar/transaction-history' + +const mockSql = sql as ReturnType + +describe('StellarTransactionHistoryService', () => { + beforeEach(() => { + vi.clearAllMocks() + callMock.mockReset() + }) + + describe('fetchContractTransactions', () => { + const contractId = '550e8400-e29b-41d4-a716-446655440000' + + it('throws when contract is not found', async () => { + mockSql.mockResolvedValueOnce([]) + + await expect( + stellarTransactionHistoryService.fetchContractTransactions(contractId) + ).rejects.toThrow('Contract not found') + }) + + it('returns empty result when contract has no address', async () => { + mockSql.mockResolvedValueOnce([ + { + contract_address: null, + contract_tx_hash: null, + client_id: '111e8400-e29b-41d4-a716-446655440001', + freelancer_id: '222e8400-e29b-41d4-a716-446655440002', + }, + ]) + mockSql.mockResolvedValueOnce([]) + + const result = await stellarTransactionHistoryService.fetchContractTransactions( + contractId + ) + + expect(result.transactions).toHaveLength(0) + expect(result.meta.hasMore).toBe(false) + }) + + it('fetches transactions from Horizon for contract address', async () => { + mockSql.mockResolvedValueOnce([ + { + contract_address: 'GCDNJUBQSX7AJWLJ4QTEPX32VOPJATM5XSEB5KJ3ESSPXYWQYNOEVUOX', + contract_tx_hash: 'abc123', + client_id: '111e8400-e29b-41d4-a716-446655440001', + freelancer_id: '222e8400-e29b-41d4-a716-446655440002', + }, + ]) + mockSql.mockResolvedValueOnce([ + { wallet_address: 'GCLJQ5JKMX57VYNLSLEQ5QN465XG4XJYOUCE3T3OTQEBZRQCQF5G4PVR' }, + ]) + + callMock.mockResolvedValue({ + records: [ + { + hash: 'tx_hash_1', + created_at: '2024-01-01T00:00:00Z', + source_account: 'GCLJQ5JKMX57VYNLSLEQ5QN465XG4XJYOUCE3T3OTQEBZRQCQF5G4PVR', + successful: true, + failed_at: null, + ledger: 12345, + fee_charged: '100', + memo: '', + memo_type: 'none', + signatures: [], + envelope_xdr: '', + operations: [ + { + type: 'payment', + amount: '100.5', + asset_code: 'USDC', + to: 'GCDNJUBQSX7AJWLJ4QTEPX32VOPJATM5XSEB5KJ3ESSPXYWQYNOEVUOX', + from: 'GCLJQ5JKMX57VYNLSLEQ5QN465XG4XJYOUCE3T3OTQEBZRQCQF5G4PVR', + }, + ], + }, + ], + }) + + const service = new StellarTransactionHistoryService() + const result = await service.fetchContractTransactions(contractId, 20) + + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0]).toMatchObject({ + hash: 'tx_hash_1', + timestamp: '2024-01-01T00:00:00Z', + amount: '100.5', + assetType: 'USDC', + status: 'successful', + contractId, + transactionType: 'payment_received', + sourceAccount: 'GCLJQ5JKMX57VYNLSLEQ5QN465XG4XJYOUCE3T3OTQEBZRQCQF5G4PVR', + destinationAccount: 'GCDNJUBQSX7AJWLJ4QTEPX32VOPJATM5XSEB5KJ3ESSPXYWQYNOEVUOX', + }) + }) + + it('deduplicates transactions across addresses', async () => { + mockSql.mockResolvedValueOnce([ + { + contract_address: 'GCDNJUBQSX7AJWLJ4QTEPX32VOPJATM5XSEB5KJ3ESSPXYWQYNOEVUOX', + contract_tx_hash: 'abc123', + client_id: '111e8400-e29b-41d4-a716-446655440001', + freelancer_id: '222e8400-e29b-41d4-a716-446655440002', + }, + ]) + mockSql.mockResolvedValueOnce([ + { wallet_address: 'GCLJQ5JKMX57VYNLSLEQ5QN465XG4XJYOUCE3T3OTQEBZRQCQF5G4PVR' }, + { wallet_address: 'GDUVRMYC4PEOOZISVYL57WDFHZAX6LB4DBYQMWYUXDBLJ5QKB2QPG4OG' }, + ]) + + callMock.mockResolvedValue({ + records: [ + { + hash: 'duplicate_tx', + created_at: '2024-01-01T00:00:00Z', + source_account: 'ANY_ADDRESS', + successful: true, + failed_at: null, + ledger: 1, + fee_charged: '100', + memo: '', + memo_type: 'none', + signatures: [], + envelope_xdr: '', + operations: [], + }, + ], + }) + + const service = new StellarTransactionHistoryService() + const result = await service.fetchContractTransactions( + contractId, + 20 + ) + + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0].hash).toBe('duplicate_tx') + }) + + it('maps failed transactions correctly', async () => { + mockSql.mockResolvedValueOnce([ + { + contract_address: 'GCDNJUBQSX7AJWLJ4QTEPX32VOPJATM5XSEB5KJ3ESSPXYWQYNOEVUOX', + contract_tx_hash: 'abc123', + client_id: '111e8400-e29b-41d4-a716-446655440001', + freelancer_id: '222e8400-e29b-41d4-a716-446655440002', + }, + ]) + mockSql.mockResolvedValueOnce([]) + + callMock.mockResolvedValue({ + records: [ + { + hash: 'failed_tx', + created_at: '2024-01-01T00:00:00Z', + source_account: 'GCLJQ5JKMX57VYNLSLEQ5QN465XG4XJYOUCE3T3OTQEBZRQCQF5G4PVR', + successful: false, + failed_at: '2024-01-01T00:00:01Z', + ledger: 12346, + fee_charged: '100', + memo: '', + memo_type: 'none', + signatures: [], + envelope_xdr: '', + operations: [], + }, + ], + }) + + const service = new StellarTransactionHistoryService() + const result = await service.fetchContractTransactions( + contractId, + 20 + ) + + expect(result.transactions).toHaveLength(1) + expect(result.transactions[0].status).toBe('failed') + }) + + it('respects limit parameter', async () => { + mockSql.mockResolvedValueOnce([ + { + contract_address: 'GCDNJUBQSX7AJWLJ4QTEPX32VOPJATM5XSEB5KJ3ESSPXYWQYNOEVUOX', + contract_tx_hash: 'abc123', + client_id: '111e8400-e29b-41d4-a716-446655440001', + freelancer_id: '222e8400-e29b-41d4-a716-446655440002', + }, + ]) + mockSql.mockResolvedValueOnce([]) + + const baseTx = { + created_at: '2024-01-01T00:00:00Z', + source_account: 'GCLJQ5JKMX57VYNLSLEQ5QN465XG4XJYOUCE3T3OTQEBZRQCQF5G4PVR', + successful: true, + failed_at: null, + ledger: 1, + fee_charged: '100', + memo: '', + memo_type: 'none', + signatures: [], + envelope_xdr: '', + operations: [], + } + + callMock.mockResolvedValue({ + records: Array.from({ length: 5 }, (_, i) => ({ + ...baseTx, + hash: `tx_${i}`, + })), + }) + + const service = new StellarTransactionHistoryService() + const result = await service.fetchContractTransactions( + contractId, + 5 + ) + + expect(result.transactions).toHaveLength(5) + expect(result.meta.limit).toBe(5) + }) + + it('handles Horizon API errors gracefully', async () => { + mockSql.mockResolvedValueOnce([ + { + contract_address: 'GCDNJUBQSX7AJWLJ4QTEPX32VOPJATM5XSEB5KJ3ESSPXYWQYNOEVUOX', + contract_tx_hash: 'abc123', + client_id: '111e8400-e29b-41d4-a716-446655440001', + freelancer_id: '222e8400-e29b-41d4-a716-446655440002', + }, + ]) + mockSql.mockResolvedValueOnce([]) + + callMock.mockRejectedValue(new Error('Horizon unavailable')) + + const service = new StellarTransactionHistoryService() + + await expect( + service.fetchContractTransactions(contractId) + ).rejects.toThrow('Failed to fetch transaction history from Horizon') + }) + }) +}) diff --git a/app/api/contracts/[id]/transactions/route.ts b/app/api/contracts/[id]/transactions/route.ts new file mode 100644 index 0000000..98d277d --- /dev/null +++ b/app/api/contracts/[id]/transactions/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server' +import { readAccessToken, verifyAccessToken } from '@/lib/auth/session' +import { + stellarTransactionHistoryService, + type TransactionHistoryResult, +} from '@/lib/stellar/transaction-history' + +export const dynamic = 'force-dynamic' + +type RouteContext = { params: Promise<{ id: string }> } + +function parsePagination(searchParams: URLSearchParams): { + limit: number + cursor?: string +} { + const limitRaw = searchParams.get('limit') + const cursor = searchParams.get('cursor') || undefined + + let limit = 20 + if (limitRaw) { + const parsed = parseInt(limitRaw, 10) + if (Number.isFinite(parsed) && parsed > 0 && parsed <= 100) { + limit = parsed + } + } + + return { limit, cursor } +} + +export async function GET( + request: NextRequest, + context: RouteContext +): Promise { + const token = readAccessToken(request) + if (!token) { + return NextResponse.json( + { error: 'Unauthorized', code: 'AUTH_REQUIRED' }, + { status: 401 } + ) + } + + const payload = verifyAccessToken(token) + if (!payload) { + return NextResponse.json( + { error: 'Unauthorized', code: 'AUTH_REQUIRED' }, + { status: 401 } + ) + } + + try { + const { id } = await context.params + + const contractId = id + + const { limit, cursor } = parsePagination( + request.nextUrl.searchParams + ) + + const result: TransactionHistoryResult = + await stellarTransactionHistoryService.fetchContractTransactions( + contractId, + limit, + cursor + ) + + return NextResponse.json(result) + } catch (error) { + console.error('[GET /api/contracts/[id]/transactions]', error) + + const message = + error instanceof Error ? error.message : 'Failed to fetch transaction history' + + const status = message.includes('not found') ? 404 : 500 + + return NextResponse.json( + { + error: message, + code: status === 404 ? 'CONTRACT_NOT_FOUND' : 'FETCH_FAILED', + }, + { status } + ) + } +} diff --git a/lib/db/migrations/006_stellar_transactions.sql b/lib/db/migrations/006_stellar_transactions.sql new file mode 100644 index 0000000..aa253e1 --- /dev/null +++ b/lib/db/migrations/006_stellar_transactions.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS stellar_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES contracts (id) ON DELETE CASCADE, + tx_hash TEXT NOT NULL UNIQUE, + timestamp TIMESTAMPTZ NOT NULL, + amount NUMERIC(18,6) NOT NULL DEFAULT 0, + asset_type TEXT NOT NULL DEFAULT 'native', + status TEXT NOT NULL DEFAULT 'successful' + CHECK (status IN ('successful', 'failed', 'pending')), + transaction_type TEXT, + source_account TEXT, + destination_account TEXT, + memo_type TEXT, + memo TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_stellar_transactions_contract + ON stellar_transactions (contract_id, timestamp DESC); + +CREATE INDEX IF NOT EXISTS idx_stellar_transactions_hash + ON stellar_transactions (tx_hash); + +CREATE INDEX IF NOT EXISTS idx_stellar_transactions_fetched + ON stellar_transactions (fetched_at); \ No newline at end of file diff --git a/lib/stellar/transaction-history.ts b/lib/stellar/transaction-history.ts new file mode 100644 index 0000000..340fa96 --- /dev/null +++ b/lib/stellar/transaction-history.ts @@ -0,0 +1,213 @@ +import { Horizon } from '@stellar/stellar-sdk' +import { sql } from '@/lib/db' + +export interface StellarTransaction { + hash: string + timestamp: string + amount: string + assetType: string + status: 'successful' | 'failed' | 'pending' + contractId: string + transactionType?: string + sourceAccount?: string + destinationAccount?: string + metadata?: Record +} + +export interface TransactionHistoryResult { + transactions: StellarTransaction[] + meta: { + limit: number + cursor?: string + hasMore: boolean + } +} + +export class StellarTransactionHistoryService { + private readonly horizonUrl: string + + constructor() { + this.horizonUrl = + process.env.STELLAR_HORIZON_URL || 'https://horizon-testnet.stellar.org' + } + + async fetchContractTransactions( + contractId: string, + limit = 20, + cursor?: string + ): Promise { + const contractRows = (await sql` + SELECT c.contract_address, c.contract_tx_hash, c.client_id, c.freelancer_id + FROM contracts c + WHERE c.id = ${contractId}::uuid + LIMIT 1 + `) as Array<{ + contract_address: string | null + contract_tx_hash: string | null + client_id: string + freelancer_id: string + }> + + if (contractRows.length === 0) { + throw new Error('Contract not found') + } + + const contract = contractRows[0] + + const userRows = (await sql` + SELECT wallet_address + FROM users + WHERE id IN (${contract.client_id}::uuid, ${contract.freelancer_id}::uuid) + `) as Array<{ wallet_address: string }> + + const walletAddresses = userRows + .map((r) => r.wallet_address) + .filter((addr): addr is string => typeof addr === 'string' && addr.length > 0) + + const server = new Horizon.Server(this.horizonUrl) + + try { + const allTransactions: StellarTransaction[] = [] + const seenHashes = new Set() + + const addressesToQuery = [ + ...(contract.contract_address ? [contract.contract_address] : []), + ...walletAddresses, + ] + + for (const address of addressesToQuery) { + let query = server + .transactions() + .forAccount(address) + .limit(limit) + + if (cursor) { + query = query.cursor(cursor) + } + + const page = await query.call() + + for (const tx of page.records) { + if (seenHashes.has(tx.hash)) continue + seenHashes.add(tx.hash) + + const mapped = this.mapTransaction(tx, contractId, contract.contract_address) + if (mapped) { + allTransactions.push(mapped) + } + } + + if (allTransactions.length >= limit) break + } + + const trimmed = allTransactions.slice(0, limit) + + return { + transactions: trimmed, + meta: { + limit, + cursor, + hasMore: allTransactions.length >= limit, + }, + } + } catch (error) { + console.error( + `[StellarTransactionHistory] Horizon query failed for contract ${contractId}:`, + error + ) + throw new Error( + `Failed to fetch transaction history from Horizon: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ) + } + } + + private mapTransaction( + tx: Record, + contractId: string, + contractAddress: string | null + ): StellarTransaction | null { + const hash = tx.hash as string + const timestamp = + (tx.created_at as string) || new Date().toISOString() + const sourceAccount = tx.source_account as string | undefined + + let amount = '0' + let assetType = 'native' + let destinationAccount: string | undefined + + const ops = tx.operations as Array> | undefined + if (ops && ops.length > 0) { + const payment = ops.find( + (op) => op.type === 'payment' || op.type_i === 1 + ) + if (payment) { + amount = (payment.amount as string) || '0' + assetType = (payment.asset_code as string) || (payment.asset_type as string) || 'native' + destinationAccount = payment.to as string | undefined + } + } + + const isSuccessful = tx.successful === true + const hasFailed = tx.failed_at != null && tx.failed_at !== '' + const status: 'successful' | 'failed' | 'pending' = isSuccessful + ? 'successful' + : hasFailed + ? 'failed' + : 'pending' + + const transactionType = this.inferTransactionType(tx, contractAddress) + + return { + hash, + timestamp, + amount, + assetType, + status, + contractId, + transactionType, + sourceAccount, + destinationAccount, + metadata: { + ledger: tx.ledger, + fee: tx.fee_charged, + memo: tx.memo, + memoType: tx.memo_type, + signatures: tx.signatures, + envelopeXdr: tx.envelope_xdr, + }, + } + } + + private inferTransactionType( + tx: Record, + contractAddress: string | null + ): string { + if (!contractAddress) return 'unknown' + + const ops = tx.operations as Array> | undefined + if (!ops || ops.length === 0) return 'unknown' + + const opTypes = ops.map((op) => op.type) + + if (opTypes.includes('payment')) { + const payment = ops.find((op) => op.type === 'payment') + if (payment) { + if (payment.to === contractAddress) return 'payment_received' + if (payment.from === contractAddress) return 'payment_sent' + } + return 'payment' + } + + if (opTypes.includes('manage_data')) return 'data_operation' + if (opTypes.includes('set_options')) return 'account_settings' + if (opTypes.includes('change_trust')) return 'trustline' + if (opTypes.includes('allow_trust')) return 'allow_trust' + if (opTypes.includes('bump_sequence')) return 'bump_sequence' + + return 'other' + } +} + +export const stellarTransactionHistoryService = new StellarTransactionHistoryService()