Skip to content
Open
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
23 changes: 15 additions & 8 deletions contracts/escrow-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pub enum JobStatus {
Released,
Disputed,
Refunded,
SplitResolved,
}

#[contracttype]
Expand Down Expand Up @@ -371,7 +372,7 @@ impl EscrowContract {
.get(&DataKey::Job(job_id.clone()))
.expect("Job not found");

// Checks
// 1. Checks
if caller != job.client && caller != job.freelancer {
panic!("Only the client or freelancer can trigger the fallback");
}
Expand All @@ -382,30 +383,36 @@ impl EscrowContract {
panic!("Dispute has not timed out yet");
}

// Effects: commit all state before any token transfer (CEI).
// 2. Effects
let remaining = job.remaining_amount;
let freelancer_share = remaining / 2;
let client_share = remaining
.checked_sub(freelancer_share)
.expect("Share arithmetic underflow");

job.remaining_amount = 0;
job.status = JobStatus::Refunded;
job.status = JobStatus::SplitResolved;

let client_addr = job.client.clone();
let freelancer_addr = job.freelancer.clone();
let token_addr = job.token.clone();

env.storage()
.instance()
.set(&DataKey::Job(job_id.clone()), &job);

// Interaction: token transfers last.
let token_client = token::Client::new(&env, &job.token);
// 3. Interactions
let token_client = token::Client::new(&env, &token_addr);
let contract_addr = env.current_contract_address();

if freelancer_share > 0 {
token_client.transfer(&contract_addr, &freelancer_addr, &freelancer_share);
}
if client_share > 0 {
token_client.transfer(&contract_addr, &client_addr, &client_share);
}

// 4. Events
env.events().publish(
(symbol_short!("stale_res"), caller),
(job_id, freelancer_share, client_share),
Expand Down Expand Up @@ -1051,7 +1058,7 @@ mod tests {
contract.resolve_stale_dispute(&client, &job_id);

let job = contract.get_job(&job_id).unwrap();
assert_eq!(job.status, JobStatus::Refunded);
assert_eq!(job.status, JobStatus::SplitResolved);
assert_eq!(job.remaining_amount, 0);

let token_client = token::Client::new(&env, &token);
Expand All @@ -1074,7 +1081,7 @@ mod tests {
contract.resolve_stale_dispute(&freelancer, &job_id);

let job = contract.get_job(&job_id).unwrap();
assert_eq!(job.status, JobStatus::Refunded);
assert_eq!(job.status, JobStatus::SplitResolved);
assert_eq!(job.remaining_amount, 0);

let token_client = token::Client::new(&env, &token);
Expand Down Expand Up @@ -1213,7 +1220,7 @@ mod tests {

let job = contract.get_job(&job_id).unwrap();
assert_eq!(job.remaining_amount, 0);
assert_eq!(job.status, JobStatus::Refunded);
assert_eq!(job.status, JobStatus::SplitResolved);
}

// -------------------------------------------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions contracts/escrow-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,41 @@ fn test_delisted_token_allows_dispute_and_cancellation() {
// Assert existing escrow can be disputed & cancelled after delisting
let dispute_res = client.try_resolve_dispute(&job_id, &resolution);
assert!(dispute_res.is_ok(), "Disputes in delisted tokens must resolve cleanly");
}

#[test]
fn resolve_stale_dispute_splits_funds_and_sets_status() {
let (env, cid, contract, _admin, client, freelancer, token, job_id, _amount, _expiry) =
setup_with_cid();

// 1. Raise a dispute
let timeout_ledger = dispute_job_and_get_timeout(&env, &contract, &client, &job_id);

// 2. Extend storage TTL and advance ledger sequence past the timeout
extend_ttl(&env, &cid, &token);
env.ledger().set_sequence_number(timeout_ledger + 1);

// 3. Trigger fallback resolution
contract.resolve_stale_dispute(&client, &job_id);

// 4. Verify job state updated to SplitResolved
let job = contract.get_job(&job_id).unwrap();
assert_eq!(job.status, JobStatus::SplitResolved);
assert_eq!(job.remaining_amount, 0);

// 5. Verify 50/50 token split (100 total amount -> 50 each)
let token_client = token::Client::new(&env, &token);
assert_eq!(token_client.balance(&freelancer), 50);
assert_eq!(token_client.balance(&client), 50);
}

#[test]
#[should_panic(expected = "Dispute has not timed out yet")]
fn resolve_stale_dispute_before_timeout_panics() {
let (env, contract, _admin, client, _freelancer, _token, job_id, _amount, _expiry) =
setup();
contract.dispute(&client, &job_id);

// Try calling fallback before advancing the ledger timeout
contract.resolve_stale_dispute(&client, &job_id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down Expand Up @@ -2342,7 +2342,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down Expand Up @@ -2025,7 +2025,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down Expand Up @@ -2025,7 +2025,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down Expand Up @@ -1959,7 +1959,10 @@
"data": {
"vec": [
{
"string": "caught panic 'Job is not disputed' from contract function 'Symbol(obj#611)'"

"string": "caught panic 'Job is not disputed' from contract function 'Symbol(obj#637)'"

},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@
"val": {
"vec": [
{
"symbol": "Refunded"
"symbol": "SplitResolved"
}
]
}
Expand Down
7 changes: 7 additions & 0 deletions frontend/__tests__/renderMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ describe("renderMarkdown", () => {
);
});

it("escapes HTML in link labels", () => {
const output = renderMarkdown("[<img src=x onerror=alert(1)>](https://example.com)");
expect(output).toBe(
'<a href="https://example.com" target="_blank" rel="noopener noreferrer" class="text-forest-600 hover:underline">&lt;img src=x onerror=alert(1)&gt;</a>',
);
});

it("converts newlines to <br />", () => {
expect(renderMarkdown("Hello\nWorld")).toBe("Hello<br />World");
});
Expand Down
40 changes: 30 additions & 10 deletions frontend/components/DonationFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface DonationFeedProps {
onNewDonation?: (donation: Donation) => void;
}

const MAX_SEEN_HASHES = 500;

export default function DonationFeed({ projectId, walletAddress, refreshKey = 0, onNewDonation }: DonationFeedProps) {
const [donations, setDonations] = useState<Donation[]>([]);
const [loading, setLoading] = useState(true);
Expand All @@ -26,6 +28,23 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
const latestPagingTokenRef = useRef<HorizonPagingToken | null>(null);
const seenTxHashesRef = useRef<Set<string>>(new Set());

// Hold onNewDonation in a ref so prop identity changes never restart the stream
const onNewDonationRef = useRef(onNewDonation);
useEffect(() => {
onNewDonationRef.current = onNewDonation;
}, [onNewDonation]);

// Helper to safely add transaction hashes to the set with a maximum capacity bound
const addSeenTxHash = useCallback((hash: string) => {
if (seenTxHashesRef.current.size >= MAX_SEEN_HASHES) {
const firstItem = seenTxHashesRef.current.values().next().value;
if (firstItem) {
seenTxHashesRef.current.delete(firstItem);
}
}
seenTxHashesRef.current.add(hash);
}, []);

// Load initial donation data from the backend API. Note: `d.id` is a
// backend database identifier, not a Horizon paging token, so it must
// never seed the SSE cursor below.
Expand All @@ -35,11 +54,11 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
.then(({ donations: data, nextCursor: cursor }) => {
setDonations(data);
setNextCursor(cursor);
data.forEach((d) => seenTxHashesRef.current.add(d.transactionHash));
data.forEach((d) => addSeenTxHash(d.transactionHash));
})
.catch(console.error)
.finally(() => setLoading(false));
}, [projectId, refreshKey]);
}, [projectId, refreshKey, addSeenTxHash]);

// Handle incoming SSE payment
const handleNewPayment = useCallback((payment: {
Expand All @@ -52,7 +71,7 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
transactionHash: string;
}) => {
if (seenTxHashesRef.current.has(payment.transactionHash)) return;
seenTxHashesRef.current.add(payment.transactionHash);
addSeenTxHash(payment.transactionHash);

const newDonation: Donation = {
id: payment.id,
Expand All @@ -79,10 +98,10 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
});
}, 2000);

onNewDonation?.(newDonation);
onNewDonationRef.current?.(newDonation);

latestPagingTokenRef.current = payment.pagingToken;
}, [projectId, onNewDonation]);
}, [projectId, addSeenTxHash]);

