Skip to content

Commit d8322e8

Browse files
authored
Merge branch 'main' into feature/multisig-arbitration
2 parents fe48d30 + 8d63b6e commit d8322e8

8 files changed

Lines changed: 665 additions & 15 deletions

File tree

api/src/error-reporting.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Centralised error reporting for the API client layer.
3+
*
4+
* - Structured in-memory log (last 100 entries)
5+
* - Remote reporting via POST /api/errors (best-effort, never throws)
6+
* - User-friendly message map for contract codes, HTTP statuses, named codes
7+
*/
8+
9+
export interface ErrorEntry {
10+
ts: string;
11+
level: 'error' | 'warn';
12+
message: string;
13+
code?: string | number;
14+
status?: number;
15+
context?: Record<string, unknown>;
16+
}
17+
18+
// ── In-memory log ─────────────────────────────────────────────────────────────
19+
20+
const _log: ErrorEntry[] = [];
21+
const MAX_LOG = 100;
22+
23+
export function logError(
24+
message: string,
25+
opts: Omit<ErrorEntry, 'ts' | 'level' | 'message'> = {}
26+
): ErrorEntry {
27+
const entry: ErrorEntry = { ts: new Date().toISOString(), level: 'error', message, ...opts };
28+
_log.unshift(entry);
29+
if (_log.length > MAX_LOG) _log.pop();
30+
console.error('[api-error]', message, opts);
31+
return entry;
32+
}
33+
34+
export function logWarn(
35+
message: string,
36+
opts: Omit<ErrorEntry, 'ts' | 'level' | 'message'> = {}
37+
): ErrorEntry {
38+
const entry: ErrorEntry = { ts: new Date().toISOString(), level: 'warn', message, ...opts };
39+
_log.unshift(entry);
40+
if (_log.length > MAX_LOG) _log.pop();
41+
console.warn('[api-warn]', message, opts);
42+
return entry;
43+
}
44+
45+
export function getErrorLog(): ErrorEntry[] {
46+
return [..._log];
47+
}
48+
49+
// ── Remote reporting ──────────────────────────────────────────────────────────
50+
51+
const REPORT_ENDPOINT = '/api/errors';
52+
53+
export async function reportError(
54+
error: unknown,
55+
context: Record<string, unknown> = {}
56+
): Promise<void> {
57+
const message = error instanceof Error ? error.message : String(error);
58+
const stack = error instanceof Error ? error.stack : undefined;
59+
const entry = logError(message, { context: { stack, ...context } });
60+
try {
61+
await fetch(REPORT_ENDPOINT, {
62+
method: 'POST',
63+
headers: { 'Content-Type': 'application/json' },
64+
body: JSON.stringify(entry),
65+
keepalive: true, // survives page unload
66+
});
67+
} catch { /* best-effort — never throw */ }
68+
}
69+
70+
// ── User-friendly message map ─────────────────────────────────────────────────
71+
72+
const MESSAGES: Record<string | number, string> = {
73+
// Contract error codes
74+
1: 'This contract has already been set up.',
75+
2: 'Contract is not yet initialized.',
76+
3: 'Amount must be greater than zero.',
77+
4: 'Fee must be between 0 and 100%.',
78+
5: 'This arbitrator is not registered.',
79+
6: 'Trade not found. Check the trade ID.',
80+
7: 'This action is not allowed in the current trade state.',
81+
8: 'A calculation error occurred. Please try again.',
82+
9: 'No fees available to withdraw.',
83+
10: 'You are not authorized to perform this action.',
84+
11: 'The contract is currently paused.',
85+
// HTTP status codes
86+
400: 'Invalid request. Please check your input.',
87+
401: 'Session expired. Please reconnect your wallet.',
88+
403: 'You do not have permission to do that.',
89+
404: 'The requested resource was not found.',
90+
408: 'Request timed out. Please try again.',
91+
429: 'Too many requests. Please wait a moment.',
92+
500: 'Server error. Our team has been notified.',
93+
503: 'Service unavailable. You may be offline.',
94+
// Named API codes
95+
DATABASE_ERROR: 'A database error occurred. Please try again shortly.',
96+
STELLAR_ERROR: 'Could not reach the Stellar network. Check your connection.',
97+
NETWORK_ERROR: 'Network error. Please check your internet connection.',
98+
INVALID_FORMAT: 'Unexpected data format received.',
99+
RATE_LIMITED: 'Too many requests. Please slow down.',
100+
};
101+
102+
export function friendlyMessage(error: unknown): string {
103+
if (!error) return 'An unexpected error occurred. Please try again.';
104+
const e = error as any;
105+
if (e.code !== undefined) return MESSAGES[e.code] ?? e.message ?? 'An unexpected error occurred.';
106+
if (e.status !== undefined) return MESSAGES[e.status] ?? 'An unexpected error occurred.';
107+
if (error instanceof TypeError && String(error.message).includes('fetch')) return MESSAGES[503];
108+
return (error instanceof Error ? error.message : String(error)) || 'An unexpected error occurred.';
109+
}

