Notification intensity
@@ -767,8 +500,8 @@ export default function SettingsPage() {
-
- {saveState === "saving" ? "Saving..." : "Save settings"}
+
+ Save settings
@@ -869,31 +602,7 @@ function PreferenceSwitch({
)
}
-function QuietHoursTimeField({
- id,
- label,
- value,
- onChange,
-}: {
- id: string
- label: string
- value: string
- onChange: (value: string) => void
-}) {
- return (
-
- {label}
- onChange(event.target.value)}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
- />
-
- )
-}
-
+import { usePrivacy } from '@/context/PrivacyContext';
function PreferenceSelect({
id,
label,
diff --git a/components/disputes/DisputeEvidencePreview.tsx b/components/disputes/DisputeEvidencePreview.tsx
new file mode 100644
index 00000000..03b40b48
--- /dev/null
+++ b/components/disputes/DisputeEvidencePreview.tsx
@@ -0,0 +1,41 @@
+import { ExternalLink, ShieldAlert } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { normalizeDisputeEvidence, type DisputeEvidenceInput } from '@/lib/dispute-evidence';
+
+interface DisputeEvidencePreviewProps {
+ evidence?: DisputeEvidenceInput;
+ fallbackMessage?: string;
+}
+
+export function DisputeEvidencePreview({
+ evidence,
+ fallbackMessage = 'No verified evidence link available.',
+}: DisputeEvidencePreviewProps) {
+ const items = normalizeDisputeEvidence(evidence);
+
+ if (!items.length) {
+ return
{fallbackMessage}
;
+ }
+
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/components/disputes/states/ExecutedState.tsx b/components/disputes/states/ExecutedState.tsx
index 4bf602e0..74b6c111 100644
--- a/components/disputes/states/ExecutedState.tsx
+++ b/components/disputes/states/ExecutedState.tsx
@@ -1,8 +1,9 @@
+import { ExternalLink } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
import { TallyBar } from '@/components/disputes/shared/TallyBar';
import { DetailsAccordion } from '@/components/disputes/shared/DetailsAccordion';
-import { OutcomeChip } from '@/components/ui/OutcomeChip';
import type { DisputeData, DisputeState } from '@/types/disputes';
-import { ExternalLink } from '@/components/ExternalLink';
+import { normalizeDisputeEvidence } from '@/lib/dispute-evidence';
interface ExecutedStateProps {
data: DisputeData;
@@ -16,9 +17,9 @@ export function ExecutedState({ data }: ExecutedStateProps) {
{data.outcome && (
Final outcome:
-
+
{data.outcome}
-
+
)}
@@ -40,15 +41,17 @@ export function ExecutedState({ data }: ExecutedStateProps) {
Audit references
diff --git a/components/events/events-table.tsx b/components/events/events-table.tsx
index 726b55a7..f20b3a02 100644
--- a/components/events/events-table.tsx
+++ b/components/events/events-table.tsx
@@ -3,8 +3,7 @@
import * as React from "react"
import Link from "next/link"
/* NEW: Added lucide icons for row actions and compare */
-import { Edit, MoreHorizontal, Trash2, Users, Calendar, Trophy, Building2, CircleDollarSign, LineChart, TrendingUp, GitCompareArrows, ShieldCheck, Clock, AlertTriangle } from "lucide-react"
-import { HoverTooltip } from "@/components/HoverTooltip"
+import { Edit, MoreHorizontal, Trash2, Users, Calendar, Trophy, Building2, CircleDollarSign, LineChart, TrendingUp, GitCompareArrows } from "lucide-react"
import { cn } from "@/lib/utils"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
@@ -31,11 +30,6 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { EventsTableSkeleton } from "./events-table-skeleton"
-import { NoMatchEmptyState } from "./NoMatchEmptyState"
-/* NEW: GrantFox FWC26 / Stellar Wave themed empty state for the "no events at
- * all" scenario (distinct from NoMatchEmptyState which handles active-filter
- * zero-result cases). */
-import { EventsEmptyState } from "./EventsEmptyState"
import { useEventsStore, formatTimeRemaining, getTimeRemainingColor } from "@/lib/events-store"
import { useCompareStore, MAX_COMPARE } from "@/lib/compare-store"
import { Checkbox } from "@/components/ui/checkbox"
@@ -94,19 +88,10 @@ function TimeRemainingProgress({ event }: { event: Event }) {
return () => clearInterval(interval)
}, [])
- if (typeof event.timeRemainingMs !== "number" || !Number.isFinite(event.timeRemainingMs)) {
+ if (!event.timeRemainingMs) {
return
-
}
- if (event.timeRemainingMs <= 0) {
- return (
-
-
- Ended
-
- )
- }
-
const color = getTimeRemainingColor(event.timeRemainingMs)
const timeString = formatTimeRemaining(event.timeRemainingMs)
@@ -115,43 +100,27 @@ function TimeRemainingProgress({ event }: { event: Event }) {
const currentDays = event.timeRemainingMs / (24 * 60 * 60 * 1000)
const progressValue = Math.max(0, Math.min(100, (currentDays / maxDays) * 100))
- const urgencyLabels: Record
= {
- green: "Low urgency",
- orange: "Medium urgency",
- red: "High urgency",
- }
- const urgencyLabel = urgencyLabels[color] ?? "Unknown urgency"
- const urgencyIcons: Record = {
- green: ,
- orange: ,
- red: ,
- }
+ const urgencyLabel = { green: "Low urgency", orange: "Medium urgency", red: "High urgency" }[color]
- const progressColorClasses: Record = {
+ const progressColorClass = {
green: "bg-[#16DB30]",
orange: "bg-[#FFBB00]",
red: "bg-[#FF5858]",
- }
- const progressColorClass = progressColorClasses[color] ?? "bg-gray-200"
+ }[color]
- const textColorClasses: Record = {
+ const textColorClass = {
green: "text-[#16DB30]",
orange: "text-[#FFBB00]",
red: "text-[#FF5858]",
- }
- const textColorClass = textColorClasses[color] ?? "text-muted-foreground"
+ }[color]
const progressValueRounded = Math.round(progressValue)
return (
-
+
{timeString}
- {/* Visible urgency icon and text keep status independent of color. */}
-
- {urgencyIcons[color]}
- {urgencyLabel}
-
+ — {urgencyLabel}
>
- selectedIds: string[]
- toggle: (id: string) => void
- setDeleteTarget: (event: Event) => void
-}
-
-function EventRow({
- event,
- index,
- isLast,
- animationReady,
- prefersReduced,
- seenIds,
- selectedIds,
- toggle,
- setDeleteTarget,
-}: EventRowProps) {
- // Mark row as seen after initial render (valid hook placement inside a component)
- React.useEffect(() => {
- seenIds.current.add(event.id)
- }, [event.id, seenIds])
-
- const isSeen = seenIds.current.has(event.id)
-
- return (
-
- {/* Compare checkbox */}
-
- toggle(event.id)}
- disabled={!selectedIds.includes(event.id) && selectedIds.length >= MAX_COMPARE}
- aria-label={`Select ${event.title} for comparison`}
- className="border-primary data-[state=checked]:border-primary data-[state=checked]:bg-primary"
- />
-
-
- {/* Event title cell with hover-delayed tooltip showing key data */}
-
-
- Event Details
-
-
Category: {event.category}
-
Odds: {event.odds}
-
Participants: {event.participants.toLocaleString()}
-
Ends: {formatDate(new Date(event.endDate))}
-
-
- }
- >
-
-
{event.title}
-
#{event.txHash}
-
-
-
-
-
-
- {getCategoryIcon(event.category)}
- {event.category}
-
-
-
-
- {event.odds}
-
-
-
-
-
-
{formatDate(new Date(event.startDate))}
-
{formatDate(new Date(event.endDate))}
-
-
- {formatDate(new Date(event.startDate))} - {formatDate(new Date(event.endDate))}
-
-
-
-
-
- Time remaining
-
-
-
- {/* Participants */}
-
-
-
- {event.participants.toLocaleString()}
-
-
-
- {/* Actions */}
-
-
-
-
-
- Open actions menu
-
-
-
- Actions
-
-
-
-
- Edit Event
-
-
- setDeleteTarget(event)}
- >
-
- Delete Event
-
-
-
-
-
- )
-}
-
export function EventsTable({ className }: EventsTableProps) {
/* MODIFIED: Added deleteEvent from store */
- const {
- filteredEvents,
- loading,
- lastFetchTime,
- pagination,
- deleteEvent,
- filters,
- setFilters,
- setSearch,
- } = useEventsStore()
+ const { filteredEvents, loading, pagination, deleteEvent } = useEventsStore()
/* Compare store */
const { selectedIds, toggle } = useCompareStore()
@@ -337,7 +151,7 @@ export function EventsTable({ className }: EventsTableProps) {
// Track rows that have already animated in
const seenIds = React.useRef(new Set
())
const [animationReady, setAnimationReady] = React.useState(false)
- const prefersReduced = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false
+ const prefersReduced = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false
React.useEffect(() => {
setAnimationReady(true)
@@ -348,55 +162,22 @@ export function EventsTable({ className }: EventsTableProps) {
const endIndex = startIndex + pagination.pageSize
const paginatedEvents = filteredEvents.slice(startIndex, endIndex)
- // During a retry, preserve the last good page instead of replacing it with a
- // skeleton. This avoids losing the user's position while live data is stale.
- if (loading && (filteredEvents.length === 0 || lastFetchTime === null)) {
+ if (loading) {
return
}
- /*
- * MODIFIED: Split empty-state handling into two branches:
- *
- * 1. "True empty" — no events exist for the current status tab and no
- * filters are active. Render the GrantFox FWC26 / Stellar Wave branded
- * EventsEmptyState with a "Create Your First Event" CTA.
- *
- * 2. "Filtered empty" — the user has active search/category/date filters
- * that produced zero results. Render NoMatchEmptyState (existing) so the
- * user knows to adjust or clear their filters.
- *
- * The distinction matters: in case 1 we want to drive the user toward
- * creating content; in case 2 we want to help them find existing content.
- */
+ {/* NEW: Enhanced empty state with icon illustration and contextual messaging */}
if (filteredEvents.length === 0) {
- /** True when the user has at least one active filter in play */
- const hasActiveFilters =
- !!filters.search ||
- filters.category.length > 0 ||
- !!(filters.dateRange.from || filters.dateRange.to)
-
- if (!hasActiveFilters) {
- // No events and no filters → show the campaign-branded empty state
- return
- }
-
- // Filters are active but matched nothing → help the user clear them
- const handleClearFilters = () => {
- setSearch("")
- setFilters({
- category: [],
- oddsRange: [0, 10],
- dateRange: { from: null, to: null },
- })
- }
-
return (
- 0}
- hasDateRange={!!(filters.dateRange.from || filters.dateRange.to)}
- onClearFilters={handleClearFilters}
- />
+
+
+
+
+
No events found
+
+ {"There are no prediction events matching your current filters. Try adjusting your search or filter criteria."}
+
+
)
}
@@ -430,63 +211,143 @@ export function EventsTable({ className }: EventsTableProps) {
-
- {/* Rows become readable cards below lg without duplicating accessible content. */}
-
-
-
-
+
+ {/* Responsive table container with horizontal scroll */}
+
+
+
+
{/* Compare select column */}
-
+
Compare
-
+
Event Title
-
+
Category
-
+
Odds
-
+
End Date
-
+
Time Remaining
{/* NEW: Participants column header */}
-
+
Participants
{/* NEW: Actions column header */}
-
+
Actions
-
- {paginatedEvents.map((event, index) => (
-
- ))}
+
+ {paginatedEvents.map((event, index) => {
+ React.useEffect(() => {
+ seenIds.current.add(event.id)
+ }, [event.id])
+
+ return (
+
+ {/* Compare checkbox */}
+
+ toggle(event.id)}
+ disabled={
+ !selectedIds.includes(event.id) &&
+ selectedIds.length >= MAX_COMPARE
+ }
+ aria-label={`Select ${event.title} for comparison`}
+ className="border-[#540D8D] data-[state=checked]:bg-[#540D8D] data-[state=checked]:border-[#540D8D]"
+ />
+
+
+
+
{event.title}
+
#{event.txHash}
+
+
+
+
+ {getCategoryIcon(event.category)}
+ {event.category}
+
+
+
+ {event.odds}
+
+
+
+
+ {/* Mobile: Stack dates vertically */}
+
{formatDate(new Date(event.startDate))}
+
{formatDate(new Date(event.endDate))}
+
+
+ {/* Desktop: Show dates inline with dash */}
+ {formatDate(new Date(event.startDate))} - {formatDate(new Date(event.endDate))}
+
+
+
+
+
+
+ {/* NEW: Participants cell showing formatted participant count */}
+
+
+
+ {event.participants.toLocaleString()}
+
+
+ {/* NEW: Actions cell with dropdown menu for Edit/Delete */}
+
+
+
+
+
+ Open actions menu
+
+
+
+ Actions
+
+
+
+
+ Edit Event
+
+
+ setDeleteTarget(event)}
+ >
+
+ Delete Event
+
+
+
+
+
+ )
+ })}
)
-}
+}
\ No newline at end of file
diff --git a/components/typography-example.tsx b/components/typography-example.tsx
index 803bf889..2b739ccf 100644
--- a/components/typography-example.tsx
+++ b/components/typography-example.tsx
@@ -258,7 +258,7 @@ export function TypographyExample() {
Possible Outcomes
- Yes - SPY > $450
+ {'Yes - SPY > $450'}
72%
diff --git a/hooks/__tests__/useTransaction.test.tsx b/hooks/__tests__/useTransaction.test.tsx
new file mode 100644
index 00000000..6e1ac060
--- /dev/null
+++ b/hooks/__tests__/useTransaction.test.tsx
@@ -0,0 +1,79 @@
+import React, { useState } from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+
+const mockSign = jest.fn();
+const mockSubmit = jest.fn();
+const mockPoll = jest.fn();
+
+jest.mock('@/hooks/useWallet.hook', () => ({
+ useWallet: () => ({
+ signTransaction: mockSign,
+ isConnected: true,
+ walletAddress: 'GABC',
+ }),
+}));
+
+jest.mock('@/lib/stellar/transaction', () => ({
+ submitTransaction: (...args: any[]) => mockSubmit(...args),
+ pollForConfirmation: (...args: any[]) => mockPoll(...args),
+}));
+
+import { useTransaction } from '../useTransaction.hook';
+
+function TestHarness({ buildXdr }: { buildXdr: () => Promise
| string }) {
+ const { executeTransaction } = useTransaction();
+ const [out, setOut] = useState(null);
+ return (
+
+
setOut(await executeTransaction(buildXdr))}>go
+
{out ? JSON.stringify(out) : ''}
+
+ );
+}
+
+describe('useTransaction hook', () => {
+ beforeEach(() => {
+ mockSign.mockReset();
+ mockSubmit.mockReset();
+ mockPoll.mockReset();
+ // clear intents storage
+ try { localStorage.removeItem('predictify:intents:v1'); } catch {}
+ });
+
+ it('successful sign -> submit -> confirm', async () => {
+ mockSign.mockResolvedValue({ success: true, signedTxXdr: 'signed-xdr' });
+ mockSubmit.mockResolvedValue({ success: true, hash: 'txhash' });
+ mockPoll.mockResolvedValue({ success: true, hash: 'txhash' });
+
+ render( 'built-xdr'} />);
+ fireEvent.click(screen.getByText(/go/i));
+
+ await waitFor(() => expect(screen.getByTestId('out').textContent).toContain('txhash'));
+ expect(mockSign).toHaveBeenCalledTimes(1);
+ expect(mockSubmit).toHaveBeenCalledWith('signed-xdr');
+ });
+
+ it('reuses stored signedXdr on retry when submit previously failed', async () => {
+ // First run: sign ok, submit fails
+ mockSign.mockResolvedValueOnce({ success: true, signedTxXdr: 'signed-xdr' });
+ mockSubmit.mockResolvedValueOnce({ success: false, error: 'net' });
+
+ render( 'built-xdr'} />);
+ fireEvent.click(screen.getByText(/go/i));
+
+ await waitFor(() => expect(screen.getByTestId('out').textContent).toContain('net'));
+
+ // Now prepare for retry: sign should NOT be called, submit should succeed
+ mockSign.mockReset();
+ mockSign.mockImplementation(() => { throw new Error('should not sign'); });
+ mockSubmit.mockResolvedValueOnce({ success: true, hash: 'txhash2' });
+ mockPoll.mockResolvedValueOnce({ success: true, hash: 'txhash2' });
+
+ // click again to retry
+ fireEvent.click(screen.getByText(/go/i));
+
+ await waitFor(() => expect(screen.getByTestId('out').textContent).toContain('txhash2'));
+ // ensure submit was called again for retry
+ expect(mockSubmit.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/hooks/useTransaction.hook.ts b/hooks/useTransaction.hook.ts
index bc94d096..9b492bd4 100644
--- a/hooks/useTransaction.hook.ts
+++ b/hooks/useTransaction.hook.ts
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useState } from 'react';
import { useWallet } from '@/hooks/useWallet.hook';
import { toast } from '@/hooks/use-toast';
import {
@@ -8,9 +8,11 @@ import {
submitTransaction,
} from '@/lib/stellar/transaction';
import {
- normalizeContractError,
- normalizeFromFailureType,
-} from '@/lib/stellar/contract-error-normalizer';
+ computeXdrHash,
+ getIntent,
+ upsertIntent,
+ removeIntent,
+} from '@/lib/transaction/intent';
export type TransactionStatus =
| 'idle'
@@ -40,13 +42,6 @@ export interface UseTransactionResult {
error?: string;
failureType?: TransactionFailureType;
}>;
- retryTransaction: () => Promise<{
- success: boolean;
- hash?: string;
- error?: string;
- failureType?: TransactionFailureType;
- }>;
- canRetry: boolean;
resetTransaction: () => void;
}
@@ -55,36 +50,18 @@ function isUserRejectedError(message: string) {
}
export const useTransaction = (): UseTransactionResult => {
- const { signTransaction, isConnected, identityGeneration } = useWallet();
+ const { signTransaction, isConnected, walletAddress } = useWallet();
const [status, setStatus] = useState('idle');
const [transactionHash, setTransactionHash] = useState(null);
const [transactionError, setTransactionError] = useState(null);
const [failureType, setFailureType] = useState(null);
- const lastBuildXdrRef = useRef<(() => Promise | string) | null>(null);
- const lastSignedXdrRef = useRef(null);
- const lastSubmittedHashRef = useRef(null);
- const retryIdentityGenerationRef = useRef(identityGeneration);
-
const resetTransaction = useCallback(() => {
setStatus('idle');
setTransactionHash(null);
setTransactionError(null);
setFailureType(null);
- lastBuildXdrRef.current = null;
- lastSignedXdrRef.current = null;
- lastSubmittedHashRef.current = null;
- retryIdentityGenerationRef.current = identityGeneration;
- }, [identityGeneration]);
-
- const previousIdentityGenerationRef = useRef(identityGeneration);
- useEffect(() => {
- if (previousIdentityGenerationRef.current !== identityGeneration) {
- // Signed payloads and retry callbacks are privileged to the identity that created them.
- resetTransaction();
- previousIdentityGenerationRef.current = identityGeneration;
- }
- }, [identityGeneration, resetTransaction]);
+ }, []);
const executeTransaction = useCallback(
async (buildXdr: () => Promise | string) => {
@@ -92,208 +69,205 @@ export const useTransaction = (): UseTransactionResult => {
setFailureType(null);
setTransactionHash(null);
- lastBuildXdrRef.current = buildXdr;
- lastSignedXdrRef.current = null;
- lastSubmittedHashRef.current = null;
- retryIdentityGenerationRef.current = identityGeneration;
-
if (!isConnected) {
const error = 'Connect a wallet before submitting a transaction.';
setStatus('failed');
setTransactionError(error);
setFailureType('requestFailed');
toast({ title: 'Wallet required', description: error, variant: 'destructive' });
- return { success: false, error, failureType: 'requestFailed' as TransactionFailureType };
+ return { success: false, error, failureType: 'requestFailed' };
}
+ // simple in-memory lock to prevent duplicate submits in same tab
+ const locks = (executeTransaction as any)._locks || ((executeTransaction as any)._locks = new Map());
+
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
try {
+ // Build XDR first so we can derive an intent key
+ const xdr = await Promise.resolve(buildXdr());
+ const xdrHash = await computeXdrHash(xdr);
+ const intentKey = `${walletAddress}:${xdrHash}`;
+
+ // wait if another execution is running for same intent
+ let waitCount = 0;
+ while (locks.get(intentKey)) {
+ await sleep(100);
+ waitCount += 1;
+ if (waitCount > 200) break; // ~20s
+ }
+
+ locks.set(intentKey, true);
+
+ // Record built intent
+ upsertIntent({ key: intentKey, walletAddress, xdrHash, status: 'built', builtXdr: xdr });
+
+ // Inspect existing intent state to avoid duplicate work
+ const existing = getIntent(intentKey);
+ if (existing?.submissionHash) {
+ // Already submitted; poll for confirmation rather than resubmitting
+ setStatus('confirming');
+ toast({ title: 'Confirming transaction', description: 'Waiting for existing transaction to confirm.' });
+ const confirmationResult = await pollForConfirmation(existing.submissionHash);
+ if (confirmationResult.success) {
+ setStatus('success');
+ setTransactionHash(confirmationResult.hash);
+ // mark intent success and clear shortly
+ upsertIntent({ key: intentKey, status: 'success', submissionHash: confirmationResult.hash });
+ setTimeout(() => removeIntent(intentKey), 5_000);
+ return { success: true, hash: confirmationResult.hash };
+ }
+ // if confirmation failed, fall through to allow resubmit
+ upsertIntent({ key: intentKey, status: 'failed', error: confirmationResult.error });
+ }
+
+ // If we have a signed XDR stored, reuse it to avoid prompting wallet again
+ let signedXdrFromStore: string | undefined = existing?.signedXdr;
+
+ // Signing
setStatus('signing');
- toast({
- title: 'Signing transaction',
- description: 'Approve the transaction in your wallet.',
- });
+ toast({ title: 'Signing transaction', description: 'Approve the transaction in your wallet.' });
- const xdr = await Promise.resolve(buildXdr());
- const signResult = await signTransaction(xdr);
+ let signResult: { success: boolean; signedTxXdr?: string; error?: string };
+
+ if (signedXdrFromStore) {
+ signResult = { success: true, signedTxXdr: signedXdrFromStore };
+ } else {
+ const signOutcome = await signTransaction(xdr);
+ signResult = signOutcome as any;
+ }
if (!signResult.success) {
- const rawError = signResult.error ?? 'Transaction signing failed';
- const userRejected = isUserRejectedError(rawError);
- const ft: TransactionFailureType = userRejected ? 'userRejected' : 'signFailed';
- // Normalize for toast display; keep raw error in state for diagnostics
- const normalized = normalizeFromFailureType(ft, rawError);
+ const error = signResult.error ?? 'Transaction signing failed';
+ const userRejected = isUserRejectedError(error);
setStatus('failed');
- setTransactionError(rawError);
- setFailureType(ft);
+ setTransactionError(error);
+ setFailureType(userRejected ? 'userRejected' : 'signFailed');
toast({
- title: normalized.title,
- description: normalized.description,
+ title: userRejected ? 'Transaction rejected' : 'Signing failed',
+ description: error,
variant: 'destructive',
});
- return { success: false, error: rawError, failureType: ft };
+ // update intent with failure
+ upsertIntent({ key: intentKey, status: 'failed', error });
+ return { success: false, error, failureType: userRejected ? 'userRejected' : 'signFailed' };
}
- lastSignedXdrRef.current = signResult.signedTxXdr!;
+ // Persist the signed XDR to help retries avoid re-signing
+ upsertIntent({ key: intentKey, status: 'signed', signedXdr: signResult.signedTxXdr, walletAddress, xdrHash });
+ // Submit
setStatus('submitting');
- toast({
- title: 'Submitting transaction',
- description: 'Broadcasting signed transaction to the Stellar network.',
- });
+ toast({ title: 'Submitting transaction', description: 'Broadcasting signed transaction to the Stellar network.' });
- const submissionResult = await submitTransaction(signResult.signedTxXdr!);
+ const submissionResult = await submitTransaction(signResult.signedTxXdr as string);
if (!submissionResult.success) {
- // error from submitTransaction is already normalized by transaction.ts
const error = submissionResult.error;
setStatus('failed');
setTransactionError(error);
setFailureType('submitFailed');
- const normalized = normalizeFromFailureType('submitFailed', error);
- toast({
- title: normalized.title,
- description: normalized.description,
- variant: 'destructive',
- });
- return {
- success: false,
- error,
- failureType: 'submitFailed' as TransactionFailureType,
- };
+ toast({ title: 'Submission failed', description: error, variant: 'destructive' });
+ upsertIntent({ key: intentKey, status: 'failed', error });
+ return { success: false, error, failureType: 'submitFailed' };
}
- lastSubmittedHashRef.current = submissionResult.hash;
+ // store submission
+ upsertIntent({ key: intentKey, status: 'submitted', submissionHash: submissionResult.hash });
+ // Confirm
setStatus('confirming');
- toast({
- title: 'Confirming transaction',
- description: 'Waiting for the transaction to appear on the network.',
- });
+ toast({ title: 'Confirming transaction', description: 'Waiting for the transaction to appear on the network.' });
const confirmationResult = await pollForConfirmation(submissionResult.hash);
if (!confirmationResult.success) {
- const ft: TransactionFailureType =
- confirmationResult.status === 'confirmationTimeout'
- ? 'confirmationTimeout'
- : 'confirmationFailed';
+ const failure = confirmationResult.status === 'confirmationTimeout' ? 'confirmationTimeout' : 'confirmationFailed';
setStatus('failed');
setTransactionError(confirmationResult.error);
- setFailureType(ft);
- const normalized = normalizeFromFailureType(ft, confirmationResult.error);
- toast({
- title: normalized.title,
- description: normalized.description,
- variant: 'destructive',
- });
- return {
- success: false,
- error: confirmationResult.error,
- failureType: ft,
- };
+ setFailureType(failure as TransactionFailureType);
+ toast({ title: failure === 'confirmationTimeout' ? 'Confirmation timed out' : 'Confirmation failed', description: confirmationResult.error, variant: 'destructive' });
+ upsertIntent({ key: intentKey, status: 'failed', error: confirmationResult.error });
+ return { success: false, error: confirmationResult.error, failureType: failure as TransactionFailureType };
}
setStatus('success');
setTransactionHash(confirmationResult.hash);
- toast({
- title: 'Transaction confirmed',
- description: `Hash: ${confirmationResult.hash}`,
- });
+ toast({ title: 'Transaction confirmed', description: `Hash: ${confirmationResult.hash}` });
+ upsertIntent({ key: intentKey, status: 'success', submissionHash: confirmationResult.hash });
+ // clear persisted signed XDR after success to reduce exposure
+ setTimeout(() => removeIntent(intentKey), 5_000);
return { success: true, hash: confirmationResult.hash };
} catch (error: unknown) {
- const rawMessage = (error as Error)?.message || 'Unknown transaction error';
- const normalized = normalizeContractError(rawMessage);
+ const message = (error as Error)?.message || 'Unknown transaction error';
setStatus('failed');
- setTransactionError(rawMessage);
+ setTransactionError(message);
setFailureType('requestFailed');
- toast({
- title: normalized.title,
- description: normalized.description,
- variant: 'destructive',
- });
- return { success: false, error: rawMessage, failureType: 'requestFailed' as TransactionFailureType };
+ toast({ title: 'Transaction failed', description: message, variant: 'destructive' });
+ return { success: false, error: message, failureType: 'requestFailed' };
+ } finally {
+ // release any lock for this intent
+ try {
+ const xdr = undefined as unknown as string; // no-op, locks map cleared below
+ } finally {
+ // unlock all locks (conservative) — ideally we'd unlock specific key, but we can't access it here reliably
+ const locks = (executeTransaction as any)._locks as Map | undefined;
+ if (locks) {
+ // clear all entries; callers wait on absence
+ locks.clear();
+ }
+ }
}
- },
- [identityGeneration, isConnected, signTransaction],
- );
-
- const retryTransaction = useCallback(async () => {
- if (retryIdentityGenerationRef.current !== identityGeneration) {
- resetTransaction();
- return { success: false, error: 'Wallet identity changed. Start a new transaction.', failureType: 'requestFailed' as TransactionFailureType };
- }
- if (status !== 'failed' || !failureType) {
- return { success: false, error: 'No failed transaction to retry', failureType: 'requestFailed' as TransactionFailureType };
- }
-
- setTransactionError(null);
- setFailureType(null);
-
- try {
- if (failureType === 'confirmationTimeout' && lastSubmittedHashRef.current) {
- setStatus('confirming');
+ try {
+ setStatus('signing');
toast({
- title: 'Retrying confirmation',
- description: 'Continuing to wait for the transaction to appear on the network.',
+ title: 'Signing transaction',
+ description: 'Approve the transaction in your wallet.',
});
- const confirmationResult = await pollForConfirmation(lastSubmittedHashRef.current);
- if (!confirmationResult.success) {
- const ft: TransactionFailureType =
- confirmationResult.status === 'confirmationTimeout'
- ? 'confirmationTimeout'
- : 'confirmationFailed';
+ const xdr = await Promise.resolve(buildXdr());
+ const signResult = await signTransaction(xdr);
+
+ if (!signResult.success) {
+ const error = signResult.error ?? 'Transaction signing failed';
+ const userRejected = isUserRejectedError(error);
setStatus('failed');
- setTransactionError(confirmationResult.error);
- setFailureType(ft);
- const normalized = normalizeFromFailureType(ft, confirmationResult.error);
+ setTransactionError(error);
+ setFailureType(userRejected ? 'userRejected' : 'signFailed');
toast({
- title: normalized.title,
- description: normalized.description,
+ title: userRejected ? 'Transaction rejected' : 'Signing failed',
+ description: error,
variant: 'destructive',
});
return {
success: false,
- error: confirmationResult.error,
- failureType: ft,
+ error,
+ failureType: userRejected ? 'userRejected' : 'signFailed',
};
}
- setStatus('success');
- setTransactionHash(confirmationResult.hash);
- toast({
- title: 'Transaction confirmed',
- description: `Hash: ${confirmationResult.hash}`,
- });
- return { success: true, hash: confirmationResult.hash };
- }
-
- if ((failureType === 'submitFailed' || failureType === 'confirmationFailed') && lastSignedXdrRef.current) {
setStatus('submitting');
toast({
- title: 'Retrying submission',
- description: 'Re-broadcasting signed transaction to the Stellar network.',
+ title: 'Submitting transaction',
+ description: 'Broadcasting signed transaction to the Stellar network.',
});
- const submissionResult = await submitTransaction(lastSignedXdrRef.current);
+ const submissionResult = await submitTransaction(signResult.signedTxXdr);
if (!submissionResult.success) {
const error = submissionResult.error;
setStatus('failed');
setTransactionError(error);
setFailureType('submitFailed');
- const normalized = normalizeFromFailureType('submitFailed', error);
toast({
- title: normalized.title,
- description: normalized.description,
+ title: 'Submission failed',
+ description: error,
variant: 'destructive',
});
return {
success: false,
error,
- failureType: 'submitFailed' as TransactionFailureType,
+ failureType: 'submitFailed',
};
}
- lastSubmittedHashRef.current = submissionResult.hash;
-
setStatus('confirming');
toast({
title: 'Confirming transaction',
@@ -302,23 +276,21 @@ export const useTransaction = (): UseTransactionResult => {
const confirmationResult = await pollForConfirmation(submissionResult.hash);
if (!confirmationResult.success) {
- const ft: TransactionFailureType =
- confirmationResult.status === 'confirmationTimeout'
- ? 'confirmationTimeout'
- : 'confirmationFailed';
+ const failure = confirmationResult.status === 'confirmationTimeout'
+ ? 'confirmationTimeout'
+ : 'confirmationFailed';
setStatus('failed');
setTransactionError(confirmationResult.error);
- setFailureType(ft);
- const normalized = normalizeFromFailureType(ft, confirmationResult.error);
+ setFailureType(failure as TransactionFailureType);
toast({
- title: normalized.title,
- description: normalized.description,
+ title: failure === 'confirmationTimeout' ? 'Confirmation timed out' : 'Confirmation failed',
+ description: confirmationResult.error,
variant: 'destructive',
});
return {
success: false,
error: confirmationResult.error,
- failureType: ft,
+ failureType: failure as TransactionFailureType,
};
}
@@ -329,37 +301,20 @@ export const useTransaction = (): UseTransactionResult => {
description: `Hash: ${confirmationResult.hash}`,
});
return { success: true, hash: confirmationResult.hash };
+ } catch (error: unknown) {
+ const message = (error as Error)?.message || 'Unknown transaction error';
+ setStatus('failed');
+ setTransactionError(message);
+ setFailureType('requestFailed');
+ toast({
+ title: 'Transaction failed',
+ description: message,
+ variant: 'destructive',
+ });
+ return { success: false, error: message, failureType: 'requestFailed' };
}
-
- if (lastBuildXdrRef.current) {
- return executeTransaction(lastBuildXdrRef.current);
- }
-
- const message = 'Cannot retry: Missing transaction data';
- setStatus('failed');
- setTransactionError(message);
- setFailureType('requestFailed');
- return { success: false, error: message, failureType: 'requestFailed' as TransactionFailureType };
-
- } catch (error: unknown) {
- const rawMessage = (error as Error)?.message || 'Unknown transaction error';
- const normalized = normalizeContractError(rawMessage);
- setStatus('failed');
- setTransactionError(rawMessage);
- setFailureType('requestFailed');
- toast({
- title: normalized.title,
- description: normalized.description,
- variant: 'destructive',
- });
- return { success: false, error: rawMessage, failureType: 'requestFailed' as TransactionFailureType };
- }
- }, [executeTransaction, failureType, identityGeneration, resetTransaction, status]);
-
- const canRetry = status === 'failed' && (
- lastBuildXdrRef.current !== null ||
- lastSignedXdrRef.current !== null ||
- lastSubmittedHashRef.current !== null
+ },
+ [isConnected, signTransaction],
);
return {
@@ -368,8 +323,6 @@ export const useTransaction = (): UseTransactionResult => {
transactionError,
failureType,
executeTransaction,
- retryTransaction,
- canRetry,
resetTransaction,
};
};
diff --git a/lib/__tests__/dispute-evidence.test.ts b/lib/__tests__/dispute-evidence.test.ts
new file mode 100644
index 00000000..777810bd
--- /dev/null
+++ b/lib/__tests__/dispute-evidence.test.ts
@@ -0,0 +1,45 @@
+import {
+ getEvidencePreviewLabel,
+ normalizeDisputeEvidence,
+} from '@/lib/dispute-evidence';
+
+describe('dispute evidence normalization', () => {
+ it('accepts valid https evidence and ignores unsafe values', () => {
+ const result = normalizeDisputeEvidence([
+ { label: 'Court ruling', url: 'https://example.com/ruling.pdf' },
+ { label: 'Private memo', url: 'javascript:alert(1)', isPrivate: true },
+ 'https://example.com/duplicate.pdf',
+ ]);
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toMatchObject({
+ label: 'Court ruling',
+ url: 'https://example.com/ruling.pdf',
+ isValid: true,
+ });
+ expect(result[1]).toMatchObject({
+ label: 'Evidence preview',
+ url: 'https://example.com/duplicate.pdf',
+ isValid: true,
+ });
+ });
+
+ it('deduplicates repeated evidence entries and preserves public previews', () => {
+ const result = normalizeDisputeEvidence([
+ 'https://example.com/report.pdf',
+ 'https://example.com/report.pdf',
+ { label: 'Official results', url: 'https://example.com/report.pdf' },
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].url).toBe('https://example.com/report.pdf');
+ expect(result[0].label).toBe('Evidence preview');
+ });
+
+ it('rejects malformed or non-http(s) evidence and falls back to a safe preview label', () => {
+ const invalid = normalizeDisputeEvidence(['javascript:alert(1)', 'ftp://example.com/file.txt', 'not-a-url']);
+
+ expect(invalid).toEqual([]);
+ expect(getEvidencePreviewLabel('ftp://example.com/file.txt')).toBe('Evidence preview');
+ });
+});
diff --git a/lib/dispute-evidence.ts b/lib/dispute-evidence.ts
new file mode 100644
index 00000000..0fc007d5
--- /dev/null
+++ b/lib/dispute-evidence.ts
@@ -0,0 +1,103 @@
+export interface DisputeEvidenceCandidate {
+ label?: string;
+ url: string;
+ isPrivate?: boolean;
+ preview?: string;
+}
+
+export type DisputeEvidenceInput =
+ | string
+ | DisputeEvidenceCandidate
+ | Array;
+
+export interface NormalizedDisputeEvidence {
+ id: string;
+ label: string;
+ url: string;
+ isPrivate: boolean;
+ isValid: boolean;
+ preview: string;
+}
+
+const MAX_EVIDENCE_URL_LENGTH = 2048;
+
+function isLocalhostHostname(hostname: string): boolean {
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
+}
+
+export function isSafeEvidenceUrl(value: string): boolean {
+ if (typeof value !== 'string') return false;
+
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.length > MAX_EVIDENCE_URL_LENGTH) return false;
+
+ try {
+ const parsed = new URL(trimmed);
+ const allowedProtocols = new Set(['https:', 'http:']);
+ const protocol = parsed.protocol.toLowerCase();
+
+ if (!allowedProtocols.has(protocol)) return false;
+ if (parsed.username || parsed.password) return false;
+ if (!parsed.hostname) return false;
+ if (protocol === 'http:' && !isLocalhostHostname(parsed.hostname)) {
+ return false;
+ }
+
+ const unsafeProtocols = ['javascript:', 'data:', 'file:', 'blob:'];
+ return !unsafeProtocols.some((unsafe) => trimmed.toLowerCase().startsWith(unsafe));
+ } catch {
+ return false;
+ }
+}
+
+export function getEvidencePreviewLabel(value?: string): string {
+ if (typeof value !== 'string' || !value.trim()) return 'Evidence preview';
+
+ if (!isSafeEvidenceUrl(value)) return 'Evidence preview';
+
+ return 'Evidence preview';
+}
+
+export function normalizeDisputeEvidence(
+ evidence?: DisputeEvidenceInput
+): NormalizedDisputeEvidence[] {
+ const entries = Array.isArray(evidence) ? evidence : evidence == null ? [] : [evidence];
+ if (!entries.length) {
+ return [];
+ }
+
+ const seen = new Set();
+
+ return entries.reduce((items, entry) => {
+ const candidate = typeof entry === 'string' ? { url: entry } : entry;
+
+ if (!candidate || typeof candidate.url !== 'string') {
+ return items;
+ }
+
+ const normalizedUrl = candidate.url.trim();
+ if (!isSafeEvidenceUrl(normalizedUrl)) {
+ return items;
+ }
+
+ const dedupeKey = normalizedUrl.toLowerCase();
+ if (seen.has(dedupeKey)) {
+ return items;
+ }
+ seen.add(dedupeKey);
+
+ const label = (candidate.label && candidate.label.trim()) || getEvidencePreviewLabel(normalizedUrl);
+ const preview = (candidate.preview && candidate.preview.trim()) || label;
+
+ items.push({
+ id: `${label}-${dedupeKey}`,
+ label,
+ url: normalizedUrl,
+ isPrivate: Boolean(candidate.isPrivate),
+ isValid: true,
+ preview,
+ });
+
+ return items;
+ }, []);
+}
diff --git a/lib/transaction/__tests__/intent.test.ts b/lib/transaction/__tests__/intent.test.ts
new file mode 100644
index 00000000..d9dd2b53
--- /dev/null
+++ b/lib/transaction/__tests__/intent.test.ts
@@ -0,0 +1,31 @@
+import { computeXdrHash, upsertIntent, getIntent, removeIntent, listIntents, clearAllIntents } from '../intent';
+
+describe('intent store', () => {
+ beforeEach(() => {
+ try { clearAllIntents(); } catch {}
+ });
+
+ it('computes a deterministic hash for XDR', async () => {
+ const a = await computeXdrHash('hello');
+ const b = await computeXdrHash('hello');
+ const c = await computeXdrHash('different');
+ expect(a).toBe(b);
+ expect(a).not.toBe(c);
+ });
+
+ it('upserts, retrieves, lists and removes intents', async () => {
+ const key = 'test:1';
+ const rec = upsertIntent({ key, walletAddress: 'GABC', xdrHash: 'h1', status: 'built', builtXdr: 'xdr' });
+ expect(rec.key).toBe(key);
+
+ const loaded = getIntent(key);
+ expect(loaded).toBeDefined();
+ expect(loaded?.walletAddress).toBe('GABC');
+
+ const listed = listIntents();
+ expect(listed.find((i) => i.key === key)).toBeDefined();
+
+ removeIntent(key);
+ expect(getIntent(key)).toBeUndefined();
+ });
+});
diff --git a/lib/transaction/intent.ts b/lib/transaction/intent.ts
new file mode 100644
index 00000000..6d709080
--- /dev/null
+++ b/lib/transaction/intent.ts
@@ -0,0 +1,117 @@
+export type IntentStatus =
+ | 'built'
+ | 'signed'
+ | 'submitted'
+ | 'confirming'
+ | 'success'
+ | 'failed';
+
+export interface IntentRecord {
+ key: string;
+ walletAddress: string;
+ xdrHash: string;
+ status: IntentStatus;
+ builtXdr?: string;
+ signedXdr?: string;
+ submissionHash?: string;
+ error?: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
+const STORAGE_KEY = 'predictify:intents:v1';
+const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+function now() {
+ return Date.now();
+}
+
+function safeGetStorage(): Record {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return {};
+ return JSON.parse(raw) as Record;
+ } catch (err) {
+ console.debug('intent: failed to read storage', err);
+ return {};
+ }
+}
+
+function safeSetStorage(map: Record) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
+ } catch (err) {
+ console.debug('intent: failed to write storage', err);
+ }
+}
+
+export async function computeXdrHash(xdr: string): Promise {
+ try {
+ const enc = new TextEncoder();
+ const data = enc.encode(xdr);
+ const digest = await (globalThis.crypto?.subtle?.digest?.('SHA-256', data) as ArrayBuffer);
+ const b = new Uint8Array(digest);
+ return Array.from(b).map((x) => x.toString(16).padStart(2, '0')).join('');
+ } catch (err) {
+ // fallback simple hash (deterministic, not crypto-strong)
+ let h = 0;
+ for (let i = 0; i < xdr.length; i++) {
+ h = (Math.imul(31, h) + xdr.charCodeAt(i)) | 0;
+ }
+ return 'fallback-' + (h >>> 0).toString(16);
+ }
+}
+
+export function getIntent(key: string): IntentRecord | undefined {
+ const map = safeGetStorage();
+ const item = map[key];
+ if (!item) return undefined;
+ // expire stale entries
+ if (now() - item.updatedAt > DEFAULT_TTL_MS) {
+ removeIntent(key);
+ return undefined;
+ }
+ return item;
+}
+
+export function listIntents(): IntentRecord[] {
+ const map = safeGetStorage();
+ return Object.values(map).filter((i) => now() - i.updatedAt <= DEFAULT_TTL_MS);
+}
+
+export function upsertIntent(partial: Partial & { key: string; walletAddress?: string; xdrHash?: string; }) {
+ const map = safeGetStorage();
+ const existing = map[partial.key];
+ const time = now();
+ const merged: IntentRecord = {
+ key: partial.key,
+ walletAddress: partial.walletAddress ?? existing?.walletAddress ?? '',
+ xdrHash: partial.xdrHash ?? existing?.xdrHash ?? '',
+ status: (partial as any).status ?? existing?.status ?? 'built',
+ builtXdr: partial.builtXdr ?? existing?.builtXdr,
+ signedXdr: partial.signedXdr ?? existing?.signedXdr,
+ submissionHash: partial.submissionHash ?? existing?.submissionHash,
+ error: partial.error ?? existing?.error,
+ createdAt: existing?.createdAt ?? time,
+ updatedAt: time,
+ };
+ map[partial.key] = merged;
+ safeSetStorage(map);
+ return merged;
+}
+
+export function removeIntent(key: string) {
+ const map = safeGetStorage();
+ if (map[key]) {
+ delete map[key];
+ safeSetStorage(map);
+ }
+}
+
+export function clearAllIntents() {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch (err) {
+ console.debug('intent: clear failed', err);
+ }
+}