Skip to content

Commit 2d76f4e

Browse files
committed
✨ Display contract history
1 parent 4ae00b9 commit 2d76f4e

3 files changed

Lines changed: 283 additions & 5 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/explorer/Contract.vue

Lines changed: 276 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
import ExplorerLayout from "@/explorer/components/ExplorerLayout.vue";
33
import CopyButton from "@/components/CopyButton.vue";
44
import { 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
910
const route = useRoute();
11+
const router = useRouter();
1012
const contract_name = computed(() => route.params.contract_name as string);
1113
1214
watchEffect(() => {
@@ -18,21 +20,217 @@ watchEffect(() => {
1820
const data = computed(() => contractStore.value.data?.[contract_name.value]);
1921
2022
const 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
2231
onMounted(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
2844
const 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>

src/model/orderbook.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export type PermissionnedOrderbookAction =
6161
amount: number;
6262
destination: WithdrawDestination;
6363
};
64+
}
65+
| {
66+
UpgradeContract: Uint8Array;
6467
};
6568

6669
export type PermissionlessOrderbookAction = {
@@ -141,6 +144,9 @@ const schema = BorshSchema.Enum({
141144
address: BorshSchema.String,
142145
}),
143146
}),
147+
UpgradeContract: BorshSchema.Struct({
148+
new_program_id: BorshSchema.Vec(BorshSchema.u8),
149+
}),
144150
}),
145151
global_nonce: BorshSchema.u32,
146152
}),

0 commit comments

Comments
 (0)