From 8442aba9e24acde4aa96d3619bdf45279c9f5e90 Mon Sep 17 00:00:00 2001 From: biokes Date: Tue, 30 Jun 2026 09:34:59 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20token=20lookup,=20extended=20event=20fe?= =?UTF-8?q?ed,=20contract=20upgrade=20UI=20-=20Fix=20#306:=20add=20contrac?= =?UTF-8?q?t=20ID=20search/lookup=20input=20to=20landing=20page=20hero=20-?= =?UTF-8?q?=20Validate=20against=20^C[A-Z2-7]{55}$=20Stellar=20contract=20?= =?UTF-8?q?ID=20regex=20-=20Route=20to=20/token/=20on=20valid=20submit?= =?UTF-8?q?=20-=20Graceful=20error=20state=20for=20invalid=20input=20-=20F?= =?UTF-8?q?ix=20#305:=20extend=20activity=20feed=20to=20decode=20all=20adm?= =?UTF-8?q?in/compliance=20events=20-=20Add=20TokenActivityType=20union=20?= =?UTF-8?q?with=20freeze/unfreeze,=20pause/unpause,=20authorize/unauthoriz?= =?UTF-8?q?e,=20set=5Fadmin,=20revoke=5Fadmin,=20upgrade,=20other=20-=20Ad?= =?UTF-8?q?d=20TRACKED=5FEVENT=5FTOPICS=20constant=20and=20decodeActivityE?= =?UTF-8?q?vent()=20helper=20in=20stellar.ts=20-=20Extend=20fetchAccountOp?= =?UTF-8?q?erations()=20and=20fetchTransactionHistory()=20to=20subscribe?= =?UTF-8?q?=20to=20all=2013=20topic=20types=20via=20indexer=20-=20Rewrite?= =?UTF-8?q?=20useContractEvents.ts=20poll=20loop=20with=20full=20switch=20?= =?UTF-8?q?decoder=20-=20Add=20subject=20field=20to=20TokenActivityInfo=20?= =?UTF-8?q?for=20admin/compliance=20events=20-=20ActivityFeed.tsx:=20per-t?= =?UTF-8?q?ype=20icons,=20human=20labels,=20subject=20column=20fallback=20?= =?UTF-8?q?-=20Fix=20#299:=20Upgrade=20Contract=20UI=20in=20AdminPanel=20-?= =?UTF-8?q?=20Advanced/Danger=20section=20with=20WASM=20hash=20input=20(64?= =?UTF-8?q?-char=20hex=20validation)=20-=20is=5Flocked()=20guard=20?= =?UTF-8?q?=E2=80=94=20disables=20upgrade=20when=20contract=20is=20immutab?= =?UTF-8?q?le=20-=20Typed-symbol=20confirmation=20modal=20before=20submitt?= =?UTF-8?q?ing=20upgrade()=20-=20Audit=20warning=20banner;=20uses=20existi?= =?UTF-8?q?ng=20sign/submit=20flow=20-=20Pass=20tokenSymbol=20prop=20from?= =?UTF-8?q?=20TokenDashboard=20to=20AdminPanel=20Closes=20#306=20Closes=20?= =?UTF-8?q?#305=20Closes=20#299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/[contractId]/ActivityFeed.tsx | 72 +++++- .../dashboard/[contractId]/TokenDashboard.tsx | 1 + .../[contractId]/components/AdminPanel.tsx | 215 +++++++++++++++++- frontend/app/page.tsx | 67 +++++- frontend/hooks/useContractEvents.ts | 154 ++++++++----- frontend/lib/stellar.ts | 213 ++++++++++++----- 6 files changed, 601 insertions(+), 121 deletions(-) diff --git a/frontend/app/dashboard/[contractId]/ActivityFeed.tsx b/frontend/app/dashboard/[contractId]/ActivityFeed.tsx index 5d59c330..26eebc47 100644 --- a/frontend/app/dashboard/[contractId]/ActivityFeed.tsx +++ b/frontend/app/dashboard/[contractId]/ActivityFeed.tsx @@ -7,6 +7,14 @@ import { ArrowLeftRight, Flame, Droplets, + SnowflakeIcon, + PauseCircle, + PlayCircle, + ShieldCheck, + ShieldOff, + UserCheck, + UserX, + Upload, } from "lucide-react"; import { type TokenActivityInfo, @@ -118,20 +126,72 @@ export default function ActivityFeed({ accountId }: { accountId: string }) { return ; case "transfer": return ; + case "freeze": + return ; + case "unfreeze": + return ; + case "pause": + return ; + case "unpause": + return ; + case "authorize": + return ; + case "unauthorize": + return ; + case "set_admin": + return ; + case "revoke_admin": + return ; + case "upgrade": + return ; default: return ; } }; + const getTypeLabel = (type: string): string => { + switch (type) { + case "mint": return "Mint"; + case "burn": return "Burn"; + case "clawback": return "Clawback"; + case "transfer": return "Transfer"; + case "freeze": return "Account frozen"; + case "unfreeze": return "Account unfrozen"; + case "pause": return "Token paused"; + case "unpause": return "Token unpaused"; + case "authorize": return "Authorized"; + case "unauthorize": return "Unauthorize"; + case "set_admin": return "Admin set"; + case "revoke_admin": return "Admin revoked"; + case "upgrade": return "Contract upgraded"; + default: return "Other"; + } + }; + const getStyleForType = (type: string) => { switch (type) { case "mint": return "text-blue-400 bg-blue-400/10 border-blue-400/20"; case "burn": case "clawback": + case "revoke_admin": return "text-red-400 bg-red-400/10 border-red-400/20"; case "transfer": + case "unpause": + case "authorize": return "text-green-400 bg-green-400/10 border-green-400/20"; + case "freeze": + return "text-cyan-400 bg-cyan-400/10 border-cyan-400/20"; + case "unfreeze": + return "text-teal-400 bg-teal-400/10 border-teal-400/20"; + case "pause": + return "text-yellow-400 bg-yellow-400/10 border-yellow-400/20"; + case "unauthorize": + return "text-orange-400 bg-orange-400/10 border-orange-400/20"; + case "set_admin": + return "text-stellar-400 bg-stellar-400/10 border-stellar-400/20"; + case "upgrade": + return "text-purple-400 bg-purple-400/10 border-purple-400/20"; default: return "text-gray-400 bg-gray-400/10 border-gray-400/20"; } @@ -174,14 +234,22 @@ export default function ActivityFeed({ accountId }: { accountId: string }) { className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium capitalize ${getStyleForType(op.type)}`} > {getTypeIcon(op.type)} - {op.type} + {getTypeLabel(op.type)} {op.amount !== "-" ? op.amount : "-"} - {op.from !== "-" ? ( + {op.subject ? ( + + ) : op.from !== "-" ? ( )} diff --git a/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx b/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx index 9c2c61cd..1292c11e 100644 --- a/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx +++ b/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx @@ -38,6 +38,7 @@ import { Clock, Lock, AlertTriangle, + Upload, } from "lucide-react"; import { VestingCurveChart } from "@/components/VestingCurveChart"; import { PreflightCheckDisplay } from "@/components/ui/PreflightCheck"; @@ -104,11 +105,19 @@ const metadataUriSchema = z.object({ .min(1, "URI is required"), }); +const upgradeSchema = z.object({ + wasmHash: z + .string() + .regex(/^[0-9a-fA-F]{64}$/, "Must be a 64-character hex string (32-byte WASM hash)"), + confirmSymbol: z.string().min(1, "Type the token symbol to confirm"), +}); + type MintData = z.infer; type BurnData = z.infer; type TransferAdminData = z.infer; type VestingData = z.infer; type MetadataUriData = z.infer; +type UpgradeData = z.infer; type AcceptAdminData = Record; type AdminActionData = MintData | BurnData | TransferAdminData | VestingData | MetadataUriData | AcceptAdminData; @@ -120,9 +129,10 @@ interface AdminPanelProps { maxSupply?: string | null; totalSupply?: string; decimals: number; + tokenSymbol?: string; } -export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: AdminPanelProps) { +export function AdminPanel({ contractId, maxSupply, totalSupply, decimals, tokenSymbol }: AdminPanelProps) { const { signTransaction, publicKey } = useWallet(); const { networkConfig } = useNetwork(); const toast = useToast(); @@ -137,6 +147,9 @@ export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: Adm const [paused, setPaused] = useState(false); const [showPauseConfirm, setShowPauseConfirm] = useState(false); + // Upgrade state + const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false); + const [mintMode, setMintMode] = useState<"single" | "batch">("single"); const [batchData, setBatchData] = useState(""); const [batchErrors, setBatchErrors] = useState([]); @@ -148,6 +161,7 @@ export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: Adm const transferForm = useForm({ resolver: zodResolver(transferAdminSchema) }); const vestingForm = useForm({ resolver: zodResolver(vestingSchema) }); const metadataUriForm = useForm({ resolver: zodResolver(metadataUriSchema) }); + const upgradeForm = useForm({ resolver: zodResolver(upgradeSchema) }); // Live values for the vesting curve preview chart. const [watchedCliff, watchedDuration] = vestingForm.watch(["cliffDays", "durationDays"]); @@ -659,6 +673,72 @@ export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: Adm } }; + const handleUpgrade = async (data: UpgradeData) => { + if (!publicKey) return; + + const expectedSymbol = (tokenSymbol ?? "").toUpperCase(); + if (data.confirmSymbol.trim().toUpperCase() !== expectedSymbol) { + upgradeForm.setError("confirmSymbol", { + message: `Type "${expectedSymbol}" exactly to confirm.`, + }); + return; + } + + setLoading("upgrade"); + setSuccess(null); + try { + const txHash = await wrapRpcCall( + async () => { + const server = new rpc.Server(networkConfig.rpcUrl); + const account = await server.getAccount(publicKey); + const contract = new Contract(contractId); + + // Convert the 64-char hex string to a BytesN<32> ScVal + const hashBytes = Buffer.from(data.wasmHash, "hex"); + const { xdr: xdrLib } = await import("@stellar/stellar-sdk"); + const hashScVal = xdrLib.ScVal.scvBytes(hashBytes); + + const built = new TransactionBuilder(account, { + fee: "1000", + networkPassphrase: networkConfig.passphrase, + }) + .addOperation(contract.call("upgrade", hashScVal)) + .setTimeout(30) + .build(); + + const sim = await server.simulateTransaction(built); + if (rpc.Api.isSimulationError(sim)) { + throw new Error(`Simulation failed: ${sim.error}`); + } + const prepared = rpc.assembleTransaction(built, sim).build(); + + const signedXdr = await signTransaction(prepared.toXDR(), { + networkPassphrase: networkConfig.passphrase, + }); + + return await submitSignedTransaction(signedXdr); + }, + { operation: "Upgrade contract", toastTitle: "Upgrade failed" }, + ); + + setLastTxHash(txHash); + setSuccess("upgrade"); + setShowUpgradeConfirm(false); + upgradeForm.reset(); + setAnnouncement(`Contract upgrade submitted. Transaction hash ${txHash}.`); + toast.show({ + title: "Contract upgraded", + message: "The contract WASM has been replaced. All holders are now on the new logic.", + variant: "success", + duration: 8_000, + }); + } catch { + // Toast already surfaced via wrapRpcCall. + } finally { + setLoading(null); + } + }; + const adminDisabled = !!loading || locked; return ( @@ -1354,6 +1434,139 @@ export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: Adm )} + + {/* ── Advanced / Danger: Upgrade Contract ──────────────── */} +
+
+
+ + Advanced / Danger + +
+
+ +
+
+
+ +
+
+

Upgrade Contract

+

+ Replace the contract WASM with a new version. Affects all holders immediately. +

+
+
+ + {locked ? ( +
+ + Contract is locked — upgrades are permanently disabled. +
+ ) : ( + <> +
+ Before upgrading: ensure the new WASM has been + reviewed and audited. This replaces contract logic for every token holder and cannot be + undone unless the new contract itself supports a further upgrade. +
+ +
{ + if (!showUpgradeConfirm) { + setShowUpgradeConfirm(true); + } else { + handleUpgrade(data); + } + })} + className="flex flex-col gap-4" + > +
+ + + {upgradeForm.formState.errors.wasmHash && ( +

+ {upgradeForm.formState.errors.wasmHash.message} +

+ )} +
+ + {showUpgradeConfirm && ( +
+

+ Confirm upgrade +

+

+ Type the token symbol{" "} + + {tokenSymbol ?? "SYMBOL"} + {" "} + to confirm you understand this is irreversible. +

+ + {upgradeForm.formState.errors.confirmSymbol && ( +

+ {upgradeForm.formState.errors.confirmSymbol.message} +

+ )} +
+ )} + +
+ {showUpgradeConfirm && ( + + )} + +
+
+ + )} +
+
); } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 6b495b63..50ad7396 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,10 +1,37 @@ "use client"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState, useCallback } from "react"; +import { Search, ArrowRight, AlertCircle } from "lucide-react"; import { RecentLaunches } from "./components/RecentLaunches"; +const CONTRACT_ID_REGEX = /^C[A-Z2-7]{55}$/; + export default function Home() { const t = useTranslations("home"); + const router = useRouter(); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + + const handleLookup = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const val = query.trim(); + if (!val) { + setError("Paste a contract ID to look it up."); + return; + } + if (!CONTRACT_ID_REGEX.test(val)) { + setError("That doesn't look like a valid Stellar contract ID (C + 55 base-32 chars)."); + return; + } + setError(null); + router.push(`/token/${val}`); + }, + [query, router], + ); + return (
{/* Background gradient orbs */} @@ -30,7 +57,45 @@ export default function Home() { {t("description")}

-
+ {/* Token lookup */} +
+
+
+ {error && ( +

+ + {error} +

+ )} +
+ +
{t("deployButton")} diff --git a/frontend/hooks/useContractEvents.ts b/frontend/hooks/useContractEvents.ts index 0c565dd3..f8c5ed04 100644 --- a/frontend/hooks/useContractEvents.ts +++ b/frontend/hooks/useContractEvents.ts @@ -3,6 +3,7 @@ import * as StellarSdk from "@stellar/stellar-sdk"; import { useNetwork } from "@/app/providers/NetworkProvider"; import { type TokenActivityInfo, + type TokenActivityType, toScVal, decodeString, decodeI128, @@ -14,6 +15,23 @@ import { readEventTimestamp, } from "@/lib/stellar"; +// All event topic names the hook subscribes to — mirrors TRACKED_EVENT_TOPICS in stellar.ts +const TRACKED_TOPICS = new Set([ + "transfer", + "mint", + "burn", + "clawback", + "freeze", + "unfreeze", + "pause", + "unpause", + "authorize", + "unauthorize", + "set_admin", + "revoke_admin", + "upgrade", +]); + interface UseContractEventsOptions { intervalMs?: number; } @@ -36,7 +54,7 @@ export function useContractEvents( const { networkConfig } = useNetwork(); const [events, setEvents] = useState([]); const [error, setError] = useState(null); - + const startLedgerRef = useRef(null); const intervalMs = options?.intervalMs ?? 10000; @@ -44,7 +62,6 @@ export function useContractEvents( if (!contractId || !networkConfig?.rpcUrl) return; const rpc = new StellarSdk.rpc.Server(networkConfig.rpcUrl); - // TypeScript workaround since getEvents is experimental/not fully typed in some SDK versions const getEvents = ( rpc as unknown as { getEvents?: (req: unknown) => Promise<{ events?: RpcEvent[] }>; @@ -60,13 +77,12 @@ export function useContractEvents( let timerId: ReturnType | null = null; let isPolling = false; - // Helper to safely fetch events and catch errors const safeGetEvents = async (startLedger: number) => { try { const response = await getEvents.call(rpc, { startLedger, filters: [{ type: "contract", contractIds: [contractId] }], - pagination: { limit: 100 }, // Fetch up to 100 latest events + pagination: { limit: 100 }, }); return response?.events ?? []; } catch (err) { @@ -80,14 +96,13 @@ export function useContractEvents( isPolling = true; try { - // If we don't have a starting ledger, initialize it to the latest ledger if (startLedgerRef.current === null) { const { sequence } = await rpc.getLatestLedger(); startLedgerRef.current = sequence; } const rawEvents = await safeGetEvents(startLedgerRef.current); - + if (!isMounted) return; const newRecords: TokenActivityInfo[] = []; @@ -95,9 +110,7 @@ export function useContractEvents( for (const evt of rawEvents) { const evtLedger = readEventLedger(evt) || startLedgerRef.current; - if (evtLedger > maxLedgerSeen) { - maxLedgerSeen = evtLedger; - } + if (evtLedger > maxLedgerSeen) maxLedgerSeen = evtLedger; const topics = readEventTopics(evt); if (topics.length === 0) continue; @@ -106,81 +119,106 @@ export function useContractEvents( if (!topic0) continue; const typePath = decodeString(topic0); - if ( - typePath !== "mint" && - typePath !== "burn" && - typePath !== "transfer" - ) { - continue; - } - const data = toScVal( - (evt as { value?: unknown; data?: unknown }).value ?? - (evt as { data?: unknown }).data, - ); - - if (!data) continue; - - const amount = decodeI128(data); - let from = "-"; - let to = "-"; - - if (typePath === "mint" && topics.length > 1) { - const toVal = toScVal(topics[1]); - if (toVal) to = decodeAddress(toVal); - } else if (typePath === "burn" && topics.length > 1) { - const fromVal = toScVal(topics[1]); - if (fromVal) from = decodeAddress(fromVal); - } else if (typePath === "transfer" && topics.length > 2) { - const fromVal = toScVal(topics[1]); - const toVal = toScVal(topics[2]); - if (fromVal) from = decodeAddress(fromVal); - if (toVal) to = decodeAddress(toVal); - } + // Keep all tracked topics; label anything else as "other" + const eventType: TokenActivityType = TRACKED_TOPICS.has(typePath) + ? (typePath as TokenActivityType) + : "other"; + + const rawValue = (evt as { value?: unknown; data?: unknown }).value ?? + (evt as { data?: unknown }).data; + const data = toScVal(rawValue as string | undefined); - newRecords.push({ + const record: TokenActivityInfo = { id: readEventId(evt, `${readEventTxHash(evt)}-${evtLedger}`), pagingToken: evt.pagingToken ?? "", - type: typePath as TokenActivityInfo["type"], - amount, - from, - to, - timestamp: readEventTimestamp(evt), + type: eventType, + amount: "-", + from: "-", + to: "-", txHash: readEventTxHash(evt), - }); + timestamp: readEventTimestamp(evt), + }; + + switch (typePath) { + case "mint": + if (data) record.amount = decodeI128(data); + if (topics.length > 1) { + const toVal = toScVal(topics[1]); + if (toVal) record.to = decodeAddress(toVal); + } + break; + + case "burn": + case "clawback": + if (data) record.amount = decodeI128(data); + if (topics.length > 1) { + const fromVal = toScVal(topics[1]); + if (fromVal) record.from = decodeAddress(fromVal); + } + break; + + case "transfer": + if (data) record.amount = decodeI128(data); + if (topics.length > 2) { + const fromVal = toScVal(topics[1]); + const toVal = toScVal(topics[2]); + if (fromVal) record.from = decodeAddress(fromVal); + if (toVal) record.to = decodeAddress(toVal); + } + break; + + case "freeze": + case "unfreeze": + case "authorize": + case "unauthorize": + case "set_admin": + case "revoke_admin": + if (topics.length > 1) { + const addrVal = toScVal(topics[1]); + if (addrVal) record.subject = decodeAddress(addrVal); + } + break; + + case "pause": + case "unpause": + case "upgrade": + // no extra payload needed + break; + + default: + // unknown topic — kept as "other", no extra decoding + break; + } + + newRecords.push(record); } - // Advance the ledger so we don't re-fetch old events - // +1 if we want to strictly ask for new ledgers next time if (maxLedgerSeen >= startLedgerRef.current) { startLedgerRef.current = maxLedgerSeen + 1; } if (newRecords.length > 0) { - // Sort descending by ledger/timestamp if needed, though they usually arrive in order. setEvents((prev: TokenActivityInfo[]) => { const addedIds = new Set(prev.map((p: TokenActivityInfo) => p.id)); - const uniqueNew = newRecords.filter((r: TokenActivityInfo) => !addedIds.has(r.id)); + const uniqueNew = newRecords.filter( + (r: TokenActivityInfo) => !addedIds.has(r.id), + ); if (uniqueNew.length === 0) return prev; - // Prepend new events (newest first in typical feeds, though we should check how ActivityFeed renders) - // The ActivityFeed displays newest first. So we prepend unique new records. - // Reverse uniqueNew if it came in oldest->newest, but usually we just prepend. return [...uniqueNew.reverse(), ...prev]; }); } - + setError(null); } catch (err) { - if (isMounted) setError(err instanceof Error ? err : new Error(String(err))); + if (isMounted) + setError(err instanceof Error ? err : new Error(String(err))); } finally { isPolling = false; } }; - // Initial poll poll(); - - // Setup interval timerId = setInterval(poll, intervalMs); return () => { diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index c89647d6..2573901d 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -781,19 +781,134 @@ export async function fetchVestingSchedule( * Fetch transaction history (events) for a token contract via the Mercury indexer. * Uses cursor-based pagination to walk past the Soroban RPC retention window. */ +/** All event topic names the indexer and live-poll hooks subscribe to. */ +const TRACKED_EVENT_TOPICS = [ + "transfer", + "mint", + "burn", + "clawback", + "freeze", + "unfreeze", + "pause", + "unpause", + "authorize", + "unauthorize", + "set_admin", + "revoke_admin", + "upgrade", +] as const; + +type TrackedTopic = (typeof TRACKED_EVENT_TOPICS)[number]; + +function isTrackedTopic(s: string): s is TrackedTopic { + return (TRACKED_EVENT_TOPICS as readonly string[]).includes(s); +} + +/** + * Decode a single raw event into a partial TokenActivityInfo. + * Returns null for topics we don't recognise. + */ +function decodeActivityEvent( + topicStrings: string[], + value: string | undefined, + meta: { id: string; txHash: string; ledger: number; timestamp: string }, +): TokenActivityInfo | null { + if (topicStrings.length === 0) return null; + const topic0 = toScVal(topicStrings[0]); + if (!topic0) return null; + const typePath = decodeString(topic0); + + const base: TokenActivityInfo = { + id: meta.id, + pagingToken: meta.id, + type: "other", + amount: "-", + from: "-", + to: "-", + txHash: meta.txHash, + timestamp: meta.timestamp, + }; + + if (isTrackedTopic(typePath)) { + base.type = typePath; + } else { + // Gracefully label unknowns instead of dropping them + base.type = "other"; + } + + const data = toScVal(value); + + switch (typePath) { + case "mint": { + if (data) base.amount = decodeI128(data); + if (topicStrings.length > 1) { + const toVal = toScVal(topicStrings[1]); + if (toVal) base.to = decodeAddress(toVal); + } + break; + } + case "burn": + case "clawback": { + if (data) base.amount = decodeI128(data); + if (topicStrings.length > 1) { + const fromVal = toScVal(topicStrings[1]); + if (fromVal) base.from = decodeAddress(fromVal); + } + break; + } + case "transfer": { + if (data) base.amount = decodeI128(data); + if (topicStrings.length > 2) { + const fromVal = toScVal(topicStrings[1]); + const toVal = toScVal(topicStrings[2]); + if (fromVal) base.from = decodeAddress(fromVal); + if (toVal) base.to = decodeAddress(toVal); + } + break; + } + case "freeze": + case "unfreeze": + case "authorize": + case "unauthorize": { + // topic[1] = account address being acted on + if (topicStrings.length > 1) { + const addrVal = toScVal(topicStrings[1]); + if (addrVal) base.subject = decodeAddress(addrVal); + } + break; + } + case "set_admin": + case "revoke_admin": { + // topic[1] = new/old admin address + if (topicStrings.length > 1) { + const addrVal = toScVal(topicStrings[1]); + if (addrVal) base.subject = decodeAddress(addrVal); + } + break; + } + case "pause": + case "unpause": + case "upgrade": + // No address payload; the event itself is the signal + break; + default: + break; + } + + return base; +} + export async function fetchTransactionHistory( contractId: string, config: NetworkConfig, options: { cursor?: string; limit?: number } = {}, ): Promise<{ items: TransactionItem[]; nextCursor: string | null }> { const { cursor, limit = 200 } = options; - const topicTransfer = encodeTopicSymbol("transfer"); - const topicMint = encodeTopicSymbol("mint"); - const topicBurn = encodeTopicSymbol("burn"); - const topicClawback = encodeTopicSymbol("clawback"); + + const topicFilters = TRACKED_EVENT_TOPICS.map(encodeTopicSymbol); const { events, nextCursor } = await fetchIndexedEvents(contractId, config, { - topics: [topicTransfer, topicMint, topicBurn, topicClawback], + topics: topicFilters, cursor, limit, }); @@ -805,6 +920,7 @@ export async function fetchTransactionHistory( if (!topic0) continue; const typePath = decodeString(topic0); + // fetchTransactionHistory returns only the classic token-transfer types if ( typePath !== "mint" && typePath !== "burn" && @@ -848,13 +964,31 @@ export async function fetchTransactionHistory( return { items: items.reverse(), nextCursor }; } +export type TokenActivityType = + | "mint" + | "transfer" + | "burn" + | "clawback" + | "freeze" + | "unfreeze" + | "pause" + | "unpause" + | "authorize" + | "unauthorize" + | "set_admin" + | "revoke_admin" + | "upgrade" + | "other"; + export interface TokenActivityInfo { id: string; pagingToken: string; - type: "mint" | "transfer" | "burn" | "clawback" | "other"; + type: TokenActivityType; amount: string; from: string; to: string; + /** address or subject involved in admin/compliance events */ + subject?: string; timestamp: string; txHash: string; } @@ -872,15 +1006,12 @@ export async function fetchAccountOperations( try { // For contract IDs, use indexer events instead of Horizon. if (accountId.startsWith("C")) { - const topicTransfer = encodeTopicSymbol("transfer"); - const topicMint = encodeTopicSymbol("mint"); - const topicBurn = encodeTopicSymbol("burn"); - const topicClawback = encodeTopicSymbol("clawback"); + const topicFilters = TRACKED_EVENT_TOPICS.map(encodeTopicSymbol); const pageSize = Math.min(limit, 200); const { events, nextCursor: nextIndexerCursor } = await fetchIndexedEvents(accountId, config, { - topics: [topicTransfer, topicMint, topicBurn, topicClawback], + topics: topicFilters, limit: pageSize, cursor: cursor ?? undefined, }); @@ -888,56 +1019,20 @@ export async function fetchAccountOperations( const records: TokenActivityInfo[] = []; for (const event of events) { - const topic0 = toScVal(event.topic[0]); - if (!topic0) continue; - - const typePath = decodeString(topic0); - if ( - typePath !== "mint" && - typePath !== "burn" && - typePath !== "clawback" && - typePath !== "transfer" - ) { - continue; - } - - const data = toScVal(event.value); - if (!data) continue; - - const amount = decodeI128(data); - let from = "-"; - let to = "-"; - - if (typePath === "mint" && event.topic.length > 1) { - const toVal = toScVal(event.topic[1]); - if (toVal) to = decodeAddress(toVal); - } else if ( - (typePath === "burn" || typePath === "clawback") && - event.topic.length > 1 - ) { - const fromVal = toScVal(event.topic[1]); - if (fromVal) from = decodeAddress(fromVal); - } else if (typePath === "transfer" && event.topic.length > 2) { - const fromVal = toScVal(event.topic[1]); - const toVal = toScVal(event.topic[2]); - if (fromVal) from = decodeAddress(fromVal); - if (toVal) to = decodeAddress(toVal); - } - - records.push({ - id: event.id || `${event.tx_hash}-${event.ledger}`, - pagingToken: event.id || "", - type: typePath as TokenActivityInfo["type"], - amount, - from, - to, - timestamp: event.timestamp, - txHash: event.tx_hash, - }); + const decoded = decodeActivityEvent( + event.topic, + event.value, + { + id: event.id || `${event.tx_hash}-${event.ledger}`, + txHash: event.tx_hash, + ledger: event.ledger, + timestamp: event.timestamp, + }, + ); + if (decoded) records.push(decoded); } - const nextCursor = nextIndexerCursor; - return { records, nextCursor }; + return { records, nextCursor: nextIndexerCursor }; } const horizon = new StellarSdk.Horizon.Server(getHorizonUrl());