Skip to content

Commit 04d0e33

Browse files
authored
Merge pull request #1599 from romeoville/fix/four-issues
feat: implement 4 open source contributions
2 parents 799543a + c7b0f6b commit 04d0e33

7 files changed

Lines changed: 386 additions & 5 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();
@@ -1145,6 +1161,32 @@ fn test_initialize_guard() {
11451161
client.initialize(&admin, &reflector_id);
11461162
}
11471163

1164+
#[test]
1165+
fn test_initialize_rejects_invalid_reflector_address() {
1166+
let env = Env::default();
1167+
env.mock_all_auths();
1168+
let contract_id = env.register_contract(None, PortfolioRebalancer);
1169+
let client = PortfolioRebalancerClient::new(&env, &contract_id);
1170+
let non_reflector_id = env.register_contract(None, non_reflector_contract::NonReflector);
1171+
let admin = Address::generate(&env);
1172+
1173+
let result = client.try_initialize(&admin, &non_reflector_id);
1174+
assert_eq!(result, Err(Ok(Error::InvalidOracleAddress)));
1175+
}
1176+
1177+
#[test]
1178+
fn test_initialize_accepts_valid_reflector_address() {
1179+
let env = Env::default();
1180+
env.mock_all_auths();
1181+
let contract_id = env.register_contract(None, PortfolioRebalancer);
1182+
let client = PortfolioRebalancerClient::new(&env, &contract_id);
1183+
let reflector_id = env.register_contract(None, reflector_contract::MockReflector);
1184+
let admin = Address::generate(&env);
1185+
1186+
let result = client.try_initialize(&admin, &reflector_id);
1187+
assert_eq!(result, Ok(Ok(())));
1188+
}
1189+
11481190
#[test]
11491191
#[should_panic]
11501192
fn test_create_portfolio_invalid_allocation() {

contracts/src/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ pub enum Error {
189189
InvalidAmount = 26,
190190
WithdrawFailed = 27,
191191
InvalidAllocationSum = 28,
192+
InvalidOracleAddress = 29,
192193
}
193194

194195
#[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 => {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { render, screen, cleanup, act } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import RouteErrorState from './RouteErrorState'
5+
6+
describe('RouteErrorState', () => {
7+
beforeEach(() => {
8+
vi.useFakeTimers()
9+
})
10+
11+
afterEach(() => {
12+
cleanup()
13+
vi.restoreAllMocks()
14+
})
15+
16+
it('renders title and message', () => {
17+
render(
18+
<RouteErrorState
19+
title="Loading Failed"
20+
message="Could not load portfolio data."
21+
onRetry={() => {}}
22+
/>,
23+
)
24+
expect(screen.getByText('Loading Failed')).toBeInTheDocument()
25+
expect(screen.getByText('Could not load portfolio data.')).toBeInTheDocument()
26+
})
27+
28+
it('calls onRetry when retry button is clicked', async () => {
29+
const onRetry = vi.fn()
30+
render(
31+
<RouteErrorState
32+
title="Error"
33+
message="Something went wrong."
34+
onRetry={onRetry}
35+
/>,
36+
)
37+
38+
const button = screen.getByRole('button', { name: /retry/i })
39+
await act(async () => {
40+
button.click()
41+
})
42+
43+
expect(onRetry).not.toHaveBeenCalled()
44+
45+
act(() => {
46+
vi.advanceTimersByTime(1000)
47+
})
48+
49+
expect(onRetry).toHaveBeenCalledTimes(1)
50+
})
51+
52+
it('applies exponential backoff on repeated retries', async () => {
53+
const onRetry = vi.fn()
54+
render(
55+
<RouteErrorState
56+
title="Error"
57+
message="Failed."
58+
onRetry={onRetry}
59+
/>,
60+
)
61+
62+
const button = screen.getByRole('button', { name: /retry/i })
63+
64+
await act(async () => { button.click() })
65+
act(() => { vi.advanceTimersByTime(1000) })
66+
expect(onRetry).toHaveBeenCalledTimes(1)
67+
68+
await act(async () => { button.click() })
69+
expect(onRetry).toHaveBeenCalledTimes(1)
70+
71+
act(() => { vi.advanceTimersByTime(1000) })
72+
expect(onRetry).toHaveBeenCalledTimes(1)
73+
74+
act(() => { vi.advanceTimersByTime(1000) })
75+
expect(onRetry).toHaveBeenCalledTimes(2)
76+
77+
await act(async () => { button.click() })
78+
act(() => { vi.advanceTimersByTime(4000) })
79+
expect(onRetry).toHaveBeenCalledTimes(3)
80+
})
81+
82+
it('shows loading indicator while retry is in progress', async () => {
83+
render(
84+
<RouteErrorState
85+
title="Error"
86+
message="Failed."
87+
onRetry={() => {}}
88+
/>,
89+
)
90+
91+
const button = screen.getByRole('button', { name: /retry/i })
92+
expect(button).not.toBeDisabled()
93+
94+
await act(async () => { button.click() })
95+
96+
expect(button).toBeDisabled()
97+
expect(screen.getByText('Retrying…')).toBeInTheDocument()
98+
})
99+
100+
it('renders back button when onBack is provided', () => {
101+
render(
102+
<RouteErrorState
103+
title="Error"
104+
message="Failed."
105+
onRetry={() => {}}
106+
onBack={() => {}}
107+
/>,
108+
)
109+
expect(screen.getByRole('button', { name: /back/i })).toBeInTheDocument()
110+
})
111+
112+
it('resets retry count after loading completes', async () => {
113+
const onRetry = vi.fn()
114+
const { rerender } = render(
115+
<RouteErrorState
116+
title="Error"
117+
message="Failed."
118+
onRetry={onRetry}
119+
loading={false}
120+
/>,
121+
)
122+
123+
const button = screen.getByRole('button', { name: /retry/i })
124+
125+
await act(async () => { button.click() })
126+
act(() => { vi.advanceTimersByTime(1000) })
127+
expect(onRetry).toHaveBeenCalledTimes(1)
128+
129+
rerender(
130+
<RouteErrorState
131+
title="Error"
132+
message="Failed."
133+
onRetry={onRetry}
134+
loading={true}
135+
/>,
136+
)
137+
138+
rerender(
139+
<RouteErrorState
140+
title="Error"
141+
message="Failed."
142+
onRetry={onRetry}
143+
loading={false}
144+
/>,
145+
)
146+
147+
act(() => { vi.advanceTimersByTime(5000) })
148+
})
149+
})

0 commit comments

Comments
 (0)