// Handle incoming donation pushed over the Socket.io "donation_event" channel
const handleSocketDonation = useCallback((payload: {
Expand All @@ -92,7 +111,7 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
timestamp: string;
}) => {
if (seenTxHashesRef.current.has(payload.transactionHash)) return;
seenTxHashesRef.current.add(payload.transactionHash);
addSeenTxHash(payload.transactionHash);

const newDonation: Donation = {
id: payload.transactionHash,
Expand All @@ -115,12 +134,13 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
});
}, 2000);

onNewDonation?.(newDonation);
}, [projectId, onNewDonation]);
onNewDonationRef.current?.(newDonation);
}, [projectId, addSeenTxHash]);

useDonationSocket(projectId, handleSocketDonation);

// Start SSE stream once initial data is loaded
// Keyed only on loading and walletAddress to avoid unnecessary reconnects
useEffect(() => {
if (loading || !walletAddress) return;

Expand All @@ -136,7 +156,7 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
return () => {
closeStream();
};
}, [loading, walletAddress, handleNewPayment]);
}, [loading, walletAddress]); // handleNewPayment omitted intentionally

const handleLoadMore = async () => {
if (!nextCursor || loadingMore) return;
Expand Down Expand Up @@ -250,4 +270,4 @@ export default function DonationFeed({ projectId, walletAddress, refreshKey = 0,
`}</style>
</div>
);
}
}
52 changes: 52 additions & 0 deletions frontend/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { Component, ErrorInfo, ReactNode } from "react";

interface Props {
children: ReactNode;
fallback?: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
public state: State = { hasError: false, error: null };

public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error in ErrorBoundary:", error, errorInfo.componentStack);
}

public resetError = () => {
this.setState({ hasError: false, error: null });
};

public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}

return (
<div className="p-6 rounded-lg bg-red-900 border border-red-700 text-center text-white my-4">
<h2 className="text-xl font-bold text-red-100 mb-2">Something went wrong</h2>
<p className="text-sm text-red-200 mb-4">
{this.state.error?.message || "An unexpected error occurred in this section."}
</p>
<button
onClick={this.resetError}
className="px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-white rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-400"
>
Try Again
</button>
</div>
);
}

return this.props.children;
}
}
4 changes: 3 additions & 1 deletion frontend/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading