diff --git a/frontend/app/dashboard/[contractId]/ActivityFeed.tsx b/frontend/app/dashboard/[contractId]/ActivityFeed.tsx
index 5d59c33..26eebc4 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 423c512..3821532 100644
--- a/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx
+++ b/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx
@@ -40,6 +40,7 @@ import {
Clock,
Lock,
AlertTriangle,
+ Upload,
Percent,
CircleAlert,
} from "lucide-react";
@@ -142,12 +143,20 @@ 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 ManageVestingData = z.infer;
type MetadataUriData = z.infer;
+type UpgradeData = z.infer;
const whaleCapSchema = z.object({
cap: z
@@ -191,9 +200,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();
@@ -216,6 +226,9 @@ export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: Adm
// "burn" → permanently destroy tokens, reducing supply (irreversible)
const [burnMode, setBurnMode] = useState<"clawback" | "burn">("clawback");
+ // Upgrade state
+ const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false);
+
const [mintMode, setMintMode] = useState<"single" | "batch">("single");
const [batchData, setBatchData] = useState("");
const [batchErrors, setBatchErrors] = useState([]);
@@ -228,6 +241,7 @@ export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: Adm
const vestingForm = useForm({ resolver: zodResolver(vestingSchema) });
const manageVestingForm = useForm({ resolver: zodResolver(manageVestingSchema) });
const metadataUriForm = useForm({ resolver: zodResolver(metadataUriSchema) });
+ const upgradeForm = useForm({ resolver: zodResolver(upgradeSchema) });
const whaleForm = useForm({ resolver: zodResolver(whaleCapSchema) });
const complianceForm = useForm({ resolver: zodResolver(complianceNodeSchema) });
@@ -433,4 +447,1906 @@ export function AdminPanel({ contractId, maxSupply, totalSupply, decimals }: Adm
}, [refreshComplianceNode]);
const submitSignedTransaction = useCallback(
- async (signedXdr:
\ No newline at end of file
+ async (signedXdr: string) => {
+ const server = new rpc.Server(networkConfig.rpcUrl);
+ const signedTx = TransactionBuilder.fromXDR(
+ signedXdr,
+ networkConfig.passphrase,
+ );
+ const send = await server.sendTransaction(
+ signedTx as Parameters[0],
+ );
+ if (send.status === "ERROR") {
+ throw new Error(
+ `Submit failed: ${send.errorResult?.toXDR("base64") ?? "unknown"}`,
+ );
+ }
+
+ let response = await server.getTransaction(send.hash);
+ let attempts = 0;
+ while (response.status === "NOT_FOUND" && attempts < 30) {
+ await new Promise((r) => setTimeout(r, 1000));
+ response = await server.getTransaction(send.hash);
+ attempts += 1;
+ }
+
+ if (response.status === "FAILED") {
+ throw new Error("Transaction failed on-chain");
+ }
+
+ return send.hash;
+ },
+ [networkConfig.passphrase, networkConfig.rpcUrl],
+ );
+
+ const handleBatchMint = async (entries: BatchMintEntry[]) => {
+ if (!publicKey) return;
+
+ if (entries.length > 50) {
+ toast.show({
+ title: "Batch too large",
+ message: `Maximum batch size is 50 recipients. You have ${entries.length}.`,
+ variant: "error",
+ });
+ return;
+ }
+
+ setLoading("batch-mint");
+ setSuccess(null);
+ setLastTxHash(null);
+ const statusLabel = "Batch mint";
+
+ try {
+ const server = new rpc.Server(networkConfig.rpcUrl);
+ const account = await server.getAccount(publicKey);
+ const contract = new Contract(contractId);
+
+ // Prepare ScVals for the new mint_batch function
+ const addressesScVal = nativeToScVal(entries.map(e => new Address(e.address)), { type: "vec" });
+ const amountsScVal = nativeToScVal(entries.map(e => BigInt(Math.round(parseFloat(e.amount) * 10 ** decimals))), { type: "vec" });
+
+ const tx = new TransactionBuilder(account, {
+ fee: "1000",
+ networkPassphrase: networkConfig.passphrase
+ })
+ .addOperation(contract.call("mint_batch", addressesScVal, amountsScVal))
+ .setTimeout(30)
+ .build();
+
+ const xdrEncoded = tx.toXDR();
+
+ let signedXdr: string;
+ try {
+ signedXdr = await signTransaction(xdrEncoded, { networkPassphrase: networkConfig.passphrase });
+ } catch (signError) {
+ setAnnouncement(`${statusLabel} signing failed.`);
+ throw signError;
+ }
+
+ const txHash = await submitSignedTransaction(signedXdr);
+ setLastTxHash(txHash);
+ setSuccess("batch-mint");
+ setAnnouncement(`${statusLabel} transaction submitted successfully. Transaction hash ${txHash}.`);
+
+ } catch (err) {
+ const error = err as Error;
+ console.error(`batch-mint failed:`, error);
+ setAnnouncement(`${statusLabel} transaction failed.`);
+ toast.show({
+ title: `${statusLabel} failed`,
+ message: error.message,
+ variant: "error",
+ });
+ } finally {
+ setLoading(null);
+ }
+ };
+
+ const simulator = useTransactionSimulator();
+ const [mintPreflight, setMintPreflight] = useState(null);
+ const [burnPreflight, setBurnPreflight] = useState(null);
+ const [transferPreflight, setTransferPreflight] = useState(null);
+ const [vestingPreflight, setVestingPreflight] = useState(null);
+ const [manageVestingPreflight, setManageVestingPreflight] = useState(null);
+
+ const handleAction = async (action: string, data: AdminActionData) => {
+ if (!publicKey) return;
+
+ setLoading(action);
+ setSuccess(null);
+ setLastTxHash(null);
+
+ const statusLabel =
+ action === "mint"
+ ? "Mint"
+ : action === "clawback"
+ ? "Clawback"
+ : action === "burn-admin"
+ ? "Burn (admin)"
+ : action === "transfer"
+ ? "Propose admin"
+ : action === "cancel-admin"
+ ? "Cancel admin transfer"
+ : action === "accept-admin"
+ ? "Accept admin"
+ : action === "metadata-uri"
+ ? "Update metadata URI"
+ : action === "extend-cliff"
+ ? "Extend cliff"
+ : action === "vesting-revoke"
+ ? "Revoke schedule"
+ : action === "set-whale-cap"
+ ? "Set whale protection cap"
+ : action === "disable-whale-cap"
+ ? "Disable whale protection"
+ : action === "set-compliance-node"
+ ? "Set compliance node"
+ : action === "clear-compliance-node"
+ ? "Clear compliance node"
+ : "Vesting";
+
+ try {
+ const server = new rpc.Server(networkConfig.rpcUrl);
+
+ let method = "";
+ let args: xdr.ScVal[] = [];
+ let targetContractId = contractId;
+ let simulationResult: PreflightCheckResult | null = null;
+
+ if (action === "mint") {
+ const mintData = data as MintData;
+ method = "mint";
+ const scaledAmount =
+ BigInt(Math.round(parseFloat(mintData.amount) * 10 ** decimals));
+ args = [addressToScVal(mintData.to), i128ToScVal(scaledAmount)];
+
+ simulationResult = await simulator.checkMint(
+ contractId,
+ mintData.to,
+ scaledAmount,
+ publicKey,
+ );
+ setMintPreflight(simulationResult);
+ } else if (action === "clawback") {
+ const burnData = data as BurnData;
+ method = "clawback";
+ const scaledAmount =
+ BigInt(Math.round(parseFloat(burnData.amount) * 10 ** decimals));
+ args = [addressToScVal(burnData.from), i128ToScVal(scaledAmount)];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ setBurnPreflight(simulationResult);
+ } else if (action === "burn-admin") {
+ const burnData = data as BurnData;
+ method = "burn_admin";
+ const scaledAmount =
+ BigInt(Math.round(parseFloat(burnData.amount) * 10 ** decimals));
+ args = [addressToScVal(burnData.from), i128ToScVal(scaledAmount)];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ setBurnPreflight(simulationResult);
+ } else if (action === "transfer") {
+ const transferData = data as TransferAdminData;
+ method = "propose_admin";
+ args = [addressToScVal(transferData.newAdmin)];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ setTransferPreflight(simulationResult);
+ } else if (action === "cancel-admin") {
+ // No dedicated cancel exists on-chain, so overwrite the pending
+ // proposal with the current admin's own address. This neutralizes
+ // the transfer — the previously proposed admin can no longer accept.
+ method = "propose_admin";
+ args = [addressToScVal(publicKey)];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ } else if (action === "accept-admin") {
+ method = "accept_admin";
+ args = [];
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ } else if (action === "vesting") {
+ const vestingData = data as VestingData;
+ method = "create_schedule";
+ targetContractId = vestingData.vestingContract;
+
+ const currentLedgerRes = await server.getLatestLedger();
+ const currentLedger = currentLedgerRes.sequence;
+
+ const cliffLedgers = Math.round(Number(vestingData.cliffDays) * 17280);
+ const durationLedgers = Math.round(Number(vestingData.durationDays) * 17280);
+
+ const cliffLedger = currentLedger + cliffLedgers;
+ const endLedger = cliffLedger + durationLedgers;
+
+ const scaledAmount =
+ BigInt(Math.round(parseFloat(vestingData.amount) * 10 ** decimals));
+
+ args = [
+ addressToScVal(vestingData.recipient),
+ i128ToScVal(scaledAmount),
+ nativeToScVal(cliffLedger, { type: "u32" }),
+ nativeToScVal(endLedger, { type: "u32" }),
+ ];
+
+ simulationResult = await simulator.checkCreateSchedule(
+ vestingData.vestingContract,
+ vestingData.recipient,
+ scaledAmount,
+ cliffLedger,
+ endLedger,
+ publicKey,
+ );
+ setVestingPreflight(simulationResult);
+ } else if (action === "metadata-uri") {
+ const metadataData = data as MetadataUriData;
+ method = "update_contract_uri";
+ args = [nativeToScVal(metadataData.uri, { type: "string" })];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ } else if (action === "extend-cliff") {
+ const manageData = data as ManageVestingData;
+ method = "extend_cliff";
+ targetContractId = manageData.vestingContract;
+
+ // Same ledger math as create_schedule: the new cliff is an
+ // absolute ledger computed as "now + N days".
+ const currentLedgerRes = await server.getLatestLedger();
+ const currentLedger = currentLedgerRes.sequence;
+ const newCliffLedger =
+ currentLedger + Math.round(Number(manageData.newCliffDays) * 17280);
+
+ args = [
+ addressToScVal(manageData.recipient),
+ nativeToScVal(newCliffLedger, { type: "u32" }),
+ indexToScVal(manageData.scheduleIndex),
+ ];
+
+ simulationResult = await simulator.simulateContract(
+ targetContractId,
+ method,
+ args,
+ publicKey,
+ );
+ setManageVestingPreflight(simulationResult);
+ } else if (action === "vesting-revoke") {
+ const manageData = data as ManageVestingData;
+ method = "revoke";
+ targetContractId = manageData.vestingContract;
+
+ args = [
+ addressToScVal(manageData.recipient),
+ indexToScVal(manageData.scheduleIndex),
+ ];
+
+ simulationResult = await simulator.simulateContract(
+ targetContractId,
+ method,
+ args,
+ publicKey,
+ );
+ setManageVestingPreflight(simulationResult);
+ } else if (action === "set-whale-cap") {
+ const whaleData = data as WhaleCapData;
+ method = "set_max_balance_per_account";
+ args = [nativeToScVal(Number(whaleData.cap), { type: "u32" })];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ } else if (action === "disable-whale-cap") {
+ method = "set_max_balance_per_account";
+ args = [xdr.ScVal.scvVoid()];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ } else if (action === "set-compliance-node") {
+ const nodeData = data as ComplianceNodeData;
+ method = "set_compliance_node";
+ args = [addressToScVal(nodeData.address)];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ } else if (action === "clear-compliance-node") {
+ method = "set_compliance_node";
+ args = [xdr.ScVal.scvVoid()];
+
+ simulationResult = await simulator.simulateContract(
+ contractId,
+ method,
+ args,
+ publicKey,
+ );
+ } else {
+ throw new Error("Unsupported action");
+ }
+
+ if (simulationResult?.errors?.length) {
+ toast.show({
+ title: `${statusLabel} simulation failed`,
+ message: simulationResult.errors[0],
+ variant: "error",
+ });
+ return;
+ }
+
+ const account = await server.getAccount(publicKey);
+ const contract = new Contract(targetContractId);
+
+ const tx = new TransactionBuilder(account, {
+ fee: "1000",
+ networkPassphrase: networkConfig.passphrase,
+ })
+ .addOperation(contract.call(method, ...args))
+ .setTimeout(30)
+ .build();
+
+ const sim = await server.simulateTransaction(tx);
+ if (rpc.Api.isSimulationError(sim)) {
+ throw new Error(`Simulation failed: ${sim.error}`);
+ }
+ const prepared = rpc.assembleTransaction(tx, sim).build();
+
+ const signedXdr = await signTransaction(prepared.toXDR(), {
+ networkPassphrase: networkConfig.passphrase,
+ });
+
+ const txHash = await submitSignedTransaction(signedXdr);
+ setLastTxHash(txHash);
+ setSuccess(action);
+ setAnnouncement(
+ `${statusLabel} transaction submitted successfully. Transaction hash ${txHash}.`,
+ );
+
+ if (action === "mint") {
+ mintForm.reset();
+ setMintPreflight(null);
+ }
+ if (action === "clawback" || action === "burn-admin") {
+ burnForm.reset();
+ setBurnPreflight(null);
+ }
+ if (action === "transfer") {
+ transferForm.reset();
+ setShowTransferConfirm(false);
+ setTransferPreflight(null);
+ }
+ // Any change to the two-step transfer state needs a re-read so the
+ // banner / Accept gating reflects on-chain reality.
+ if (
+ action === "transfer" ||
+ action === "cancel-admin" ||
+ action === "accept-admin"
+ ) {
+ refreshPendingAdmin();
+ }
+ if (action === "metadata-uri") {
+ metadataUriForm.reset();
+ }
+ if (action === "vesting") {
+ vestingForm.reset();
+ setVestingPreflight(null);
+ }
+ if (action === "extend-cliff") {
+ // Keep the contract / recipient / index so further edits are easy;
+ // just clear the one-off cliff input and the preflight banner.
+ manageVestingForm.resetField("newCliffDays");
+ setManageVestingPreflight(null);
+ }
+ if (action === "vesting-revoke") {
+ manageVestingForm.reset();
+ setManageVestingPreflight(null);
+ setShowVestingRevokeConfirm(false);
+ }
+ if (action === "set-whale-cap" || action === "disable-whale-cap") {
+ whaleForm.reset();
+ await refreshWhaleCap();
+ }
+ if (action === "set-compliance-node" || action === "clear-compliance-node") {
+ complianceForm.reset();
+ await refreshComplianceNode();
+ }
+ } catch (err) {
+ const error = err as Error;
+ console.error(`${action} failed:`, error);
+ setAnnouncement(`${statusLabel} transaction failed.`);
+ toast.show({
+ title: `${statusLabel} failed`,
+ message: error.message,
+ variant: "error",
+ });
+ } finally {
+ setLoading(null);
+ }
+ };
+
+ /* ── Revoke admin / lock token ─────────────────────────────────── */
+ const handleRevokeAdmin = async () => {
+ if (!publicKey) return;
+ if (revokePhrase.trim() !== REVOKE_CONFIRM_PHRASE) return;
+
+ setLoading("revoke");
+ try {
+ const txHash = await wrapRpcCall(
+ async () => {
+ const server = new rpc.Server(networkConfig.rpcUrl);
+ const account = await server.getAccount(publicKey);
+ const contract = new Contract(contractId);
+
+ const built = new TransactionBuilder(account, {
+ fee: "1000",
+ networkPassphrase: networkConfig.passphrase,
+ })
+ .addOperation(contract.call("revoke_admin"))
+ .setTimeout(60)
+ .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,
+ });
+ const signedTx = TransactionBuilder.fromXDR(
+ signedXdr,
+ networkConfig.passphrase,
+ );
+ const send = await server.sendTransaction(
+ signedTx as Parameters[0],
+ );
+ if (send.status === "ERROR") {
+ throw new Error(
+ `Submit failed: ${send.errorResult?.toXDR("base64") ?? "unknown"}`,
+ );
+ }
+
+ let response = await server.getTransaction(send.hash);
+ let attempts = 0;
+ while (response.status === "NOT_FOUND" && attempts < 30) {
+ await new Promise((r) => setTimeout(r, 1000));
+ response = await server.getTransaction(send.hash);
+ attempts += 1;
+ }
+ if (response.status === "FAILED") {
+ throw new Error("Revoke transaction failed on-chain");
+ }
+ return send.hash;
+ },
+ { operation: "Revoke admin", toastTitle: "Revoke failed" },
+ );
+
+ setLastTxHash(txHash);
+ setSuccess("revoke");
+ setLocked(true);
+ setShowRevokeConfirm(false);
+ setRevokePhrase("");
+ toast.show({
+ title: "Admin revoked",
+ message:
+ "The token contract is now locked. Admin operations can no longer be performed.",
+ variant: "success",
+ duration: 8_000,
+ });
+ } catch {
+ // Toast already surfaced via wrapRpcCall.
+ } finally {
+ setLoading(null);
+ }
+ };
+
+ const handlePauseToggle = async () => {
+ if (!publicKey) return;
+
+ const action = paused ? "unpause" : "pause";
+ setLoading(action);
+ 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);
+
+ const built = new TransactionBuilder(account, {
+ fee: "1000",
+ networkPassphrase: networkConfig.passphrase,
+ })
+ .addOperation(contract.call(action))
+ .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: paused ? "Unpause" : "Pause", toastTitle: paused ? "Unpause failed" : "Pause failed" },
+ );
+
+ setLastTxHash(txHash);
+ setSuccess(action);
+ setPaused(!paused);
+ setShowPauseConfirm(false);
+ toast.show({
+ title: paused ? "Token unpaused" : "Token paused",
+ message: paused
+ ? "All token operations have been resumed."
+ : "All token operations are now halted. Call unpause to resume.",
+ variant: "success",
+ duration: 8_000,
+ });
+ } catch {
+ // Toast already surfaced via wrapRpcCall
+ } finally {
+ setLoading(null);
+ }
+ };
+
+ 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;
+
+ // Two-step transfer helpers: the connected wallet can only accept when it is
+ // the named pending admin; the outgoing admin sees a cancel/overwrite path.
+ const isConnectedPendingAdmin =
+ !!pendingAdmin && !!publicKey && pendingAdmin === publicKey;
+
+ return (
+
+
+ {announcement}
+
+
+
+
+
+ Admin Console
+
+
+ {lastTxHash && (
+
+ href={`https://stellar.expert/explorer/${networkConfig.network}/tx/${lastTxHash}`}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex items-center gap-1.5 text-xs text-stellar-400 hover:text-stellar-300 transition-colors bg-stellar-400/10 px-3 py-1.5 rounded-full border border-stellar-400/20"
+ >
+ Last Tx: {lastTxHash.slice(0, 8)}...{" "}
+
+
+ )}
+
+
+ {paused && (
+
+
+
+
+ Contract is paused
+
+
+ All state-changing operations (mint, burn, transfer, clawback)
+ are halted. Only the admin can unpause the contract.
+
+
+
+ )}
+
+ {locked && (
+
+
+
+
+ Admin permanently revoked
+
+
+ This token contract is now immutable. Mint, burn, freeze, and
+ admin-transfer operations are permanently disabled. Holders can
+ still transfer and self-burn their tokens.
+
+
+
+ )}
+
+ {/* ── Pending admin transfer banner ──────────────── */}
+ {!locked && pendingAdmin && (
+
+
+
+
+ Admin transfer pending
+
+
+ A two-step admin transfer is in progress. Pending admin →{" "}
+
+ {pendingAdmin.slice(0, 6)}…{pendingAdmin.slice(-6)}
+
+ .{" "}
+ {isConnectedPendingAdmin
+ ? "Your connected wallet is the pending admin — accept the role below to finalize."
+ : "It is not finalized until the pending admin accepts. As the current admin you can cancel or overwrite it below."}
+
+
+
+ )}
+
+
+ {/* ── Mint Form ─────────────────────────────────────── */}
+ {(!maxSupply ||
+ maxSupply === "N/A" ||
+ (totalSupply &&
+ totalSupply !== "N/A" &&
+ parseFloat(totalSupply.replace(/,/g, ""))
+ parseFloat(maxSupply.replace(/,/g, "")))) && (
+
+
+
+
+ setMintMode("single")}
+ className={`px-3 py-1 text-xs rounded-md transition-all ${mintMode === "single" ? "bg-stellar-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
+ >
+ Single
+
+ setMintMode("batch")}
+ className={`px-3 py-1 text-xs rounded-md transition-all ${mintMode === "batch" ? "bg-stellar-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
+ >
+ Batch
+
+
+
+
+ {mintMode === "single" ? (
+
+ ) : (
+
+
+
+ Manual Entry (Address, Amount)
+
+
+
+
+
+
+
+ Or Upload CSV
+
+
+
+
+
{
+ const file = e.target.files?.[0];
+ if (file) {
+ const { entries, errors } =
+ await parseBatchMintFile(file);
+ setParsedEntries(entries);
+ setBatchErrors(errors);
+ }
+ }}
+ />
+
+ {batchErrors.length > 0 && (
+
+
+ Errors Found:
+
+
+ {batchErrors.slice(0, 3).map((err, i) => (
+ {err}
+ ))}
+ {batchErrors.length > 3 && (
+ ...and {batchErrors.length - 3} more
+ )}
+
+
+ )}
+
+ {parsedEntries.length > 0 && batchErrors.length === 0 && (
+
+
+ {parsedEntries.length} valid entries ready
+
+
+ Total:{" "}
+ {parsedEntries
+ .reduce((acc, curr) => acc + Number(curr.amount), 0)
+ .toLocaleString()}
+
+
+ )}
+
+
0
+ }
+ onClick={() => handleBatchMint(parsedEntries)}
+ >
+ {success === "batch-mint" ? (
+
+ Batch Minted!
+
+ ) : (
+ `Mint Batch (${parsedEntries.length})`
+ )}
+
+
+ )}
+
+ )}
+
+ {/* ── Clawback / Burn Form ───────────────────────────── */}
+
+
+
+
+ {
+ setBurnMode("clawback");
+ setBurnPreflight(null);
+ }}
+ className={`px-3 py-1 text-xs rounded-md transition-all ${burnMode === "clawback" ? "bg-red-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
+ >
+ Confiscate
+
+ {
+ setBurnMode("burn");
+ setBurnPreflight(null);
+ }}
+ className={`px-3 py-1 text-xs rounded-md transition-all ${burnMode === "burn" ? "bg-red-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
+ >
+ Destroy
+
+
+
+
+ {/* Differentiate the two admin-initiated removals for the operator. */}
+
+ {burnMode === "clawback" ? (
+ <>
+
+ Confiscate to admin (clawback):
+ {" "}
+ forcibly moves tokens into the admin balance. Reversible — you
+ can transfer them back later.
+ >
+ ) : (
+ <>
+
+ Permanently destroy (burn admin):
+ {" "}
+ burns tokens out of existence, reducing total supply. This
+ cannot be undone.
+ >
+ )}
+
+
+
+
+
+ {/* ── Vesting Schedule ────────────────────────────────── */}
+
+
+ {/* ── Manage Vesting ─────────────────────────── */}
+
+
+
+
+
+
+
Manage Vesting
+
+ Extend the cliff of, or revoke, an existing schedule.
+
+
+
+
+
+
+
+
+
+ {manageVestingPreflight && (
+
+ )}
+
+ {/* ── Extend cliff ── */}
+
+
+ Extend Cliff
+
+
+ Push the cliff back by the number of days from now. Only works
+ while the current cliff is still in the future.
+
+
+
{
+ // newCliffDays is optional in the schema (Revoke ignores it),
+ // so enforce it here for the Extend path.
+ if (!data.newCliffDays) {
+ manageVestingForm.setError("newCliffDays", {
+ message: "Cliff extension must be positive",
+ });
+ return;
+ }
+ handleAction("extend-cliff", data);
+ })}
+ >
+ {success === "extend-cliff" ? (
+
+ Cliff Extended
+
+ ) : (
+ "Extend Cliff"
+ )}
+
+
+
+ {/* ── Revoke ── */}
+
+
+ Revoke Schedule
+
+ {!showVestingRevokeConfirm ? (
+ <>
+
+ Cancels the schedule. Vested tokens are released to the
+ recipient and all unvested tokens return to the admin.
+
+
+ setShowVestingRevokeConfirm(true),
+ )}
+ >
+ Revoke Schedule
+
+ >
+ ) : (
+
+
+ Confirm Revocation
+
+
+ Unvested tokens will be returned to the admin and the
+ schedule will be permanently revoked. This cannot be undone.
+
+
+ setShowVestingRevokeConfirm(false)}
+ disabled={loading === "vesting-revoke"}
+ >
+ Cancel
+
+
+ handleAction(
+ "vesting-revoke",
+ manageVestingForm.getValues(),
+ )
+ }
+ >
+ {success === "vesting-revoke" ? (
+
+ Revoked
+
+ ) : (
+ "Confirm Revoke"
+ )}
+
+
+
+ )}
+
+
+
+
+ {/* ── Transfer Admin ────────────────────────────────── */}
+
+
+
+
+ {!pendingAdmin ? (
+ // No two-step transfer in progress — nothing to accept or cancel.
+
+ No admin transfer is currently pending.
+
+ ) : (
+
+
+ Pending admin →{" "}
+
+ {pendingAdmin.slice(0, 6)}…{pendingAdmin.slice(-6)}
+
+
+ {isConnectedPendingAdmin ? (
+ // Only the named pending admin can finalize the transfer.
+
+ handleAction("accept-admin", {} as AcceptAdminData)
+ }
+ isLoading={loading === "accept-admin"}
+ disabled={locked || (!!loading && loading !== "accept-admin")}
+ >
+ Accept Admin Role
+
+ ) : (
+ // Outgoing admin can cancel by overwriting the proposal with self.
+
+ handleAction("cancel-admin", {} as AcceptAdminData)
+ }
+ isLoading={loading === "cancel-admin"}
+ disabled={adminDisabled && loading !== "cancel-admin"}
+ >
+ Cancel Pending Transfer
+
+ )}
+
+ )}
+
+
+
+ {/* ── Revoke Admin / Lock Token ────────────────────────── */}
+
+
+
+
+
+
+
Revoke Admin / Lock Token
+
+ Permanently make this token immutable. Removes admin and
+ disables minting, burning, freezing, and admin transfer forever.
+
+
+
+
+ {locked ? (
+
+
+ Admin already revoked — token is locked.
+
+ ) : !showRevokeConfirm ? (
+
setShowRevokeConfirm(true)}
+ >
+ Begin revocation
+
+ ) : (
+
+
+ Irreversible Action
+
+
+ Once revoked, no one — including you — can ever mint, burn,
+ freeze, or transfer admin again. Type{" "}
+
+ {REVOKE_CONFIRM_PHRASE}
+ {" "}
+ to confirm.
+
+
setRevokePhrase(e.target.value)}
+ aria-label="Revoke confirmation phrase"
+ disabled={loading === "revoke"}
+ className="bg-white/5 border-white/10"
+ />
+
+ {
+ setShowRevokeConfirm(false);
+ setRevokePhrase("");
+ }}
+ disabled={loading === "revoke"}
+ >
+ Cancel
+
+
+ {success === "revoke" ? (
+
+ Revoked
+
+ ) : (
+ "Revoke permanently"
+ )}
+
+
+
+ )}
+
+
+ {/* ── Transfer Policy ────────────────────────────────── */}
+
+
+
+
+ {/* Whale Protection */}
+
+
+ {/* Compliance Node */}
+
+
+
+
+ {/* ── Update Metadata URI ──────────────────────────────── */}
+
+
+
+
+
+
+
Metadata URI
+
+ Set or update the URI pointing to off-chain token metadata (logo, description, etc.)
+
+
+
+
+
+
+
+ {/* ── Pause / Unpause ───────────────────────────────── */}
+
+
+
+
+
Circuit Breaker
+
+ Pause or unpause all token operations in an emergency.
+
+
+
+
+ {paused ? (
+
+
+
+ Token is paused — mint, burn, transfer, and clawback are halted.
+
+
+ {success === "unpause" ? (
+
+ Unpaused
+
+ ) : (
+ "Unpause Token"
+ )}
+
+
+ ) : (
+
+
+
+ Token is active — all operations are running normally.
+
+
+ {!showPauseConfirm ? (
+
setShowPauseConfirm(true)}
+ >
+ Pause Token
+
+ ) : (
+
+
+ Pause token?
+
+
+ This will halt all mint, burn, transfer, and clawback operations
+ until unpaused. Only token holders can still self-burn.
+
+
+ setShowPauseConfirm(false)}
+ disabled={loading === "pause"}
+ >
+ Cancel
+
+
+ {success === "pause" ? (
+
+ Paused
+
+ ) : (
+ "Confirm Pause"
+ )}
+
+
+
+ )}
+
+ )}
+
+
+
+ {/* ── 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.
+
+
+
+ >
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 6b495b6..50ad739 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 */}
+
+
+
{t("deployButton")}
diff --git a/frontend/hooks/useContractEvents.ts b/frontend/hooks/useContractEvents.ts
index 0c565dd..f8c5ed0 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 62668ae..208a710 100644
--- a/frontend/lib/stellar.ts
+++ b/frontend/lib/stellar.ts
@@ -802,19 +802,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,
});
@@ -826,6 +941,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" &&
@@ -869,13 +985,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;
}
@@ -893,15 +1027,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,
});
@@ -909,56 +1040,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());