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, "")))) && ( +
+
+
+
+ +
+

Mint Assets

+
+
+ + +
+
+ + {mintMode === "single" ? ( +
+ handleAction("mint", data), + )} + className="space-y-4 flex-grow" + > + + + {mintPreflight && ( + + )} + + + ) : ( +
+
+ +