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
278 changes: 278 additions & 0 deletions __tests__/stellar/transaction-history.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>

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')
})
})
})
83 changes: 83 additions & 0 deletions app/api/contracts/[id]/transactions/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 }
)
}
}
26 changes: 26 additions & 0 deletions lib/db/migrations/006_stellar_transactions.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading