diff --git a/frontend/components/dashboard/ContractEventStream.tsx b/frontend/components/dashboard/ContractEventStream.tsx index 034e1458..c63a3044 100644 --- a/frontend/components/dashboard/ContractEventStream.tsx +++ b/frontend/components/dashboard/ContractEventStream.tsx @@ -49,10 +49,7 @@ export const ContractEventStream: React.FC = ({ setError(null); try { - const fetchedEvents = await fetchContractEventStream( - contractPrincipal, - apiUrl - ); + const fetchedEvents = await fetchContractEventStream(contractPrincipal, apiUrl); setEvents(fetchedEvents); setLastUpdated(Date.now()); } catch (err) { @@ -85,7 +82,7 @@ export const ContractEventStream: React.FC = ({ const formatTime = (timestamp: number) => { const date = new Date(timestamp); - return date.toLocaleTimeString([], { + return date.toLocaleTimeString([], { month: '2-digit', day: '2-digit', hour: '2-digit', @@ -162,17 +159,11 @@ export const ContractEventStream: React.FC = ({
- {error && ( -
- {error.message} -
- )} + {error &&
{error.message}
} {filteredEvents.length === 0 && !isLoading && (
- {events.length === 0 - ? 'No events found' - : 'No events match your filters'} + {events.length === 0 ? 'No events found' : 'No events match your filters'}
)} @@ -186,9 +177,7 @@ export const ContractEventStream: React.FC = ({
- - {event.description} - + {event.description} {event.status === 'failed' && ( diff --git a/frontend/components/dashboard/index.ts b/frontend/components/dashboard/index.ts index 1ab15ac2..c3e2d80a 100644 --- a/frontend/components/dashboard/index.ts +++ b/frontend/components/dashboard/index.ts @@ -5,6 +5,7 @@ export { default as UserProfile } from './UserProfile'; export { default as UserInsights } from './UserInsights'; export { default as UserNetwork } from './UserNetwork'; export { default as ActivityFeed } from './ActivityFeed'; +export { default as ContractEventStream } from './ContractEventStream'; export { default as AuditTrail } from './AuditTrail'; export { PerformanceMetricsPanel } from './PerformanceMetricsPanel'; export { AnalyticsKPIPanel } from './AnalyticsKPIPanel'; diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 1f6c3e19..2c02076d 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -1,61 +1,225 @@ 'use client'; -import React from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Header from '@/components/Header'; import BadgeGallery from '@/components/common/BadgeGallery'; import InterestProfiler from '@/components/InterestProfiler'; import DelegationStats from '@/components/DelegationStats'; import UserDashboard from '@/components/dashboard/UserDashboard'; +import { ActivityTimeline } from '@/components/profile'; +import { fetchUserProfile } from '@/lib/profile-data'; +import { toErrorMessage } from '@/lib/errors'; +import { + useWalletAddress, + useWalletConnect, + useWalletConnected, + useWalletDisconnect, + useWalletLoading, +} from '@/store/wallet-selectors'; +import { LoadingSpinner } from '@/components/LoadingSpinner'; +import type { UserProfile } from '@/types/profile'; import { Settings, LogOut, Wallet } from 'lucide-react'; +function shortenAddress(address: string): string { + return `${address.slice(0, 8)}...${address.slice(-6)}`; +} + +function TimelinePlaceholder({ onConnect }: { onConnect: () => void }) { + return ( +
+

Wallet activity timeline

+

+ Connect your wallet to see recent proposals, votes, and execution activity. +

+ +
+ ); +} + +function TimelineLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function TimelineError({ + error, + onRetry, +}: { + error: string; + onRetry: () => void; +}) { + return ( +
+

Unable to load activity

+

{error}

+ +
+ ); +} + export default function ProfilePage() { + const walletLoading = useWalletLoading(); + const connected = useWalletConnected(); + const address = useWalletAddress(); + const connect = useWalletConnect(); + const disconnect = useWalletDisconnect(); + + const [profile, setProfile] = useState(null); + const [activityLoading, setActivityLoading] = useState(false); + const [activityError, setActivityError] = useState(null); + + const loadProfile = useCallback(async () => { + if (!address || !connected) return; + + setActivityLoading(true); + setActivityError(null); + + try { + const data = await fetchUserProfile(address); + setProfile(data); + } catch (error) { + setActivityError(toErrorMessage(error)); + } finally { + setActivityLoading(false); + } + }, [address, connected]); + + useEffect(() => { + if (!connected || !address) return; + + const timeout = window.setTimeout(() => { + void loadProfile(); + }, 0); + + return () => window.clearTimeout(timeout); + }, [address, connected, loadProfile]); + + const isCurrentProfile = profile?.address === address; + const visibleActivity = isCurrentProfile ? profile.activity : []; + const showTimelineLoading = Boolean(connected && address && (activityLoading || !isCurrentProfile)); + const avatarInitials = address ? address.slice(0, 2).toUpperCase() : 'SP'; + + if (walletLoading) { return ( -
-
- -
-
-
-
- SP -
-
-
-

Sprint Citizen

-
- -

- SP12...ABCD -

-
-
-
- -
- - -
-
- -
-
- -
-
- -
-
- -
- - -
-
-
+
+ +
); + } + + return ( +
+
+ +
+
+
+
+ {avatarInitials} +
+
+
+

+ {address ? 'Wallet Profile' : 'Sprint Citizen'} +

+
+ +

+ {address ? shortenAddress(address) : 'Connect a wallet to view your profile'} +

+
+
+
+ +
+ + {connected && address ? ( + + ) : ( + + )} +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+ +
+
+
+

Wallet activity timeline

+

+ Recent proposal, voting, and execution activity for the connected wallet. +

+
+ {address && ( +

+ {shortenAddress(address)} +

+ )} +
+ + {!connected || !address ? ( + + ) : showTimelineLoading ? ( + + ) : activityError ? ( + + ) : ( + + )} +
+
+
+ ); } diff --git a/frontend/src/lib/contract-event-stream.test.ts b/frontend/src/lib/contract-event-stream.test.ts new file mode 100644 index 00000000..6e9c8ec6 --- /dev/null +++ b/frontend/src/lib/contract-event-stream.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchContractEventStream, + normalizeContractTransaction, + type ContractTransaction, +} from './contract-event-stream'; + +describe('normalizeContractTransaction', () => { + const contractPrincipal = 'SP1234567890.contract'; + + it('maps a vote transaction into a readable event', () => { + const tx: ContractTransaction = { + tx_id: '0xabc', + tx_type: 'contract_call', + tx_status: 'success', + sender_address: 'SP3TESTADDRESS1234567890', + block_time_iso: '2026-04-21T00:00:00.000Z', + event_count: 2, + contract_call: { + contract_id: contractPrincipal, + function_name: 'vote', + function_args: [ + { repr: 'u7', type: 'uint', name: 'proposal-id' }, + { repr: 'true', type: 'bool', name: 'support' }, + { repr: 'u5', type: 'uint', name: 'vote-weight' }, + ], + }, + }; + + const event = normalizeContractTransaction(tx, contractPrincipal); + + expect(event).not.toBeNull(); + expect(event?.category).toBe('vote'); + expect(event?.title).toBe('Vote recorded'); + expect(event?.summary).toContain('proposal #7'); + expect(event?.summary).toContain('weight 5'); + expect(event?.summary).toContain('yes'); + expect(event?.status).toBe('confirmed'); + expect(event?.eventCount).toBe(2); + }); + + it('returns null for non contract-call transactions', () => { + const event = normalizeContractTransaction( + { + tx_id: '0xdef', + tx_type: 'token_transfer', + tx_status: 'success', + sender_address: 'SP3TEST', + }, + contractPrincipal, + ); + + expect(event).toBeNull(); + }); + + it('returns null for another contract', () => { + const event = normalizeContractTransaction( + { + tx_id: '0xghi', + tx_type: 'contract_call', + tx_status: 'success', + sender_address: 'SP3TEST', + contract_call: { + contract_id: 'SP999.other-contract', + function_name: 'vote', + function_args: [{ repr: 'u1' }], + }, + }, + contractPrincipal, + ); + + expect(event).toBeNull(); + }); +}); + +describe('fetchContractEventStream', () => { + const contractPrincipal = 'SP1234567890.contract'; + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('fetches and sorts recent contract events', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + tx_id: '0x2', + tx_type: 'contract_call', + tx_status: 'success', + sender_address: 'SP2', + block_time_iso: '2026-04-21T01:00:00.000Z', + contract_call: { + contract_id: contractPrincipal, + function_name: 'execute-proposal', + function_args: [{ repr: 'u9' }], + }, + }, + { + tx_id: '0x1', + tx_type: 'contract_call', + tx_status: 'success', + sender_address: 'SP1', + block_time_iso: '2026-04-21T00:00:00.000Z', + contract_call: { + contract_id: contractPrincipal, + function_name: 'create-proposal', + function_args: [ + { repr: 'u50000000' }, + { repr: 'u"Community Grant"' }, + ], + }, + }, + { + tx_id: '0x3', + tx_type: 'token_transfer', + tx_status: 'success', + sender_address: 'SP3', + }, + ], + }), + }); + + const events = await fetchContractEventStream({ + baseUrl: 'https://api.example.com', + contractPrincipal, + limit: 12, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/extended/v1/address/SP1234567890.contract/transactions?limit=12&offset=0', + ); + expect(events).toHaveLength(2); + expect(events[0].txId).toBe('0x2'); + expect(events[0].category).toBe('execute'); + expect(events[1].txId).toBe('0x1'); + expect(events[1].summary).toContain('Community Grant'); + }); +}); + diff --git a/frontend/src/lib/contract-event-stream.ts b/frontend/src/lib/contract-event-stream.ts new file mode 100644 index 00000000..87335874 --- /dev/null +++ b/frontend/src/lib/contract-event-stream.ts @@ -0,0 +1,300 @@ +import { CONTRACT_PRINCIPAL, API_URL, formatStx } from '@/config'; +import { explorerTxUrl, truncateAddress } from './api'; +import { encodePathSegment } from './sanitize-url'; + +export type ContractEventCategory = + | 'stake' + | 'proposal' + | 'vote' + | 'cancel' + | 'execute' + | 'treasury' + | 'other'; + +export type ContractEventStatus = 'confirmed' | 'pending' | 'failed'; + +export interface ContractCallArg { + name?: string; + type?: string; + repr: string; +} + +export interface ContractCallPayload { + contract_id: string; + function_name: string; + function_args?: ContractCallArg[]; +} + +export interface ContractTransaction { + tx_id: string; + tx_type: string; + tx_status: string; + sender_address: string; + block_time?: number; + block_time_iso?: string; + event_count?: number; + tx_result?: { + repr?: string; + }; + contract_call?: ContractCallPayload; +} + +export interface ContractEventRecord { + id: string; + txId: string; + category: ContractEventCategory; + title: string; + summary: string; + senderAddress: string; + senderLabel: string; + timestamp: number; + status: ContractEventStatus; + functionName: string; + eventCount: number; + explorerUrl: string; + proposalId?: number; + amount?: number; + weight?: number; + support?: boolean; + errorMessage?: string; +} + +export interface ContractEventStreamOptions { + baseUrl?: string; + contractPrincipal?: string; + limit?: number; + offset?: number; +} + +interface AddressTransactionsResponse { + results?: ContractTransaction[]; +} + +const EVENT_FUNCTIONS = new Set([ + 'stake', + 'withdraw-stake', + 'create-proposal', + 'vote', + 'cancel-proposal', + 'execute-proposal', + 'deposit-treasury', +]); + +function normalizeClarityRepr(repr: string): string { + const stringMatch = repr.match(/^u?"(.*)"$/); + if (stringMatch) { + return stringMatch[1]; + } + + if (/^u\d+$/.test(repr)) { + return repr.slice(1); + } + + return repr; +} + +function parseNumberArg(arg?: ContractCallArg): number | undefined { + if (!arg) return undefined; + + const value = Number(normalizeClarityRepr(arg.repr)); + return Number.isFinite(value) ? value : undefined; +} + +function parseBooleanArg(arg?: ContractCallArg): boolean | undefined { + if (!arg) return undefined; + if (arg.repr === 'true') return true; + if (arg.repr === 'false') return false; + return undefined; +} + +function getCategory(functionName: string): ContractEventCategory { + switch (functionName) { + case 'stake': + case 'withdraw-stake': + return 'stake'; + case 'create-proposal': + return 'proposal'; + case 'vote': + return 'vote'; + case 'cancel-proposal': + return 'cancel'; + case 'execute-proposal': + return 'execute'; + case 'deposit-treasury': + return 'treasury'; + default: + return 'other'; + } +} + +function getTimestamp(tx: ContractTransaction): number { + if (tx.block_time_iso) { + const parsed = Date.parse(tx.block_time_iso); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + if (typeof tx.block_time === 'number') { + return tx.block_time * 1000; + } + + return Date.now(); +} + +function buildSummary(tx: ContractTransaction): Omit { + const functionName = tx.contract_call?.function_name ?? 'contract-call'; + const category = getCategory(functionName); + const args = tx.contract_call?.function_args ?? []; + const amountArg = parseNumberArg(args[0]); + const titleArg = args[1] ? normalizeClarityRepr(args[1].repr) : undefined; + const proposalIdArg = parseNumberArg(args[0]); + const weightArg = parseNumberArg(args[2]); + const supportArg = parseBooleanArg(args[1]); + const status = mapStatus(tx.tx_status); + const isConfirmed = status === 'confirmed'; + const errorMessage = tx.tx_result?.repr; + + switch (category) { + case 'stake': + return { + category, + title: isConfirmed ? (functionName === 'withdraw-stake' ? 'Stake withdrawn' : 'Stake confirmed') : 'Stake transaction failed', + summary: amountArg !== undefined + ? `${functionName === 'withdraw-stake' ? 'Withdrew' : 'Staked'} ${formatStx(amountArg)} STX` + : `${functionName === 'withdraw-stake' ? 'Stake withdrawal' : 'Stake'} submitted`, + amount: amountArg, + errorMessage: status === 'failed' ? errorMessage : undefined, + }; + case 'proposal': + return { + category, + title: isConfirmed ? 'Proposal created' : 'Proposal creation failed', + summary: amountArg !== undefined + ? `Submitted "${titleArg || 'Untitled proposal'}" for ${formatStx(amountArg)} STX` + : `Submitted "${titleArg || 'Untitled proposal'}"`, + amount: amountArg, + errorMessage: status === 'failed' ? errorMessage : undefined, + }; + case 'vote': + return { + category, + title: isConfirmed ? 'Vote recorded' : 'Vote transaction failed', + summary: [ + `Voted ${supportArg === false ? 'no' : 'yes'}`, + weightArg !== undefined ? `with weight ${weightArg}` : null, + proposalIdArg !== undefined ? `on proposal #${proposalIdArg}` : null, + ].filter(Boolean).join(' '), + proposalId: proposalIdArg, + weight: weightArg, + support: supportArg, + errorMessage: status === 'failed' ? errorMessage : undefined, + }; + case 'cancel': + return { + category, + title: isConfirmed ? 'Proposal cancelled' : 'Cancellation failed', + summary: proposalIdArg !== undefined ? `Cancelled proposal #${proposalIdArg}` : 'Cancelled a proposal', + proposalId: proposalIdArg, + errorMessage: status === 'failed' ? errorMessage : undefined, + }; + case 'execute': + return { + category, + title: isConfirmed ? 'Proposal executed' : 'Execution failed', + summary: proposalIdArg !== undefined ? `Executed proposal #${proposalIdArg}` : 'Executed a proposal', + proposalId: proposalIdArg, + errorMessage: status === 'failed' ? errorMessage : undefined, + }; + case 'treasury': + return { + category, + title: isConfirmed ? 'Treasury funded' : 'Treasury deposit failed', + summary: amountArg !== undefined + ? `Deposited ${formatStx(amountArg)} STX into the treasury` + : 'Deposited funds into the treasury', + amount: amountArg, + errorMessage: status === 'failed' ? errorMessage : undefined, + }; + default: + return { + category: 'other', + title: isConfirmed ? 'Contract interaction' : 'Contract interaction failed', + summary: functionName.replace(/-/g, ' '), + errorMessage: status === 'failed' ? errorMessage : undefined, + }; + } +} + +function mapStatus(txStatus: string): ContractEventStatus { + switch (txStatus) { + case 'success': + return 'confirmed'; + case 'pending': + return 'pending'; + default: + return 'failed'; + } +} + +export function normalizeContractTransaction( + tx: ContractTransaction, + contractPrincipal: string = CONTRACT_PRINCIPAL, +): ContractEventRecord | null { + if (tx.tx_type !== 'contract_call') { + return null; + } + + const contractId = tx.contract_call?.contract_id; + const functionName = tx.contract_call?.function_name; + + if (!contractId || !functionName || !EVENT_FUNCTIONS.has(functionName)) { + return null; + } + + if (contractId !== contractPrincipal) { + return null; + } + + const status = mapStatus(tx.tx_status); + const summary = buildSummary(tx); + const timestamp = getTimestamp(tx); + + return { + id: tx.tx_id, + txId: tx.tx_id, + senderAddress: tx.sender_address, + senderLabel: truncateAddress(tx.sender_address), + timestamp, + status, + functionName, + eventCount: tx.event_count ?? 0, + explorerUrl: explorerTxUrl(tx.tx_id), + ...summary, + }; +} + +export async function fetchContractEventStream( + options: ContractEventStreamOptions = {}, +): Promise { + const baseUrl = options.baseUrl ?? API_URL; + const contractPrincipal = options.contractPrincipal ?? CONTRACT_PRINCIPAL; + const limit = Math.max(1, options.limit ?? 12); + const offset = Math.max(0, options.offset ?? 0); + + const response = await fetch( + `${baseUrl}/extended/v1/address/${encodePathSegment(contractPrincipal)}/transactions?limit=${limit}&offset=${offset}`, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch contract activity: ${response.statusText}`); + } + + const data = (await response.json()) as AddressTransactionsResponse; + const results = data.results ?? []; + + return results + .map((tx) => normalizeContractTransaction(tx, contractPrincipal)) + .filter((event): event is ContractEventRecord => event !== null) + .sort((a, b) => b.timestamp - a.timestamp); +}