From 50c021b8f332221f165882f1c0ff4d084cb5ee5e Mon Sep 17 00:00:00 2001 From: barry01_hash Date: Sat, 18 Jul 2026 11:35:20 +0100 Subject: [PATCH] feat: unify wallet transaction lifecycle (#55 #56 #66) --- src/hooks/useTransaction.ts | 77 +++-------------- src/pages/Sponsors.tsx | 33 ++------ src/pages/Vouch.tsx | 59 ++++--------- .../__tests__/transaction.service.test.ts | 42 ++++++++++ src/services/transaction.service.ts | 82 +++++++++++++++++++ src/services/transactions.service.ts | 7 +- src/services/vouching.service.ts | 12 ++- src/stores/wallet.store.ts | 21 +++++ src/types/index.ts | 12 ++- 9 files changed, 205 insertions(+), 140 deletions(-) create mode 100644 src/services/__tests__/transaction.service.test.ts create mode 100644 src/services/transaction.service.ts 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 56fb0ce..862ba46 100644 --- a/src/pages/Sponsors.tsx +++ b/src/pages/Sponsors.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { sponsorsService } from '../services/sponsors.service' -import { transactionsService } from '../services/transactions.service' import { useTransaction } from '../hooks/useTransaction' import { useWallet } from '../hooks/useWallet' import { useToast } from '../hooks/useToast' @@ -51,18 +50,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('') toast.success('Deposit submitted successfully.') } catch (error) { @@ -77,20 +67,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('') toast.success('Withdrawal submitted successfully.') } catch (error) { diff --git a/src/pages/Vouch.tsx b/src/pages/Vouch.tsx index 798bfc1..02eccc3 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, useMutation, useQueryClient } 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 { Card } from '../components/ui/Card' import { Button } from '../components/ui/Button' @@ -12,7 +11,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 = { @@ -98,7 +97,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: ['vouch-requests'], @@ -110,17 +109,14 @@ export function Vouch() { queryFn: vouchingService.getMyVouches, }) - const submitMutation = useMutation({ - mutationFn: ({ learnerAddress, txHash }: { learnerAddress: string; txHash: string }) => - vouchingService.submitVouch(learnerAddress, txHash), + const declineMutation = useMutation({ + mutationFn: vouchingService.declineVouch, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vouch-requests'] }) - queryClient.invalidateQueries({ queryKey: ['my-vouches'] }) - setPreviewRequest(null) - toast.success('Vouch submitted successfully.') + toast.success('Vouch request declined.') }, onError: (error) => { - const message = error instanceof Error ? error.message : 'Failed to submit vouch.' + const message = error instanceof Error ? error.message : 'Failed to decline vouch.' toast.error(message) }, }) @@ -146,41 +142,22 @@ export function Vouch() { await connectFreighter() } - const connection = await isConnected() - if (!connection.isConnected) { - throw new Error('Freighter not installed. Download at freighter.app') - } - - const access = await requestAccess() - if (access.error) { - throw new Error(access.error.message) - } - - const txXdr = `AAAAAgAAAABz...${Math.random().toString(36).slice(2)}` - const result = await signTransaction(txXdr, { - networkPassphrase: - STELLAR_NETWORK === 'TESTNET' - ? 'Test SDF Network ; September 2015' - : 'Public Global Stellar Network ; September 2015', - }) - - const txHash = 'signedTxXdr' in result ? (result as { signedTxXdr: string }).signedTxXdr : '' - - submitMutation.mutate({ - learnerAddress: previewRequest.learnerAddress, - txHash, - }) + 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.') } catch (err) { const message = err instanceof Error ? err.message : 'Transaction failed' toast.error(message) } } - 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 = [ { @@ -298,7 +275,7 @@ export function Vouch() { } onVouch={setPreviewRequest} onDecline={handleDecline} - declining={decliningId === request.id} + declining={declineMutation.isPending && declineMutation.variables === request.id} /> ))} @@ -450,7 +427,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 d438b8f..8dab23e 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,15 @@ export const vouchingService = { }, // POST /vouching/approve — mentor approves a pending vouch request for a learner. - submitVouch: async (learnerAddress: string, _txHash?: string): Promise => { - const res = await api.post('/vouching/approve', { + buildVouch: async (learnerAddress: 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 541c4a5..4cba037 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