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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions nevo_frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +10 to +12
73 changes: 73 additions & 0 deletions nevo_frontend/hooks/useXdrTransaction.ts
Original file line number Diff line number Diff line change
@@ -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<XdrPayload>;
type SubmitXdrFn = (
signedXdr: string
) => Promise<string | { txHash?: string; hash?: string }>;

function extractUnsignedXdr(payload: XdrPayload): string {
if (typeof payload === 'string') {
return payload;
}

return payload.unsignedXdr;
Comment on lines +16 to +20
}

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<TxStatus>('idle');
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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;
Comment on lines +51 to +55
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to submit transaction';
setError(message);
setStatus('error');
throw err;
}
},
[]
);

return {
submit,
status,
txHash,
error,
};
}
38 changes: 6 additions & 32 deletions nevo_frontend/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RequestInit, 'method' | 'body'> {
Expand Down Expand Up @@ -79,10 +82,7 @@ export class ApiClient {
defaultTimeout: number = 15000,
rateLimit: Partial<RateLimitOptions> = 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();
Expand All @@ -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}`);
Expand Down Expand Up @@ -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';
Expand Down
25 changes: 25 additions & 0 deletions nevo_frontend/lib/auth-storage.ts
Original file line number Diff line number Diff line change
@@ -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);
}
27 changes: 27 additions & 0 deletions nevo_frontend/lib/env.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +3 to +27
27 changes: 4 additions & 23 deletions nevo_frontend/lib/jwt-storage.ts
Original file line number Diff line number Diff line change
@@ -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();
}
16 changes: 16 additions & 0 deletions nevo_frontend/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ 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;
usdc: string;
}

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' };
Comment on lines 16 to 19
const HORIZON_RATE_LIMIT = resolveRateLimitOptions({
maxRequests: 60,
Expand Down Expand Up @@ -78,3 +82,15 @@ export async function getAccountBalances(
return ZERO_BALANCES;
}
}

export async function signTransaction(xdr: string): Promise<string> {
const result = await freighterSignTransaction(xdr, {
networkPassphrase: NETWORK_PASSPHRASE,
});

if (result.error) {
throw new Error(result.error);
}

return result.signedTxXdr;
}
22 changes: 17 additions & 5 deletions nevo_frontend/src/store/walletStore.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -31,10 +36,11 @@ export const useWalletStore = create<WalletState>()(
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,
});
Expand All @@ -50,7 +56,12 @@ export const useWalletStore = create<WalletState>()(
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,
Expand All @@ -70,7 +81,7 @@ export const useWalletStore = create<WalletState>()(
balances: null,
isAuthenticated: false,
});
clearJwt();
clearToken();
},

refreshBalances: async () => {
Expand All @@ -81,6 +92,7 @@ export const useWalletStore = create<WalletState>()(
},

setAccessToken: (token: string) => {
setToken(token);
set({
accessToken: token,
isAuthenticated: !!get().publicKey && !!token,
Expand Down
36 changes: 20 additions & 16 deletions nevo_server/src/pools/dto/create-pool.dto.ts
Original file line number Diff line number Diff line change
@@ -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;

Comment on lines 27 to 30
@Type(() => Number)
@IsNumber()
@Min(1)
goal: number;
@IsOptional()
@IsString()
category?: string;

@IsOptional()
@IsUrl()
imageUrl?: string;
@IsString()
imageUrl?: string | null;
}
Loading
Loading