From 3300412064a27d4a866dc6e1e66bfb6406ada226 Mon Sep 17 00:00:00 2001 From: Otowo Samuel Date: Mon, 29 Jun 2026 15:24:25 +0000 Subject: [PATCH 1/2] Implement S4 frontend/server reliability improvements --- nevo_frontend/.env.example | 4 ++ nevo_frontend/hooks/useXdrTransaction.ts | 73 ++++++++++++++++++++ nevo_frontend/lib/api-client.ts | 38 ++-------- nevo_frontend/lib/auth-storage.ts | 25 +++++++ nevo_frontend/lib/env.ts | 27 ++++++++ nevo_frontend/lib/jwt-storage.ts | 27 ++------ nevo_frontend/lib/stellar.ts | 16 +++++ nevo_frontend/src/store/walletStore.ts | 22 ++++-- nevo_server/src/pools/dto/create-pool.dto.ts | 36 +++++----- nevo_server/src/pools/dto/donate-pool.dto.ts | 7 ++ nevo_server/src/pools/dto/index.ts | 1 + nevo_server/src/pools/pools.controller.ts | 30 ++------ nevo_server/src/pools/pools.service.ts | 3 +- 13 files changed, 206 insertions(+), 103 deletions(-) create mode 100644 nevo_frontend/hooks/useXdrTransaction.ts create mode 100644 nevo_frontend/lib/auth-storage.ts create mode 100644 nevo_frontend/lib/env.ts create mode 100644 nevo_server/src/pools/dto/donate-pool.dto.ts diff --git a/nevo_frontend/.env.example b/nevo_frontend/.env.example index 7e05e93a..096c0d6f 100644 --- a/nevo_frontend/.env.example +++ b/nevo_frontend/.env.example @@ -6,3 +6,7 @@ NEXT_PUBLIC_SENTRY_DSN= SENTRY_ORG= SENTRY_PROJECT= SENTRY_AUTH_TOKEN= + +# Required frontend runtime config +NEXT_PUBLIC_API_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_STELLAR_NETWORK=testnet diff --git a/nevo_frontend/hooks/useXdrTransaction.ts b/nevo_frontend/hooks/useXdrTransaction.ts new file mode 100644 index 00000000..1a8b4b65 --- /dev/null +++ b/nevo_frontend/hooks/useXdrTransaction.ts @@ -0,0 +1,73 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { signTransaction } from '@/lib/stellar'; + +type TxStatus = 'idle' | 'signing' | 'submitting' | 'success' | 'error'; + +type XdrPayload = string | { unsignedXdr: string }; + +type GetXdrFn = () => Promise; +type SubmitXdrFn = ( + signedXdr: string +) => Promise; + +function extractUnsignedXdr(payload: XdrPayload): string { + if (typeof payload === 'string') { + return payload; + } + + return payload.unsignedXdr; +} + +function extractTxHash( + result: string | { txHash?: string; hash?: string } +): string { + if (typeof result === 'string') { + return result; + } + + return result.txHash ?? result.hash ?? ''; +} + +export function useXdrTransaction() { + const [status, setStatus] = useState('idle'); + const [txHash, setTxHash] = useState(null); + const [error, setError] = useState(null); + + const submit = useCallback( + async (getXdr: GetXdrFn, submitXdr: SubmitXdrFn) => { + setStatus('signing'); + setError(null); + setTxHash(null); + + try { + const xdrPayload = await getXdr(); + const unsignedXdr = extractUnsignedXdr(xdrPayload); + const signedXdr = await signTransaction(unsignedXdr); + + setStatus('submitting'); + const submitResult = await submitXdr(signedXdr); + const hash = extractTxHash(submitResult); + + setTxHash(hash || null); + setStatus('success'); + return hash; + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to submit transaction'; + setError(message); + setStatus('error'); + throw err; + } + }, + [] + ); + + return { + submit, + status, + txHash, + error, + }; +} diff --git a/nevo_frontend/lib/api-client.ts b/nevo_frontend/lib/api-client.ts index 4e59d54f..3024cae4 100644 --- a/nevo_frontend/lib/api-client.ts +++ b/nevo_frontend/lib/api-client.ts @@ -8,9 +8,12 @@ import { parseRetryAfterHeader, resolveRateLimitOptions, } from './rate-limit'; -import { getStoredAccessToken } from './jwt-storage'; +import { env, validatePublicEnv } from './env'; +import { getToken } from './auth-storage'; import { toast } from '../components/Toast'; +validatePublicEnv(); + export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; export interface RequestConfig extends Omit { @@ -79,10 +82,7 @@ export class ApiClient { defaultTimeout: number = 15000, rateLimit: Partial = DEFAULT_RATE_LIMIT_OPTIONS ) { - this.baseURL = - baseURL || - process.env.NEXT_PUBLIC_API_BASE_URL || - 'http://localhost:3000'; + this.baseURL = baseURL || env.NEXT_PUBLIC_API_BASE_URL; this.defaultTimeout = defaultTimeout; this.defaultRateLimit = resolveRateLimitOptions(rateLimit); this.rateLimiter = new ClientRateLimiter(); @@ -99,7 +99,7 @@ export class ApiClient { } const headers = new Headers(config.headers); - const accessToken = getStoredAccessToken(); + const accessToken = getToken(); if (accessToken) { headers.set('Authorization', `Bearer ${accessToken}`); @@ -492,32 +492,6 @@ export { isRateLimitError, } from './rate-limit'; -// Add default auth interceptor for wallet signature and JWT -apiClient.addRequestInterceptor((config) => { - if (config.requireAuth !== false) { - const headers = new Headers(config.headers); - - // Get access token from wallet store (via localStorage since we can't import zustand here) - if (typeof window !== 'undefined') { - const walletStoreData = localStorage.getItem('nevo-wallet'); - if (walletStoreData) { - try { - const parsed = JSON.parse(walletStoreData); - const state = parsed.state || parsed; - if (state.accessToken) { - headers.set('Authorization', `Bearer ${state.accessToken}`); - } - } catch { - // Ignore parsing errors - } - } - } - - config.headers = headers; - } - return config; -}); - export interface ApiDonation { id: string; type: 'donation' | 'pool_creation' | 'withdrawal'; diff --git a/nevo_frontend/lib/auth-storage.ts b/nevo_frontend/lib/auth-storage.ts new file mode 100644 index 00000000..86ff3993 --- /dev/null +++ b/nevo_frontend/lib/auth-storage.ts @@ -0,0 +1,25 @@ +const TOKEN_KEY = 'nevo_jwt'; + +export function getToken(): string | null { + if (typeof window === 'undefined') { + return null; + } + + return window.localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken(): void { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.removeItem(TOKEN_KEY); +} diff --git a/nevo_frontend/lib/env.ts b/nevo_frontend/lib/env.ts new file mode 100644 index 00000000..86fa7849 --- /dev/null +++ b/nevo_frontend/lib/env.ts @@ -0,0 +1,27 @@ +const REQUIRED_PUBLIC_ENV_VARS = [ + 'NEXT_PUBLIC_API_BASE_URL', + 'NEXT_PUBLIC_STELLAR_NETWORK', +] as const; + +type RequiredPublicEnvVar = (typeof REQUIRED_PUBLIC_ENV_VARS)[number]; + +function getRequiredPublicEnvVar(name: RequiredPublicEnvVar): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +export function validatePublicEnv(): void { + REQUIRED_PUBLIC_ENV_VARS.forEach((name) => { + getRequiredPublicEnvVar(name); + }); +} + +export const env = { + NEXT_PUBLIC_API_BASE_URL: getRequiredPublicEnvVar('NEXT_PUBLIC_API_BASE_URL'), + NEXT_PUBLIC_STELLAR_NETWORK: getRequiredPublicEnvVar( + 'NEXT_PUBLIC_STELLAR_NETWORK' + ), +} as const; diff --git a/nevo_frontend/lib/jwt-storage.ts b/nevo_frontend/lib/jwt-storage.ts index 4b016765..861da9de 100644 --- a/nevo_frontend/lib/jwt-storage.ts +++ b/nevo_frontend/lib/jwt-storage.ts @@ -1,28 +1,9 @@ -export function getStoredAccessToken(): string | null { - if (typeof window === 'undefined') { - return null; - } - - const stored = window.localStorage.getItem('nevo-wallet'); - if (!stored) { - return null; - } +import { clearToken, getToken } from './auth-storage'; - try { - const parsed = JSON.parse(stored); - const state = parsed?.state ?? parsed; - const accessToken = - typeof state?.accessToken === 'string' ? state.accessToken : null; - return accessToken; - } catch { - return null; - } +export function getStoredAccessToken(): string | null { + return getToken(); } export function clearJwt(): void { - if (typeof window === 'undefined') { - return; - } - - window.localStorage.removeItem('nevo-wallet'); + clearToken(); } diff --git a/nevo_frontend/lib/stellar.ts b/nevo_frontend/lib/stellar.ts index b4dfd547..0b9a6f3f 100644 --- a/nevo_frontend/lib/stellar.ts +++ b/nevo_frontend/lib/stellar.ts @@ -5,6 +5,8 @@ import { parseRetryAfterHeader, resolveRateLimitOptions, } from './rate-limit'; +import { signTransaction as freighterSignTransaction } from '@stellar/freighter-api'; +import { Networks } from '@stellar/stellar-sdk'; export interface AccountBalances { xlm: string; @@ -12,6 +14,8 @@ export interface AccountBalances { } const HORIZON = 'https://horizon.stellar.org'; +const NETWORK_PASSPHRASE = + process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? Networks.TESTNET; const ZERO_BALANCES: AccountBalances = { xlm: '0', usdc: '0' }; const HORIZON_RATE_LIMIT = resolveRateLimitOptions({ maxRequests: 60, @@ -78,3 +82,15 @@ export async function getAccountBalances( return ZERO_BALANCES; } } + +export async function signTransaction(xdr: string): Promise { + const result = await freighterSignTransaction(xdr, { + networkPassphrase: NETWORK_PASSPHRASE, + }); + + if (result.error) { + throw new Error(result.error); + } + + return result.signedTxXdr; +} diff --git a/nevo_frontend/src/store/walletStore.ts b/nevo_frontend/src/store/walletStore.ts index 889c484b..417f3748 100644 --- a/nevo_frontend/src/store/walletStore.ts +++ b/nevo_frontend/src/store/walletStore.ts @@ -1,7 +1,12 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { getPublicKey, connect, disconnect, signWithWallet } from '@/app/stellar-wallets-kit'; -import { clearJwt } from '@/lib/jwt-storage'; +import { + getPublicKey, + connect, + disconnect, + signWithWallet, +} from '@/app/stellar-wallets-kit'; +import { clearToken, getToken, setToken } from '@/lib/auth-storage'; import { getAccountBalances, AccountBalances } from '@/lib/stellar'; import { fetchAuthChallenge, verifyAuthSignature } from '@/lib/api-client'; @@ -31,10 +36,11 @@ export const useWalletStore = create()( const key = await getPublicKey(); if (key) { const balances = await getAccountBalances(key); - const accessToken = get().accessToken; + const accessToken = get().accessToken ?? getToken(); set({ publicKey: key, balances, + accessToken, loading: false, isAuthenticated: !!accessToken, }); @@ -50,7 +56,12 @@ export const useWalletStore = create()( const balances = await getAccountBalances(key); const { nonce } = await fetchAuthChallenge(key); const signature = await signWithWallet(nonce); - const { accessToken } = await verifyAuthSignature(key, nonce, signature); + const { accessToken } = await verifyAuthSignature( + key, + nonce, + signature + ); + setToken(accessToken); set({ publicKey: key, balances, @@ -70,7 +81,7 @@ export const useWalletStore = create()( balances: null, isAuthenticated: false, }); - clearJwt(); + clearToken(); }, refreshBalances: async () => { @@ -81,6 +92,7 @@ export const useWalletStore = create()( }, setAccessToken: (token: string) => { + setToken(token); set({ accessToken: token, isAuthenticated: !!get().publicKey && !!token, diff --git a/nevo_server/src/pools/dto/create-pool.dto.ts b/nevo_server/src/pools/dto/create-pool.dto.ts index cc304ce3..1a5e88c3 100644 --- a/nevo_server/src/pools/dto/create-pool.dto.ts +++ b/nevo_server/src/pools/dto/create-pool.dto.ts @@ -1,34 +1,38 @@ -import { Type } from 'class-transformer'; import { - IsNumber, + IsNotEmpty, + IsNumberString, IsOptional, IsString, - IsUrl, MaxLength, - Min, - MinLength, } from 'class-validator'; export class CreatePoolDto { @IsString() - @MinLength(3) + @IsNotEmpty() + contractPoolId: string; + + @IsString() + @IsNotEmpty() + creatorWallet: string; + + @IsString() + @IsNotEmpty() @MaxLength(100) title: string; @IsString() - @MinLength(10) - @MaxLength(2000) + @IsNotEmpty() + @MaxLength(1000) description: string; - @IsString() - category: string; + @IsNumberString() + goal: string; - @Type(() => Number) - @IsNumber() - @Min(1) - goal: number; + @IsOptional() + @IsString() + category?: string; @IsOptional() - @IsUrl() - imageUrl?: string; + @IsString() + imageUrl?: string | null; } diff --git a/nevo_server/src/pools/dto/donate-pool.dto.ts b/nevo_server/src/pools/dto/donate-pool.dto.ts new file mode 100644 index 00000000..00e55034 --- /dev/null +++ b/nevo_server/src/pools/dto/donate-pool.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsNumberString } from 'class-validator'; + +export class DonatePoolDto { + @IsNumberString() + @IsNotEmpty() + amount: string; +} diff --git a/nevo_server/src/pools/dto/index.ts b/nevo_server/src/pools/dto/index.ts index f7a71686..1dd198a5 100644 --- a/nevo_server/src/pools/dto/index.ts +++ b/nevo_server/src/pools/dto/index.ts @@ -1,2 +1,3 @@ export { CreatePoolDto } from './create-pool.dto.js'; +export { DonatePoolDto } from './donate-pool.dto.js'; export { FilterPoolsDto } from './filter-pools.dto.js'; diff --git a/nevo_server/src/pools/pools.controller.ts b/nevo_server/src/pools/pools.controller.ts index 15748334..6ca9135b 100644 --- a/nevo_server/src/pools/pools.controller.ts +++ b/nevo_server/src/pools/pools.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - BadRequestException, ForbiddenException, Get, NotFoundException, @@ -20,16 +19,8 @@ import { GetPoolsDto } from './dto/get-pools.dto.js'; import { DonationsService } from '../donations/donations.service.js'; import { ContractService } from '../contract/contract.service.js'; import { StellarAuthGuard } from '../auth/stellar-auth.guard.js'; - -export interface CreatePoolDto { - contractPoolId: string; - creatorWallet: string; - goal: string; - title?: string; - description?: string; - category?: string; - imageUrl?: string; -} +import { CreatePoolDto } from './dto/create-pool.dto.js'; +import { DonatePoolDto } from './dto/donate-pool.dto.js'; export interface UpdatePoolDto { description?: string; @@ -45,11 +36,6 @@ export interface ClosePoolDto { requesterWallet: string; } -export interface DonateDto { - amount: number; - tokenAddress: string; -} - interface JwtPayload { sub: string; publicKey: string; @@ -76,11 +62,7 @@ export class PoolsController { } @Get(':id/donations') - getDonations( - @Param('id', ParseIntPipe) id: number, - @Query('page') page = '1', - @Query('limit') limit = '20', - ) { + getDonations(@Param('id', ParseIntPipe) id: number) { return this.donationsService.findByPool(String(id)); } @@ -124,13 +106,9 @@ export class PoolsController { @Post(':id/donate') async donate( @Param('id', ParseIntPipe) id: number, - @Body() dto: DonateDto, + @Body() dto: DonatePoolDto, @Req() req: Request & { user: JwtPayload }, ) { - if (!Number.isInteger(dto.amount) || dto.amount <= 0) { - throw new BadRequestException('amount must be a positive integer'); - } - const pool = await this.poolsService.findByContractId(String(id)); if (!pool) throw new NotFoundException('Pool not found'); diff --git a/nevo_server/src/pools/pools.service.ts b/nevo_server/src/pools/pools.service.ts index d9f5694b..0835ea1d 100644 --- a/nevo_server/src/pools/pools.service.ts +++ b/nevo_server/src/pools/pools.service.ts @@ -2,7 +2,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Pool, PoolStatus } from './pool.entity.js'; -import type { CreatePoolDto, UpdatePoolDto } from './pools.controller.js'; +import type { UpdatePoolDto } from './pools.controller.js'; +import type { CreatePoolDto } from './dto/create-pool.dto.js'; import type { GetPoolsDto } from './dto/get-pools.dto.js'; import { ContractService } from '../contract/contract.service.js'; From a4f89155bf68738d750a64b2ba5b306fed0a4d04 Mon Sep 17 00:00:00 2001 From: Otowo Samuel Date: Mon, 29 Jun 2026 15:32:54 +0000 Subject: [PATCH 2/2] Set required frontend env vars in CI build --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea635250..b8eec740 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: run: npm ci --legacy-peer-deps - name: Build + env: + NEXT_PUBLIC_API_BASE_URL: http://localhost:3000 + NEXT_PUBLIC_STELLAR_NETWORK: testnet run: npm run build server: