Skip to content
Open
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
77 changes: 12 additions & 65 deletions src/hooks/useTransaction.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,16 @@
import { useState } from 'react'
import { signTransaction } from '@stellar/freighter-api'
import { STELLAR_NETWORK } from '../constants/config'
import type { UnsignedTransaction } from '../types'
import { useCallback, useState } from 'react'
import { transactionService, type TransactionPhase, type TransactionResult } from '../services/transaction.service'
import type { TransactionType, UnsignedTransaction } from '../types'

interface UseTransactionReturn {
isLoading: boolean
error: string | null
execute: <TPreview, TResult>(
getXdr: () => Promise<UnsignedTransaction<TPreview>>,
submit: (
signedXdr: string,
transaction: UnsignedTransaction<TPreview>
) => Promise<TResult>
) => Promise<TResult>
}

export function useTransaction(): UseTransactionReturn {
const [isLoading, setIsLoading] = useState(false)
export function useTransaction() {
const [phase, setPhase] = useState<TransactionPhase | 'idle' | 'error'>('idle')
const [error, setError] = useState<string | null>(null)

const execute = async <TPreview, TResult>(
getXdr: () => Promise<UnsignedTransaction<TPreview>>,
submit: (
signedXdr: string,
transaction: UnsignedTransaction<TPreview>
) => Promise<TResult>
): Promise<TResult> => {
setIsLoading(true)
const execute = useCallback(async <TPreview>(build: () => Promise<UnsignedTransaction<TPreview>>, type: TransactionType): Promise<TransactionResult<TPreview>> => {
setError(null)
try {
// Backend returns { unsignedXdr, description, preview } (already
// unwrapped from the { success, data, message } envelope by the service).
const transaction = await getXdr()

if (!transaction?.unsignedXdr) {
throw new Error('Server did not return a transaction to sign.')
}

const networkPassphrase = (STELLAR_NETWORK as string) === 'PUBLIC'
? 'Public Global Stellar Network ; October 2015'
: 'Test SDF Network ; September 2015'

const signedResponse = await signTransaction(transaction.unsignedXdr, {
networkPassphrase,
})

if (!signedResponse || signedResponse.error) {
throw new Error(
typeof signedResponse.error === 'string'
? signedResponse.error
: 'User rejected the transaction'
)
}

const result = await submit(signedResponse.signedTxXdr, transaction)
return result
} catch (err: unknown) {
console.error('Transaction failed:', err)
const message = err instanceof Error ? err.message : 'Transaction failed'
setError(message)
throw err
} finally {
setIsLoading(false)
}
}

return { isLoading, error, execute }
const result = await transactionService.execute({ build, type, onPhase: setPhase })
if (!result.ok) { setPhase('error'); setError(result.message) }
return result
}, [])
const reset = useCallback(() => { setPhase('idle'); setError(null) }, [])
return { execute, reset, phase, error, isLoading: phase === 'pending' || phase === 'signing' || phase === 'submitted' }
}
32 changes: 6 additions & 26 deletions src/pages/Sponsors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,9 @@ export function Sponsors() {
if (!amount || amount <= 0) return

try {
const result = await execute(
() => sponsorsService.deposit(amount),
async (signedXdr, transaction) => {
const submitted = await transactionsService.submit(signedXdr, 'deposit')
return {
hash: submitted.transactionHash,
amount: transaction.preview.depositAmount,
profit: 0,
}
},
)
setSuccessData(result)
const result = await execute(() => sponsorsService.deposit(amount), 'deposit')
if (!result.ok) throw new Error(result.message)
setSuccessData({ hash: result.transactionHash, amount: result.preview.depositAmount, profit: 0 })
setDepositAmount('')
invalidateSubtree.pool(queryClient)
toast.success('Deposit submitted successfully.')
Expand All @@ -80,20 +71,9 @@ export function Sponsors() {
if (!shares || shares <= 0) return

try {
const result = await execute(
() => sponsorsService.withdraw(shares),
async (signedXdr, transaction) => {
const submitted = await transactionsService.submit(signedXdr, 'withdraw')
return {
hash: submitted.transactionHash,
amount: transaction.preview.netAmount,
// The backend does not report realized profit on withdrawal;
// the success card hides the profit row when it is 0.
profit: 0,
}
},
)
setSuccessData(result)
const result = await execute(() => sponsorsService.withdraw(shares), 'withdraw')
if (!result.ok) throw new Error(result.message)
setSuccessData({ hash: result.transactionHash, amount: result.preview.netAmount, profit: 0 })
setWithdrawShares('')
invalidateSubtree.pool(queryClient)
toast.success('Withdrawal submitted successfully.')
Expand Down
48 changes: 38 additions & 10 deletions src/pages/Vouch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { ClipboardList, ShieldCheck, Award, AlertTriangle, RotateCw, Clock, DollarSign, Percent, Ban, ExternalLink, XCircle } from 'lucide-react'
import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api'
import { vouchingService } from '../services/vouching.service'
import { queryKeys } from '../services/queryKeys'
import { useSubmitVouch, useRevokeVouch } from '../hooks/useOptimisticVouch'
Expand All @@ -14,7 +13,7 @@ import { VouchRequestCard } from '../components/vouch/VouchRequestCard'
import { VouchImpactPreview } from '../components/vouch/VouchImpactPreview'
import { useWallet } from '../hooks/useWallet'
import { useToast } from '../hooks/useToast'
import { STELLAR_NETWORK } from '../constants/config'
import { useTransaction } from '../hooks/useTransaction'
import type { VouchRequest } from '../types'

const REPAYMENT_VARIANTS: Record<string, 'green' | 'blue' | 'amber' | 'red' | 'muted'> = {
Expand Down Expand Up @@ -99,7 +98,7 @@ export function Vouch() {
const [activeTab, setActiveTab] = useState<'requests' | 'active'>('requests')
const [previewRequest, setPreviewRequest] = useState<VouchRequest | null>(null)
const [revokeTarget, setRevokeTarget] = useState<string | null>(null)
const [decliningId, setDecliningId] = useState<string | null>(null)
const { execute, isLoading: transactionPending } = useTransaction()

const requestsQuery = useQuery({
queryKey: queryKeys.vouches.requests(),
Expand All @@ -111,6 +110,30 @@ export function Vouch() {
queryFn: vouchingService.getMyVouches,
})

const declineMutation = useMutation({
mutationFn: vouchingService.declineVouch,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vouch-requests'] })
toast.success('Vouch request declined.')
},
onError: (error) => {
const message = error instanceof Error ? error.message : 'Failed to decline vouch.'
toast.error(message)
},
})

const revokeMutation = useMutation({
mutationFn: (id: string) => vouchingService.revokeVouch(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['my-vouches'] })
setRevokeTarget(null)
toast.success('Vouch revoked successfully.')
},
onError: (error) => {
const message = error instanceof Error ? error.message : 'Failed to revoke vouch.'
toast.error(message)
},
})
const submitMutation = useSubmitVouch()
const revokeMutation = useRevokeVouch()

Expand All @@ -122,6 +145,15 @@ export function Vouch() {
await connectFreighter()
}

const result = await execute(
() => vouchingService.buildVouch(previewRequest.learnerAddress),
'vouch',
)
if (!result.ok) throw new Error(result.message)
await queryClient.invalidateQueries({ queryKey: ['vouch-requests'] })
await queryClient.invalidateQueries({ queryKey: ['my-vouches'] })
setPreviewRequest(null)
toast.success('Vouch confirmed successfully.')
const connection = await isConnected()
if (!connection.isConnected) {
throw new Error('Freighter not installed. Download at freighter.app')
Expand Down Expand Up @@ -164,11 +196,7 @@ export function Vouch() {
}
}

const handleDecline = async (id: string) => {
setDecliningId(id)
await new Promise((r) => setTimeout(r, 600))
setDecliningId(null)
}
const handleDecline = async (id: string) => declineMutation.mutate(id)

const tabs = [
{
Expand Down Expand Up @@ -286,7 +314,7 @@ export function Vouch() {
}
onVouch={setPreviewRequest}
onDecline={handleDecline}
declining={decliningId === request.id}
declining={declineMutation.isPending && declineMutation.variables === request.id}
/>
))}
</div>
Expand Down Expand Up @@ -438,7 +466,7 @@ export function Vouch() {
request={previewRequest}
onConfirm={handleVouchConfirm}
onClose={() => setPreviewRequest(null)}
confirming={submitMutation.isPending}
confirming={transactionPending}
/>
)}

Expand Down
42 changes: 42 additions & 0 deletions src/services/__tests__/transaction.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import { TransactionService } from '../transaction.service'

const unsigned = { unsignedXdr: 'unsigned-xdr', description: 'Test', preview: { amount: 10 } }

function dependencies(overrides: Record<string, unknown> = {}) {
return {
getAdapter: () => ({ sign: vi.fn().mockResolvedValue('signed-xdr') }),
submit: vi.fn().mockResolvedValue({ transactionHash: 'hash-1' }),
getStatus: vi.fn().mockResolvedValue({ transactionHash: 'hash-1', status: 'confirmed' }),
wait: vi.fn().mockResolvedValue(undefined),
now: vi.fn().mockReturnValue(0),
...overrides,
}
}

describe('TransactionService', () => {
it('builds, signs, submits, and confirms a transaction', async () => {
const service = new TransactionService(dependencies())
const result = await service.execute({ build: vi.fn().mockResolvedValue(unsigned), type: 'deposit' })
expect(result).toEqual({ ok: true, transactionHash: 'hash-1', preview: { amount: 10 } })
})

it('returns a rejected result when the wallet rejects signing', async () => {
const service = new TransactionService(dependencies({
getAdapter: () => ({ sign: vi.fn().mockRejectedValue(new Error('User rejected request')) }),
}))
const result = await service.execute({ build: vi.fn().mockResolvedValue(unsigned), type: 'deposit' })
expect(result).toMatchObject({ ok: false, code: 'rejected' })
})

it('returns a timeout result when confirmation never resolves', async () => {
let currentTime = 0
const service = new TransactionService(dependencies({
getStatus: vi.fn().mockResolvedValue({ transactionHash: 'hash-1', status: 'pending' }),
wait: vi.fn().mockImplementation(() => { currentTime += 10 }),
now: () => currentTime,
}), { pollIntervalMs: 10, timeoutMs: 20 })
const result = await service.execute({ build: vi.fn().mockResolvedValue(unsigned), type: 'deposit' })
expect(result).toMatchObject({ ok: false, code: 'timeout', transactionHash: 'hash-1' })
})
})
82 changes: 82 additions & 0 deletions src/services/transaction.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { STELLAR_NETWORK } from '../constants/config'
import { useWalletStore, type WalletAdapter } from '../stores/wallet.store'
import type { TransactionStatusResponse, TransactionType, UnsignedTransaction } from '../types'
import { transactionsService } from './transactions.service'

export type TransactionErrorCode = 'rejected' | 'expired' | 'failed' | 'insufficient_funds' | 'timeout'
export type TransactionResult<TPreview> =
| { ok: true; transactionHash: string; preview: TPreview }
| { ok: false; code: TransactionErrorCode; message: string; transactionHash?: string }
export type TransactionPhase = 'pending' | 'signing' | 'submitted' | 'confirmed'

interface TransactionRequest<TPreview> {
build: () => Promise<UnsignedTransaction<TPreview>>
type: TransactionType
onPhase?: (phase: TransactionPhase) => void
}
interface TransactionDependencies {
getAdapter: () => WalletAdapter
submit: (signedXdr: string, type: TransactionType) => Promise<{ transactionHash: string }>
getStatus: (hash: string) => Promise<TransactionStatusResponse>
wait: (milliseconds: number) => Promise<void>
now: () => number
}
interface TransactionOptions { pollIntervalMs?: number; timeoutMs?: number }

const NETWORK_PASSPHRASE = (STELLAR_NETWORK as string) === 'PUBLIC'
? 'Public Global Stellar Network ; October 2015'
: 'Test SDF Network ; September 2015'

function classifyError(error: unknown): TransactionResult<never> {
const message = error instanceof Error ? error.message : 'Transaction failed.'
const normalized = message.toLowerCase()
if (normalized.includes('reject') || normalized.includes('declin') || normalized.includes('cancel')) return { ok: false, code: 'rejected', message }
if (normalized.includes('insufficient') || normalized.includes('underfunded')) return { ok: false, code: 'insufficient_funds', message }
if (normalized.includes('expired') || normalized.includes('too late')) return { ok: false, code: 'expired', message }
return { ok: false, code: 'failed', message }
}

export class TransactionService {
private readonly dependencies: TransactionDependencies
private readonly options: TransactionOptions

constructor(dependencies: TransactionDependencies, options: TransactionOptions = {}) {
this.dependencies = dependencies
this.options = options
}

async execute<TPreview>(request: TransactionRequest<TPreview>): Promise<TransactionResult<TPreview>> {
try {
request.onPhase?.('pending')
const transaction = await request.build()
if (!transaction.unsignedXdr) throw new Error('Server did not return a transaction to sign.')
request.onPhase?.('signing')
const signedXdr = await this.dependencies.getAdapter().sign(transaction.unsignedXdr, NETWORK_PASSPHRASE)
const submitted = await this.dependencies.submit(signedXdr, request.type)
request.onPhase?.('submitted')
const deadline = this.dependencies.now() + (this.options.timeoutMs ?? 60_000)
while (this.dependencies.now() < deadline) {
const status = await this.dependencies.getStatus(submitted.transactionHash)
if (status.status === 'confirmed') {
request.onPhase?.('confirmed')
return { ok: true, transactionHash: submitted.transactionHash, preview: transaction.preview }
}
if (status.status === 'failed' || status.status === 'expired') {
return { ok: false, code: status.status, message: status.error ?? `Transaction ${status.status}.`, transactionHash: submitted.transactionHash }
}
await this.dependencies.wait(this.options.pollIntervalMs ?? 2_000)
}
return { ok: false, code: 'timeout', message: 'Transaction confirmation timed out.', transactionHash: submitted.transactionHash }
} catch (error) {
return classifyError(error)
}
}
}

export const transactionService = new TransactionService({
getAdapter: () => useWalletStore.getState().getAdapter(),
submit: transactionsService.submit,
getStatus: transactionsService.getStatus,
wait: (milliseconds) => new Promise((resolve) => window.setTimeout(resolve, milliseconds)),
now: Date.now,
})
7 changes: 6 additions & 1 deletion src/services/transactions.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { api } from './api'
import type { SubmittedTransaction, TransactionType } from '../types'
import type { SubmittedTransaction, TransactionStatusResponse, TransactionType } from '../types'

export const transactionsService = {
/**
Expand All @@ -19,4 +19,9 @@ export const transactionsService = {
})
return res.data.data
},

getStatus: async (transactionHash: string): Promise<TransactionStatusResponse> => {
const res = await api.get(`/transactions/${encodeURIComponent(transactionHash)}/status`)
return res.data.data
},
}
Loading
Loading