diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index da344e3..7947cb8 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -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: ( - getXdr: () => Promise>, - submit: ( - signedXdr: string, - transaction: UnsignedTransaction - ) => Promise - ) => Promise -} - -export function useTransaction(): UseTransactionReturn { - const [isLoading, setIsLoading] = useState(false) +export function useTransaction() { + const [phase, setPhase] = useState('idle') const [error, setError] = useState(null) - - const execute = async ( - getXdr: () => Promise>, - submit: ( - signedXdr: string, - transaction: UnsignedTransaction - ) => Promise - ): Promise => { - setIsLoading(true) + const execute = useCallback(async (build: () => Promise>, type: TransactionType): Promise> => { 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' } } diff --git a/src/pages/Sponsors.tsx b/src/pages/Sponsors.tsx index bb1d0b7..7e2d451 100644 --- a/src/pages/Sponsors.tsx +++ b/src/pages/Sponsors.tsx @@ -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.') @@ -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.') diff --git a/src/pages/Vouch.tsx b/src/pages/Vouch.tsx index ae6623b..3490bfe 100644 --- a/src/pages/Vouch.tsx +++ b/src/pages/Vouch.tsx @@ -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' @@ -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 = { @@ -99,7 +98,7 @@ export function Vouch() { const [activeTab, setActiveTab] = useState<'requests' | 'active'>('requests') const [previewRequest, setPreviewRequest] = useState(null) const [revokeTarget, setRevokeTarget] = useState(null) - const [decliningId, setDecliningId] = useState(null) + const { execute, isLoading: transactionPending } = useTransaction() const requestsQuery = useQuery({ queryKey: queryKeys.vouches.requests(), @@ -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() @@ -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') @@ -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 = [ { @@ -286,7 +314,7 @@ export function Vouch() { } onVouch={setPreviewRequest} onDecline={handleDecline} - declining={decliningId === request.id} + declining={declineMutation.isPending && declineMutation.variables === request.id} /> ))} @@ -438,7 +466,7 @@ export function Vouch() { request={previewRequest} onConfirm={handleVouchConfirm} onClose={() => setPreviewRequest(null)} - confirming={submitMutation.isPending} + confirming={transactionPending} /> )} diff --git a/src/services/__tests__/transaction.service.test.ts b/src/services/__tests__/transaction.service.test.ts new file mode 100644 index 0000000..10ce8ac --- /dev/null +++ b/src/services/__tests__/transaction.service.test.ts @@ -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 = {}) { + 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' }) + }) +}) diff --git a/src/services/transaction.service.ts b/src/services/transaction.service.ts new file mode 100644 index 0000000..d534c81 --- /dev/null +++ b/src/services/transaction.service.ts @@ -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 = + | { ok: true; transactionHash: string; preview: TPreview } + | { ok: false; code: TransactionErrorCode; message: string; transactionHash?: string } +export type TransactionPhase = 'pending' | 'signing' | 'submitted' | 'confirmed' + +interface TransactionRequest { + build: () => Promise> + type: TransactionType + onPhase?: (phase: TransactionPhase) => void +} +interface TransactionDependencies { + getAdapter: () => WalletAdapter + submit: (signedXdr: string, type: TransactionType) => Promise<{ transactionHash: string }> + getStatus: (hash: string) => Promise + wait: (milliseconds: number) => Promise + 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 { + 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(request: TransactionRequest): Promise> { + 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, +}) diff --git a/src/services/transactions.service.ts b/src/services/transactions.service.ts index e05e537..80ba543 100644 --- a/src/services/transactions.service.ts +++ b/src/services/transactions.service.ts @@ -1,5 +1,5 @@ import { api } from './api' -import type { SubmittedTransaction, TransactionType } from '../types' +import type { SubmittedTransaction, TransactionStatusResponse, TransactionType } from '../types' export const transactionsService = { /** @@ -19,4 +19,9 @@ export const transactionsService = { }) return res.data.data }, + + getStatus: async (transactionHash: string): Promise => { + const res = await api.get(`/transactions/${encodeURIComponent(transactionHash)}/status`) + return res.data.data + }, } diff --git a/src/services/vouching.service.ts b/src/services/vouching.service.ts index 351c2e1..db6b490 100644 --- a/src/services/vouching.service.ts +++ b/src/services/vouching.service.ts @@ -1,5 +1,5 @@ import { api } from './api' -import type { VouchRequest, ActiveVouch, VouchResponse } from '../types' +import type { VouchRequest, ActiveVouch, UnsignedTransaction } from '../types' /** * Backend DTO shapes (StepFi-API vouching module). The frontend view models @@ -84,11 +84,17 @@ export const vouchingService = { }, // POST /vouching/approve — mentor approves a pending vouch request for a learner. + buildVouch: async (learnerAddress: string): Promise> => { + const res = await api.post('/vouching/approve', { submitVouch: async (learnerAddress: string, _?: string): Promise => { const res = await api.post('/vouching/approve', { learnerWallet: learnerAddress, }) - return res.data + return res.data.data + }, + + declineVouch: async (learnerAddress: string): Promise => { + await api.post('/vouching/decline', { learnerWallet: learnerAddress }) }, // DELETE /vouching/:id — mentor revokes a vouch they created. diff --git a/src/stores/wallet.store.ts b/src/stores/wallet.store.ts index ac7a60a..b29026c 100644 --- a/src/stores/wallet.store.ts +++ b/src/stores/wallet.store.ts @@ -1,6 +1,21 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { WalletType } from '../types' +import { signTransaction } from '@stellar/freighter-api' + +export interface WalletAdapter { + sign: (unsignedXdr: string, networkPassphrase: string) => Promise +} + +export const freighterWalletAdapter: WalletAdapter = { + async sign(unsignedXdr, networkPassphrase) { + const response = await signTransaction(unsignedXdr, { networkPassphrase }) + if (response.error || !response.signedTxXdr) { + throw new Error(response.error ? response.error.message : 'Transaction signing was rejected.') + } + return response.signedTxXdr + }, +} interface WalletStore { address: string @@ -8,6 +23,7 @@ interface WalletStore { isConnected: boolean setWallet: (address: string, type: WalletType) => void disconnect: () => void + getAdapter: () => WalletAdapter } export const useWalletStore = create()( @@ -24,6 +40,11 @@ export const useWalletStore = create()( walletType: null, isConnected: false, }), + getAdapter: () => { + const { walletType } = useWalletStore.getState() + if (walletType === 'freighter') return freighterWalletAdapter + throw new Error('The connected wallet does not support transaction signing yet.') + }, }), { name: 'stepfi-wallet' } ) diff --git a/src/types/index.ts b/src/types/index.ts index 255f252..79f5aff 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -export type WalletType = 'freighter' | 'lobstr' | null +export type WalletType = 'freighter' | 'albedo' | 'stellar-wallets-kit' | 'lobstr' | null /** Matches backend UserProfileDto (GET /users/me `data` payload) */ export interface UserProfile { @@ -61,7 +61,7 @@ export interface Vendor { } /** Matches backend TransactionType enum (submit-transaction-request.dto.ts) */ -export type TransactionType = 'deposit' | 'withdraw' | 'loan_create' | 'loan_repay' +export type TransactionType = 'deposit' | 'withdraw' | 'loan_create' | 'loan_repay' | 'vouch' /** Matches backend LiquidityDepositPreviewDto */ export interface DepositPreview { @@ -98,6 +98,14 @@ export interface SubmittedTransaction { status: 'pending' } +export type TransactionStatus = 'pending' | 'confirmed' | 'failed' | 'expired' + +export interface TransactionStatusResponse { + transactionHash: string + status: TransactionStatus + error?: string +} + export interface PoolInfo { totalDeposits: number totalLiquidity: number