app/src/App.tsx

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,28 @@ import { Routes, Route, NavLink } from 'react-router-dom';
22
import Dashboard from './pages/Dashboard';
33
import TradeDetail from './pages/TradeDetail';
44
import CreateTrade from './pages/CreateTrade';
5+
import { ErrorBoundary } from './ErrorBoundary';
56
import './App.css';
67

78
export default function App() {
89
return (
9-
<div className="app">
10-
<nav className="nav">
11-
<span className="nav-brand">StellarEscrow</span>
12-
<NavLink to="/" end>Dashboard</NavLink>
13-
<NavLink to="/trades/new">New Trade</NavLink>
14-
</nav>
15-
<main className="main">
16-
<Routes>
17-
<Route path="/" element={<Dashboard />} />
18-
<Route path="/trades/new" element={<CreateTrade />} />
19-
<Route path="/trades/:id" element={<TradeDetail />} />
20-
</Routes>
21-
</main>
22-
</div>
10+
<ErrorBoundary>
11+
<div className="app">
12+
<nav className="nav">
13+
<span className="nav-brand">StellarEscrow</span>
14+
<NavLink to="/" end>Dashboard</NavLink>
15+
<NavLink to="/trades/new">New Trade</NavLink>
16+
</nav>
17+
<main className="main">
18+
<ErrorBoundary>
19+
<Routes>
20+
<Route path="/" element={<Dashboard />} />
21+
<Route path="/trades/new" element={<CreateTrade />} />
22+
<Route path="/trades/:id" element={<TradeDetail />} />
23+
</Routes>
24+
</ErrorBoundary>
25+
</main>
26+
</div>
27+
</ErrorBoundary>
2328
);
2429
}

app/src/ErrorBoundary.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Component, ErrorInfo, ReactNode } from 'react';
2+
3+
interface Props {
4+
children: ReactNode;
5+
fallback?: ReactNode;
6+
}
7+
8+
interface State {
9+
hasError: boolean;
10+
error: Error | null;
11+
retryCount: number;
12+
}
13+
14+
const MAX_RETRIES = 3;
15+
16+
// ── User-friendly message map ─────────────────────────────────────────────────
17+
18+
const MESSAGES: Record<string | number, string> = {
19+
// Contract error codes
20+
3: 'Amount must be greater than zero.',
21+
5: 'This arbitrator is not registered.',
22+
6: 'Trade not found. Check the trade ID.',
23+
7: 'This action is not allowed in the current trade state.',
24+
9: 'No fees available to withdraw.',
25+
10: 'You are not authorized to perform this action.',
26+
11: 'The contract is currently paused.',
27+
// HTTP status codes
28+
400: 'Invalid request. Please check your input.',
29+
401: 'Session expired. Please reconnect your wallet.',
30+
403: 'You do not have permission to do that.',
31+
404: 'The requested resource was not found.',
32+
429: 'Too many requests. Please wait a moment.',
33+
500: 'Server error. Our team has been notified.',
34+
503: 'Service unavailable. You may be offline.',
35+
};
36+
37+
export function friendlyMessage(error: Error | null): string {
38+
if (!error) return 'An unexpected error occurred. Please try again.';
39+
const code = (error as any).code;
40+
if (code !== undefined) return MESSAGES[code] ?? error.message;
41+
return error.message || 'An unexpected error occurred. Please try again.';
42+
}
43+
44+
// ── Error Boundary ────────────────────────────────────────────────────────────
45+
46+
/**
47+
* Global React error boundary.
48+
* Catches render/lifecycle errors, logs them, reports remotely, and shows
49+
* a recovery UI with retry (up to MAX_RETRIES) and full-page reload options.
50+
*/
51+
export class ErrorBoundary extends Component<Props, State> {
52+
state: State = { hasError: false, error: null, retryCount: 0 };
53+
54+
static getDerivedStateFromError(error: Error): Partial<State> {
55+
return { hasError: true, error };
56+
}
57+
58+
componentDidCatch(error: Error, info: ErrorInfo) {
59+
// Structured log to console
60+
console.error('[ErrorBoundary]', {
61+
message: error.message,
62+
stack: error.stack,
63+
componentStack: info.componentStack,
64+
ts: new Date().toISOString(),
65+
});
66+
// Best-effort remote report — never throws
67+
try {
68+
navigator.sendBeacon?.(
69+
'/api/errors',
70+
JSON.stringify({
71+
message: error.message,
72+
stack: error.stack,
73+
componentStack: info.componentStack,
74+
ts: new Date().toISOString(),
75+
})
76+
);
77+
} catch { /* ignore */ }
78+
}
79+
80+
private retry = () => {
81+
this.setState((s) => ({
82+
hasError: false,
83+
error: null,
84+
retryCount: s.retryCount + 1,
85+
}));
86+
};
87+
88+
render() {
89+
if (!this.state.hasError) return this.props.children;
90+
if (this.props.fallback) return this.props.fallback;
91+
92+
return (
93+
<div role="alert" aria-live="assertive" style={{ padding: '2rem', textAlign: 'center' }}>
94+
<h2>Something went wrong</h2>
95+
<p style={{ color: '#666', marginBottom: '1rem' }}>
96+
{friendlyMessage(this.state.error)}
97+
</p>
98+
{this.state.retryCount < MAX_RETRIES && (
99+
<button onClick={this.retry} style={{ marginRight: '0.5rem' }}>
100+
Try again
101+
</button>
102+
)}
103+
<button onClick={() => window.location.reload()}>Reload page</button>
104+
</div>
105+
);
106+
}
107+
}

