Skip to content

Commit 6da86ca

Browse files
authored
Merge branch 'main' into FEATURE
2 parents febb03d + 04d0e33 commit 6da86ca

7 files changed

Lines changed: 386 additions & 6 deletions

File tree

contracts/src/lib.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,29 @@ fn guard_ledger_timestamp(env: &Env) -> u64 {
5050

5151
#[contractimpl]
5252
impl PortfolioRebalancer {
53+
/// Validate that `reflector_address` behaves like a Reflector oracle by
54+
/// making a lightweight read-only call (`base()`) and checking the result.
55+
///
56+
/// This is a best-effort guard, not a full interface conformance check:
57+
/// - A malicious contract could implement `base()` but return wrong data in
58+
/// `lastprice()`. Further operations (rebalance, preview) will detect
59+
/// missing or invalid price data at the point of use.
60+
/// - The goal is to fail early when the address is clearly not a Reflector
61+
/// contract (e.g. a typo, an EOA, or a random contract).
5362
pub fn initialize(env: Env, admin: Address, reflector_address: Address) -> Result<(), Error> {
5463
if env.storage().instance().has(&DataKey::Initialized) {
5564
return Err(Error::AlreadyInitialized);
5665
}
66+
67+
// Lightweight validation: call base() on the provided address.
68+
// If the call fails (host error) or returns an unexpected type, the
69+
// address is not a valid Reflector oracle.
70+
let reflector_client = ReflectorClient::new(&env, &reflector_address);
71+
match reflector_client.try_base() {
72+
Ok(Ok(_asset)) => {}
73+
_ => return Err(Error::InvalidOracleAddress),
74+
}
75+
5776
env.storage().instance().set(&DataKey::Admin, &admin);
5877
env.storage()
5978
.instance()

contracts/src/test.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,22 @@ mod reflector_without_prices {
170170
}
171171
}
172172

173+
// A contract that does NOT implement the Reflector interface.
174+
// Used to verify that initialize() rejects non-conforming addresses.
175+
mod non_reflector_contract {
176+
use soroban_sdk::{contract, contractimpl, Env};
177+
178+
#[contract]
179+
pub struct NonReflector;
180+
181+
#[contractimpl]
182+
impl NonReflector {
183+
pub fn hello(_env: Env) -> bool {
184+
true
185+
}
186+
}
187+
}
188+
173189
#[test]
174190
fn test_create_portfolio() {
175191
let env = Env::default();
@@ -1180,6 +1196,32 @@ fn test_initialize_guard() {
11801196
client.initialize(&admin, &reflector_id);
11811197
}
11821198

1199+
#[test]
1200+
fn test_initialize_rejects_invalid_reflector_address() {
1201+
let env = Env::default();
1202+
env.mock_all_auths();
1203+
let contract_id = env.register_contract(None, PortfolioRebalancer);
1204+
let client = PortfolioRebalancerClient::new(&env, &contract_id);
1205+
let non_reflector_id = env.register_contract(None, non_reflector_contract::NonReflector);
1206+
let admin = Address::generate(&env);
1207+
1208+
let result = client.try_initialize(&admin, &non_reflector_id);
1209+
assert_eq!(result, Err(Ok(Error::InvalidOracleAddress)));
1210+
}
1211+
1212+
#[test]
1213+
fn test_initialize_accepts_valid_reflector_address() {
1214+
let env = Env::default();
1215+
env.mock_all_auths();
1216+
let contract_id = env.register_contract(None, PortfolioRebalancer);
1217+
let client = PortfolioRebalancerClient::new(&env, &contract_id);
1218+
let reflector_id = env.register_contract(None, reflector_contract::MockReflector);
1219+
let admin = Address::generate(&env);
1220+
1221+
let result = client.try_initialize(&admin, &reflector_id);
1222+
assert_eq!(result, Ok(Ok(())));
1223+
}
1224+
11831225
#[test]
11841226
#[should_panic]
11851227
fn test_create_portfolio_invalid_allocation() {

contracts/src/types.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ pub enum Error {
195195
InvalidAmount = 26,
196196
WithdrawFailed = 27,
197197
InvalidAllocationSum = 28,
198-
TimelockNotElapsed = 29,
198+
InvalidOracleAddress = 29,
199199
}
200200

201201
#[contracttype]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest'
2+
import { render, screen, cleanup } from '@testing-library/react'
3+
4+
vi.mock('../observability', () => ({
5+
Sentry: {
6+
captureException: vi.fn(),
7+
},
8+
}))
9+
10+
vi.mock('../utils/walletManager', () => ({
11+
walletManager: {
12+
getPublicKey: vi.fn(() => null),
13+
},
14+
}))
15+
16+
import { ErrorBoundary } from './ErrorBoundary'
17+
import { Sentry } from '../observability'
18+
19+
function Bomb() {
20+
throw new Error('Test render error')
21+
}
22+
23+
function Safe() {
24+
return <div>Safe content</div>
25+
}
26+
27+
describe('ErrorBoundary', () => {
28+
afterEach(() => {
29+
cleanup()
30+
vi.clearAllMocks()
31+
})
32+
33+
it('renders children when no error occurs', () => {
34+
render(
35+
<ErrorBoundary>
36+
<Safe />
37+
</ErrorBoundary>,
38+
)
39+
expect(screen.getByText('Safe content')).toBeInTheDocument()
40+
})
41+
42+
it('renders fallback UI when a child throws', () => {
43+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
44+
45+
render(
46+
<ErrorBoundary>
47+
<Bomb />
48+
</ErrorBoundary>,
49+
)
50+
51+
expect(screen.getByText(/section error/i)).toBeInTheDocument()
52+
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
53+
54+
consoleError.mockRestore()
55+
})
56+
57+
it('reports the error to Sentry with context', () => {
58+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
59+
60+
render(
61+
<ErrorBoundary fallbackTitle="PortfolioSection">
62+
<Bomb />
63+
</ErrorBoundary>,
64+
)
65+
66+
expect(Sentry.captureException).toHaveBeenCalledTimes(1)
67+
expect(Sentry.captureException).toHaveBeenCalledWith(
68+
expect.any(Error),
69+
expect.objectContaining({
70+
extra: expect.objectContaining({
71+
componentStack: expect.any(String),
72+
section: 'PortfolioSection',
73+
}),
74+
}),
75+
)
76+
77+
consoleError.mockRestore()
78+
})
79+
80+
it('resets error state on retry', () => {
81+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
82+
const onRetry = vi.fn()
83+
84+
const { rerender } = render(
85+
<ErrorBoundary key="first" onRetry={onRetry}>
86+
<Bomb />
87+
</ErrorBoundary>,
88+
)
89+
90+
expect(screen.getByText(/section error/i)).toBeInTheDocument()
91+
92+
rerender(
93+
<ErrorBoundary key="second" onRetry={onRetry}>
94+
<Safe />
95+
</ErrorBoundary>,
96+
)
97+
98+
expect(screen.getByText('Safe content')).toBeInTheDocument()
99+
100+
consoleError.mockRestore()
101+
})
102+
103+
it('calls onRetry when retry button is clicked', () => {
104+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
105+
const onRetry = vi.fn()
106+
107+
render(
108+
<ErrorBoundary onRetry={onRetry}>
109+
<Bomb />
110+
</ErrorBoundary>,
111+
)
112+
113+
const retryButton = screen.getByRole('button', { name: /retry/i })
114+
retryButton.click()
115+
116+
expect(onRetry).toHaveBeenCalledTimes(1)
117+
118+
consoleError.mockRestore()
119+
})
120+
})

frontend/src/components/ErrorBoundary.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Component, type ErrorInfo, type ReactNode } from 'react'
22
import { RefreshCw } from 'lucide-react'
3+
import { Sentry } from '../observability'
4+
import { walletManager } from '../utils/walletManager'
35

46
interface Props {
57
children: ReactNode
@@ -29,7 +31,25 @@ export class ErrorBoundary extends Component<Props, State> {
2931
}
3032

3133
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
32-
console.error('[ErrorBoundary] Caught error:', error, errorInfo.componentStack)
34+
const route = typeof window !== 'undefined' ? window.location.pathname : undefined
35+
let userId: string | undefined
36+
try {
37+
const pk = walletManager.getPublicKey()
38+
if (pk) userId = pk
39+
} catch {
40+
// walletManager not available
41+
}
42+
Sentry.captureException(error, {
43+
extra: {
44+
componentStack: errorInfo.componentStack,
45+
route,
46+
userId,
47+
section: this.props.fallbackTitle,
48+
},
49+
tags: {
50+
errorBoundary: 'section',
51+
},
52+
})
3353
}
3454

3555
private handleRetry = (): void => {

0 commit comments

Comments
 (0)