22import ExplorerLayout from " @/explorer/components/ExplorerLayout.vue" ;
33import CopyButton from " @/components/CopyButton.vue" ;
44import { contractStore , transactionStore } from " @/state/data" ;
5- import { computed , watchEffect , onMounted , ref } from " vue" ;
6- import { useRoute } from " vue-router" ;
7- import { getTimeAgo } from " @/state/utils" ;
5+ import { network , getNetworkIndexerApiUrl } from " @/state/network" ;
6+ import { computed , watchEffect , onMounted , ref , watch } from " vue" ;
7+ import { useRoute , useRouter } from " vue-router" ;
8+ import { getTimeAgo , copyToClipboard } from " @/state/utils" ;
89
910const route = useRoute ();
11+ const router = useRouter ();
1012const contract_name = computed (() => route .params .contract_name as string );
1113
1214watchEffect (() => {
@@ -18,21 +20,217 @@ watchEffect(() => {
1820const data = computed (() => contractStore .value .data ?.[contract_name .value ]);
1921
2022const transactions = ref <string []>([]);
23+ const history = ref <ContractHistoryItem []>([]);
24+ const historyLoading = ref (false );
25+ const historyError = ref (" " );
26+ const toastMessage = ref (" " );
27+ const isToastVisible = ref (false );
28+ const toastX = ref (0 );
29+ const toastY = ref (0 );
2130
2231onMounted (async () => {
2332 transactions .value = await transactionStore .value .getTransactionsByContract (contract_name .value );
2433});
2534
26- const tabs = [{ name: " Overview" }, { name: " Raw JSON" }];
35+ const tabs = [{ name: " Overview" }, { name: " History" }, { name: " Raw JSON" }];
36+ const activeTab = ref ((route .query .tab as string ) || " Overview" );
37+ watch (
38+ () => route .query .tab ,
39+ (tab ) => {
40+ activeTab .value = (tab as string ) || " Overview" ;
41+ },
42+ );
2743
2844const formatTimestamp = (timestamp : number ) => {
2945 return ` ${getTimeAgo (timestamp )} (${new Date (timestamp ).toLocaleString ()}) ` ;
3046};
47+
48+ interface ContractHistoryItem {
49+ change_type? : string ;
50+ contract_name? : string ;
51+ tx_hash? : string ;
52+ block_height? : number ;
53+ height? : number ;
54+ timestamp? : number ;
55+ program_id? : string ;
56+ soft_timeout? : number ;
57+ hard_timeout? : number ;
58+ state_commitment? : string ;
59+ verifier? : string ;
60+ [key : string ]: any ;
61+ }
62+
63+ const historyChangeTypes = [" registered" , " program_id_updated" , " timeout_updated" , " deleted" ];
64+
65+ const fetchContractHistory = async () => {
66+ historyLoading .value = true ;
67+ historyError .value = " " ;
68+
69+ try {
70+ const baseUrl = getNetworkIndexerApiUrl (network .value );
71+ const params = new URLSearchParams ({
72+ change_type: historyChangeTypes .join (" ," ),
73+ no_cache: Date .now ().toString (),
74+ });
75+ const response = await fetch (` ${baseUrl }/v1/indexer/contract/${contract_name .value }/history?${params .toString ()} ` );
76+
77+ if (! response .ok ) {
78+ throw new Error (` HTTP error! status: ${response .status } ` );
79+ }
80+
81+ const payload = await response .json ();
82+ history .value = Array .isArray (payload ) ? payload : (payload ?.history ?? []);
83+ } catch (err ) {
84+ console .error (` Error fetching ${contract_name .value } contract history: ` , err );
85+ historyError .value = err instanceof Error ? err .message : " Unknown error" ;
86+ history .value = [];
87+ } finally {
88+ historyLoading .value = false ;
89+ }
90+ };
91+
92+ const getHistoryBlockHeight = (item : ContractHistoryItem ) => {
93+ return item .block_height ?? item .height ?? undefined ;
94+ };
95+
96+ const getHistoryTxHash = (item : ContractHistoryItem ) => {
97+ return item .tx_hash ?? item .txHash ?? undefined ;
98+ };
99+
100+ const getHistoryDetails = (item : ContractHistoryItem ) => {
101+ const details: { label: string ; value: string }[] = [];
102+ const changeTypes = normalizeHistoryChangeTypes (item .change_type );
103+ const hasType = (type : string ) => changeTypes .includes (type );
104+
105+ if (hasType (" registered" )) {
106+ if (item .program_id ) details .push ({ label: " Program ID" , value: item .program_id });
107+ if (item .soft_timeout !== undefined ) details .push ({ label: " Soft Timeout" , value: String (item .soft_timeout ) });
108+ if (item .hard_timeout !== undefined ) details .push ({ label: " Hard Timeout" , value: String (item .hard_timeout ) });
109+ if (item .state_commitment ) details .push ({ label: " State" , value: item .state_commitment });
110+ if (item .verifier ) details .push ({ label: " Verifier" , value: item .verifier });
111+ return details ;
112+ }
113+
114+ if (hasType (" program_id_updated" ) && item .program_id ) {
115+ details .push ({ label: " Program ID" , value: item .program_id });
116+ }
117+ if (hasType (" timeout_updated" )) {
118+ if (item .soft_timeout !== undefined ) details .push ({ label: " Soft Timeout" , value: String (item .soft_timeout ) });
119+ if (item .hard_timeout !== undefined ) details .push ({ label: " Hard Timeout" , value: String (item .hard_timeout ) });
120+ }
121+
122+ return details ;
123+ };
124+
125+ const isProgramIdDetail = (detail : { label: string ; value: string }) => detail .label === " Program ID" ;
126+ const handleProgramIdCopy = async (value : string , event : MouseEvent ) => {
127+ await copyToClipboard (value );
128+ const target = event .currentTarget as HTMLElement | null ;
129+ if (target ) {
130+ const rect = target .getBoundingClientRect ();
131+ const padding = 8 ;
132+ const maxX = window .innerWidth - padding ;
133+ const maxY = window .innerHeight - padding ;
134+ toastX .value = Math .min (Math .max (rect .left + rect .width / 2 , padding ), maxX );
135+ toastY .value = Math .min (Math .max (rect .top - 8 , padding ), maxY );
136+ } else {
137+ toastX .value = event .clientX ;
138+ toastY .value = event .clientY - 8 ;
139+ }
140+ toastMessage .value = " Program ID copied" ;
141+ isToastVisible .value = true ;
142+ setTimeout (() => {
143+ isToastVisible .value = false ;
144+ }, 1500 );
145+ };
146+
147+ const normalizeHistoryChangeTypes = (changeType ? : string | string []) => {
148+ if (! changeType ) return [];
149+ if (Array .isArray (changeType )) return changeType ;
150+ return [changeType ];
151+ };
152+
153+ const formatHistoryChangeType = (changeType ? : string ) => {
154+ if (! changeType ) return " Event" ;
155+ const labels: Record <string , string > = {
156+ registered: " Registered" ,
157+ program_id_updated: " Program ID Updated" ,
158+ timeout_updated: " Timeout Updated" ,
159+ deleted: " Deleted" ,
160+ };
161+ return labels [changeType ] || changeType .replaceAll (" _" , " " );
162+ };
163+
164+ const getHistoryChangeTypeClass = (changeType ? : string ) => {
165+ const classes: Record <string , string > = {
166+ registered: " bg-emerald-500/15 text-emerald-700" ,
167+ program_id_updated: " bg-blue-500/15 text-blue-700" ,
168+ timeout_updated: " bg-amber-500/15 text-amber-700" ,
169+ deleted: " bg-rose-500/15 text-rose-700" ,
170+ };
171+ return classes [changeType || " " ] || " bg-secondary/10 text-secondary" ;
172+ };
173+
174+ watch (
175+ [contract_name , network ],
176+ () => {
177+ fetchContractHistory ();
178+ },
179+ { immediate: true },
180+ );
181+
182+ const updateURL = () => {
183+ const query: Record <string , string > = {};
184+
185+ if (activeTab .value !== " Overview" ) {
186+ query .tab = activeTab .value ;
187+ }
188+
189+ router .replace ({
190+ name: " Contract" ,
191+ params: { contract_name: contract_name .value },
192+ query: Object .keys (query ).length > 0 ? query : undefined ,
193+ });
194+ };
195+
196+ let isInitialized = false ;
197+ watch (
198+ activeTab ,
199+ () => {
200+ if (isInitialized ) {
201+ updateURL ();
202+ }
203+ },
204+ { immediate: false },
205+ );
206+
207+ const handleTabChange = (tab : string ) => {
208+ activeTab .value = tab ;
209+ };
210+
211+ watch (
212+ () => contract_name .value ,
213+ () => {
214+ if (! isInitialized ) {
215+ setTimeout (() => {
216+ isInitialized = true ;
217+ }, 100 );
218+ }
219+ },
220+ { immediate: true },
221+ );
31222 </script >
32223
33224<template >
34- <ExplorerLayout title="Contract Details" :tabs =" tabs " >
225+ <ExplorerLayout title="Contract Details" :tabs =" tabs " : active-tab = " activeTab " @ update : active-tab = " handleTabChange " >
35226 <template #default =" { activeTab } " >
227+ <div
228+ v-if =" isToastVisible"
229+ class =" fixed z-50 rounded-xl bg-secondary text-white px-4 py-2 text-sm shadow-lg pointer-events-none"
230+ :style =" { left: `${toastX}px`, top: `${toastY}px`, transform: 'translate(-50%, -100%)' }"
231+ >
232+ {{ toastMessage }}
233+ </div >
36234 <div v-if =" activeTab === 'Overview'" class =" data-card" >
37235 <div class =" divide-y divide-secondary/5" >
38236 <div class =" info-row" >
@@ -132,6 +330,79 @@ const formatTimestamp = (timestamp: number) => {
132330 </div >
133331 </div >
134332
333+ <div v-else-if =" activeTab === 'History'" class =" data-card" >
334+ <h3 class =" card-header" >Contract History</h3 >
335+ <div v-if =" historyLoading" class =" text-center py-6 text-secondary" >Loading contract history...</div >
336+ <div v-else-if =" historyError" class =" text-center py-6 text-red-500" >{{ historyError }}</div >
337+ <div v-else-if =" history.length === 0" class =" text-center py-6 text-secondary" >No history events found</div >
338+ <div v-else class =" space-y-3" >
339+ <div
340+ v-for =" (event, index) in history"
341+ :key =" event.tx_hash || event.txHash || `${event.change_type}-${index}`"
342+ class =" flex items-start justify-between gap-4 p-3 rounded-lg border border-secondary/10 bg-white/60"
343+ >
344+ <div class =" flex items-start gap-3 min-w-0" >
345+ <div class =" w-9 h-9 bg-secondary/10 rounded-lg flex items-center justify-center shrink-0" >
346+ <span class =" text-xs text-secondary" >EV</span >
347+ </div >
348+ <div class =" min-w-0" >
349+ <div class =" flex items-center gap-2 flex-wrap" >
350+ <div class =" flex flex-wrap gap-2" >
351+ <span
352+ v-for =" changeType in normalizeHistoryChangeTypes(event.change_type)"
353+ :key =" changeType"
354+ :class =" [
355+ 'px-3 py-1 rounded-full text-sm font-semibold',
356+ getHistoryChangeTypeClass(changeType),
357+ ]"
358+ >
359+ {{ formatHistoryChangeType(changeType) }}
360+ </span >
361+ </div >
362+ <span v-if =" getHistoryBlockHeight(event)" class =" text-xs text-secondary" >
363+ Block #{{ getHistoryBlockHeight(event) }}
364+ </span >
365+ <span v-if =" event.timestamp" class =" text-xs text-secondary" >
366+ {{ formatTimestamp(event.timestamp) }}
367+ </span >
368+ </div >
369+ <div v-if =" getHistoryTxHash(event)" class =" text-xs text-secondary" >
370+ <RouterLink
371+ :to =" { name: ' Transaction' , params: { tx_hash: getHistoryTxHash (event ) } } "
372+ class="text-link font-mono"
373+ >
374+ {{ getHistoryTxHash(event) }}
375+ </RouterLink >
376+ </div >
377+ <div v-if =" getHistoryDetails(event).length > 0" class =" flex flex-wrap gap-2 mt-2 text-xs text-secondary" >
378+ <span
379+ v-for =" detail in getHistoryDetails(event)"
380+ :key =" detail.label"
381+ :class =" [
382+ 'px-2 py-1 rounded-full bg-secondary/5 max-w-[260px]',
383+ isProgramIdDetail(detail) ? 'cursor-pointer hover:bg-secondary/10' : '',
384+ ]"
385+ :title =" `${detail.label}: ${detail.value}`"
386+ @click =" isProgramIdDetail(detail) ? handleProgramIdCopy(detail.value, $event) : undefined"
387+ >
388+ <span v-if =" isProgramIdDetail(detail)" class =" inline-flex items-center gap-2 w-full" >
389+ <span class =" font-medium" >{{ detail.label }}:</span >
390+ <span class =" truncate max-w-[140px]" >{{ detail.value }}</span >
391+ </span >
392+ <span v-else class =" truncate block" >
393+ {{ detail.label }}: {{ detail.value }}
394+ </span >
395+ </span >
396+ </div >
397+ </div >
398+ </div >
399+ <svg class =" w-4 h-4 text-neutral shrink-0 mt-1" fill =" none" stroke =" currentColor" viewBox =" 0 0 24 24" >
400+ <path stroke-linecap =" round" stroke-linejoin =" round" stroke-width =" 2" d =" M9 5l7 7-7 7" />
401+ </svg >
402+ </div >
403+ </div >
404+ </div >
405+
135406 <div v-else-if =" activeTab === 'Raw JSON'" class =" data-card" >
136407 <h3 class =" card-header" >Contract Data</h3 >
137408 <pre class =" code-block" >{{ JSON.stringify(data, null, 2) }}</pre >
0 commit comments