contract/src/amm.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ pub struct Pool {
4343
pub fees_a: u64,
4444
/// Accumulated fees in token_b units.
4545
pub fees_b: u64,
46+
/// Total volume traded in token_a
47+
pub volume_a: u64,
48+
/// Total volume traded in token_b
49+
pub volume_b: u64,
50+
/// Pool creation timestamp
51+
pub created_at: u64,
52+
/// Last swap timestamp
53+
pub last_swap_at: u64,
4654
}
4755

4856
/// LP position for a single provider in a pool.
@@ -55,6 +63,11 @@ pub struct LpPosition {
5563
pub fee_debt_a: u64,
5664
/// Snapshot of fees_b at last claim.
5765
pub fee_debt_b: u64,
66+
/// Timestamp when position was created
67+
pub created_at: u64,
68+
/// Total rewards earned (for yield farming tracking)
69+
pub total_rewards_a: u64,
70+
pub total_rewards_b: u64,
5871
}
5972

6073
/// Result returned from a swap.
@@ -101,7 +114,15 @@ fn load_lp(env: &Env, pool_id: u64, provider: &Address) -> LpPosition {
101114
env.storage()
102115
.persistent()
103116
.get(&lp_key(env, pool_id, provider))
104-
.unwrap_or(LpPosition { pool_id, shares: 0, fee_debt_a: 0, fee_debt_b: 0 })
117+
.unwrap_or(LpPosition {
118+
pool_id,
119+
shares: 0,
120+
fee_debt_a: 0,
121+
fee_debt_b: 0,
122+
created_at: env.ledger().timestamp(),
123+
total_rewards_a: 0,
124+
total_rewards_b: 0,
125+
})
105126
}
106127

107128
fn save_lp(env: &Env, provider: &Address, pos: &LpPosition) {
@@ -151,6 +172,10 @@ pub fn create_pool(
151172
fee_bps,
152173
fees_a: 0,
153174
fees_b: 0,
175+
volume_a: 0,
176+
volume_b: 0,
177+
created_at: env.ledger().timestamp(),
178+
last_swap_at: 0,
154179
};
155180
save_pool(env, &pool);
156181
Ok(id)
@@ -369,11 +394,14 @@ pub fn swap(
369394
pool.reserve_a = pool.reserve_a.checked_add(amount_in).ok_or(ContractError::Overflow)?;
370395
pool.reserve_b = pool.reserve_b.checked_sub(amount_out).ok_or(ContractError::Overflow)?;
371396
pool.fees_a = pool.fees_a.checked_add(fee).ok_or(ContractError::Overflow)?;
397+
pool.volume_a = pool.volume_a.checked_add(amount_in).ok_or(ContractError::Overflow)?;
372398
} else {
373399
pool.reserve_b = pool.reserve_b.checked_add(amount_in).ok_or(ContractError::Overflow)?;
374400
pool.reserve_a = pool.reserve_a.checked_sub(amount_out).ok_or(ContractError::Overflow)?;
375401
pool.fees_b = pool.fees_b.checked_add(fee).ok_or(ContractError::Overflow)?;
402+
pool.volume_b = pool.volume_b.checked_add(amount_in).ok_or(ContractError::Overflow)?;
376403
}
404+
pool.last_swap_at = env.ledger().timestamp();
377405
save_pool(env, &pool);
378406

379407
Ok(SwapResult { amount_out, fee_charged: fee, price_impact_bps })
@@ -489,9 +517,11 @@ fn claim_yield_inner(
489517
let contract = env.current_contract_address();
490518
if owed_a > 0 {
491519
token::Client::new(env, &pool.token_a).transfer(&contract, provider, &(owed_a as i128));
520+
pos.total_rewards_a = pos.total_rewards_a.checked_add(owed_a).ok_or(ContractError::Overflow)?;
492521
}
493522
if owed_b > 0 {
494523
token::Client::new(env, &pool.token_b).transfer(&contract, provider, &(owed_b as i128));
524+
pos.total_rewards_b = pos.total_rewards_b.checked_add(owed_b).ok_or(ContractError::Overflow)?;
495525
}
496526

497527
pos.fee_debt_a = pool.fees_a;

contract/src/errors.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,7 @@ pub enum ContractError {
104104
VotingNotExpired = 73,
105105
/// No consensus reached among arbitrators.
106106
NoConsensus = 74,
107+
// Social feature errors (70-74)
108+
CannotFollowSelf = 70,
109+
NotFollowing = 71,
107110
}

0 commit comments

Comments
 (0)