From 2d76f4eabfec23ef29892ceda1634ece107c6d96 Mon Sep 17 00:00:00 2001 From: Bertrand Darbon Date: Fri, 9 Jan 2026 16:36:42 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Display=20contract=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 1 + src/explorer/Contract.vue | 281 +++++++++++++++++++++++++++++++++++++- src/model/orderbook.ts | 6 + 3 files changed, 283 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 0d791ce..df70b05 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "hyliou", diff --git a/src/explorer/Contract.vue b/src/explorer/Contract.vue index 78e31bf..5f3c187 100644 --- a/src/explorer/Contract.vue +++ b/src/explorer/Contract.vue @@ -2,11 +2,13 @@ import ExplorerLayout from "@/explorer/components/ExplorerLayout.vue"; import CopyButton from "@/components/CopyButton.vue"; import { contractStore, transactionStore } from "@/state/data"; -import { computed, watchEffect, onMounted, ref } from "vue"; -import { useRoute } from "vue-router"; -import { getTimeAgo } from "@/state/utils"; +import { network, getNetworkIndexerApiUrl } from "@/state/network"; +import { computed, watchEffect, onMounted, ref, watch } from "vue"; +import { useRoute, useRouter } from "vue-router"; +import { getTimeAgo, copyToClipboard } from "@/state/utils"; const route = useRoute(); +const router = useRouter(); const contract_name = computed(() => route.params.contract_name as string); watchEffect(() => { @@ -18,21 +20,217 @@ watchEffect(() => { const data = computed(() => contractStore.value.data?.[contract_name.value]); const transactions = ref([]); +const history = ref([]); +const historyLoading = ref(false); +const historyError = ref(""); +const toastMessage = ref(""); +const isToastVisible = ref(false); +const toastX = ref(0); +const toastY = ref(0); onMounted(async () => { transactions.value = await transactionStore.value.getTransactionsByContract(contract_name.value); }); -const tabs = [{ name: "Overview" }, { name: "Raw JSON" }]; +const tabs = [{ name: "Overview" }, { name: "History" }, { name: "Raw JSON" }]; +const activeTab = ref((route.query.tab as string) || "Overview"); +watch( + () => route.query.tab, + (tab) => { + activeTab.value = (tab as string) || "Overview"; + }, +); const formatTimestamp = (timestamp: number) => { return `${getTimeAgo(timestamp)} (${new Date(timestamp).toLocaleString()})`; }; + +interface ContractHistoryItem { + change_type?: string; + contract_name?: string; + tx_hash?: string; + block_height?: number; + height?: number; + timestamp?: number; + program_id?: string; + soft_timeout?: number; + hard_timeout?: number; + state_commitment?: string; + verifier?: string; + [key: string]: any; +} + +const historyChangeTypes = ["registered", "program_id_updated", "timeout_updated", "deleted"]; + +const fetchContractHistory = async () => { + historyLoading.value = true; + historyError.value = ""; + + try { + const baseUrl = getNetworkIndexerApiUrl(network.value); + const params = new URLSearchParams({ + change_type: historyChangeTypes.join(","), + no_cache: Date.now().toString(), + }); + const response = await fetch(`${baseUrl}/v1/indexer/contract/${contract_name.value}/history?${params.toString()}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const payload = await response.json(); + history.value = Array.isArray(payload) ? payload : (payload?.history ?? []); + } catch (err) { + console.error(`Error fetching ${contract_name.value} contract history:`, err); + historyError.value = err instanceof Error ? err.message : "Unknown error"; + history.value = []; + } finally { + historyLoading.value = false; + } +}; + +const getHistoryBlockHeight = (item: ContractHistoryItem) => { + return item.block_height ?? item.height ?? undefined; +}; + +const getHistoryTxHash = (item: ContractHistoryItem) => { + return item.tx_hash ?? item.txHash ?? undefined; +}; + +const getHistoryDetails = (item: ContractHistoryItem) => { + const details: { label: string; value: string }[] = []; + const changeTypes = normalizeHistoryChangeTypes(item.change_type); + const hasType = (type: string) => changeTypes.includes(type); + + if (hasType("registered")) { + if (item.program_id) details.push({ label: "Program ID", value: item.program_id }); + if (item.soft_timeout !== undefined) details.push({ label: "Soft Timeout", value: String(item.soft_timeout) }); + if (item.hard_timeout !== undefined) details.push({ label: "Hard Timeout", value: String(item.hard_timeout) }); + if (item.state_commitment) details.push({ label: "State", value: item.state_commitment }); + if (item.verifier) details.push({ label: "Verifier", value: item.verifier }); + return details; + } + + if (hasType("program_id_updated") && item.program_id) { + details.push({ label: "Program ID", value: item.program_id }); + } + if (hasType("timeout_updated")) { + if (item.soft_timeout !== undefined) details.push({ label: "Soft Timeout", value: String(item.soft_timeout) }); + if (item.hard_timeout !== undefined) details.push({ label: "Hard Timeout", value: String(item.hard_timeout) }); + } + + return details; +}; + +const isProgramIdDetail = (detail: { label: string; value: string }) => detail.label === "Program ID"; +const handleProgramIdCopy = async (value: string, event: MouseEvent) => { + await copyToClipboard(value); + const target = event.currentTarget as HTMLElement | null; + if (target) { + const rect = target.getBoundingClientRect(); + const padding = 8; + const maxX = window.innerWidth - padding; + const maxY = window.innerHeight - padding; + toastX.value = Math.min(Math.max(rect.left + rect.width / 2, padding), maxX); + toastY.value = Math.min(Math.max(rect.top - 8, padding), maxY); + } else { + toastX.value = event.clientX; + toastY.value = event.clientY - 8; + } + toastMessage.value = "Program ID copied"; + isToastVisible.value = true; + setTimeout(() => { + isToastVisible.value = false; + }, 1500); +}; + +const normalizeHistoryChangeTypes = (changeType?: string | string[]) => { + if (!changeType) return []; + if (Array.isArray(changeType)) return changeType; + return [changeType]; +}; + +const formatHistoryChangeType = (changeType?: string) => { + if (!changeType) return "Event"; + const labels: Record = { + registered: "Registered", + program_id_updated: "Program ID Updated", + timeout_updated: "Timeout Updated", + deleted: "Deleted", + }; + return labels[changeType] || changeType.replaceAll("_", " "); +}; + +const getHistoryChangeTypeClass = (changeType?: string) => { + const classes: Record = { + registered: "bg-emerald-500/15 text-emerald-700", + program_id_updated: "bg-blue-500/15 text-blue-700", + timeout_updated: "bg-amber-500/15 text-amber-700", + deleted: "bg-rose-500/15 text-rose-700", + }; + return classes[changeType || ""] || "bg-secondary/10 text-secondary"; +}; + +watch( + [contract_name, network], + () => { + fetchContractHistory(); + }, + { immediate: true }, +); + +const updateURL = () => { + const query: Record = {}; + + if (activeTab.value !== "Overview") { + query.tab = activeTab.value; + } + + router.replace({ + name: "Contract", + params: { contract_name: contract_name.value }, + query: Object.keys(query).length > 0 ? query : undefined, + }); +}; + +let isInitialized = false; +watch( + activeTab, + () => { + if (isInitialized) { + updateURL(); + } + }, + { immediate: false }, +); + +const handleTabChange = (tab: string) => { + activeTab.value = tab; +}; + +watch( + () => contract_name.value, + () => { + if (!isInitialized) { + setTimeout(() => { + isInitialized = true; + }, 100); + } + }, + { immediate: true }, +);