import { AlgorandClient, microAlgos } from '@algorandfoundation/algokit-utils' import { useWallet } from '@txnlab/use-wallet-react' import { sha512_256 } from 'js-sha512' import { useSnackbar } from 'notistack' import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AiOutlineCloudUpload, AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai' import { BsCoin } from 'react-icons/bs' import { getAlgodConfigFromViteEnvironment } from '../utils/network/getAlgoClientConfigs' /** * Type for created assets stored in browser localStorage * Captures ASA configuration including compliance fields */ type CreatedAsset = { assetId: bigint assetName: string unitName: string total: string decimals: string url?: string manager?: string reserve?: string freeze?: string clawback?: string createdAt: string } /** * Tri-state for USDC opt-in status * - 'loading': blockchain query in progress, UI should show spinner/loading * - 'opted-in': confirmed on-chain that user has opted in * - 'not-opted-in': confirmed on-chain that user has NOT opted in */ type UsdcStatus = 'loading' | 'opted-in' | 'not-opted-in' const STORAGE_KEY = 'tokenize_assets' const LORA_BASE = 'https://lora.algokit.io/testnet' // Circle USDC on Algorand TestNet (ASA) const TESTNET_USDC_ASSET_ID = 10458941 const USDC_DECIMALS = 6 const ALGO_DECIMALS = 6 type TransferMode = 'manual' | 'algo' | 'usdc' function resolveBackendBase(): string { // 1) Respect explicit env (Vercel or custom) const env = import.meta.env.VITE_API_URL?.trim() if (env) { const cleaned = env.replace(/\/$/, '') // If someone pastes "my-backend.vercel.app" (no protocol), // the browser will treat it as a relative path. Force https. return cleaned.startsWith('http://') || cleaned.startsWith('https://') ? cleaned : `https://${cleaned}` } // 2) Codespaces: convert current host to port 3001 // e.g. https://abc-5173.app.github.dev -> https://abc-3001.app.github.dev const host = window.location.host if (host.endsWith('.app.github.dev')) { const base = host.replace(/-\d+\.app\.github\.dev$/, '-3001.app.github.dev') return `https://${base}` } // 3) Plain local fallback return 'http://localhost:3001' } /** * Load created assets from browser localStorage */ function loadAssets(): CreatedAsset[] { try { const raw = localStorage.getItem(STORAGE_KEY) return raw ? (JSON.parse(raw) as CreatedAsset[]) : [] } catch { return [] } } /** * Save a newly created asset to localStorage * Returns updated asset list with new asset at the top */ function persistAsset(asset: CreatedAsset): CreatedAsset[] { const existing = loadAssets() const next = [asset, ...existing] localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) return next } /** * Convert a decimal string (e.g. "1.23") into base units bigint given decimals. * - Supports up to `decimals` fractional digits. * - Rejects negatives and invalid formats. */ function decimalToBaseUnits(value: string, decimals: number): bigint { const v = value.trim() if (!v) throw new Error('Amount is required') // Allow: "1", "1.", "1.0", ".5" ? We'll keep it simple: must start with digit. // (Users can type 0.5) if (!/^\d+(\.\d+)?$/.test(v)) throw new Error('Invalid amount format') const [wholeRaw, fracRaw = ''] = v.split('.') const whole = wholeRaw || '0' const frac = fracRaw || '' if (frac.length > decimals) { throw new Error(`Too many decimal places (max ${decimals})`) } const fracPadded = frac.padEnd(decimals, '0') const combined = `${whole}${fracPadded}`.replace(/^0+(?=\d)/, '') // keep at least one digit return BigInt(combined || '0') } /** * TokenizeAsset Component * Main form for creating Algorand Standard Assets (ASAs) * + NFT minting panel (ASA mint with IPFS metadata) * Persists created assets to localStorage for tracking */ export default function TokenizeAsset() { // ===== ASA (original) state ===== const [assetName, setAssetName] = useState('Tokenized Coffee Membership') const [unitName, setUnitName] = useState('COFFEE') const [total, setTotal] = useState('1000') const [decimals, setDecimals] = useState('0') const [url, setUrl] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [manager, setManager] = useState('') const [reserve, setReserve] = useState('') const [freeze, setFreeze] = useState('') const [clawback, setClawback] = useState('') const [loading, setLoading] = useState(false) const [createdAssets, setCreatedAssets] = useState([]) // ===== Transfer state ===== const [transferMode, setTransferMode] = useState('manual') const [transferAssetId, setTransferAssetId] = useState('') const [receiverAddress, setReceiverAddress] = useState('') const [transferAmount, setTransferAmount] = useState('1') const [transferLoading, setTransferLoading] = useState(false) // ===== USDC opt-in state ===== // Uses tri-state ('loading' | 'opted-in' | 'not-opted-in') to prevent infinite re-renders // Refs are used to track state without causing callback recreations const [usdcStatus, setUsdcStatus] = useState('loading') const [usdcBalance, setUsdcBalance] = useState(0n) const [usdcOptInLoading, setUsdcOptInLoading] = useState(false) // Track if we've completed at least one successful blockchain check for this address const [hasCheckedUsdcOnChain, setHasCheckedUsdcOnChain] = useState(false) // Refs to prevent circular dependencies and duplicate operations const hasShownUsdcWarningRef = useRef(false) // Prevent duplicate snackbar warnings const lastTransferModeRef = useRef('manual') // Track mode changes const isCheckingUsdcRef = useRef(false) // Prevent duplicate status checks const hasCheckedUsdcOnChainRef = useRef(false) // Track checked state (avoids stale closures) // ===== NFT mint state ===== const [selectedFile, setSelectedFile] = useState(null) const [previewUrl, setPreviewUrl] = useState('') const [nftLoading, setNftLoading] = useState(false) const fileInputRef = useRef(null) // NFT mint configurable fields const [nftName, setNftName] = useState('MasterPass Ticket') const [nftUnit, setNftUnit] = useState('MTK') const [nftSupply, setNftSupply] = useState('1') const [nftDecimals, setNftDecimals] = useState('0') const [nftDefaultFrozen, setNftDefaultFrozen] = useState(false) // NFT advanced (addresses) const [nftShowAdvanced, setNftShowAdvanced] = useState(false) const [nftManager, setNftManager] = useState('') const [nftReserve, setNftReserve] = useState('') const [nftFreeze, setNftFreeze] = useState('') const [nftClawback, setNftClawback] = useState('') // ===== use-wallet (Web3Auth OR WalletConnect) ===== // Use transactionSigner (not signer) - this is the correct property name from use-wallet const { transactionSigner, activeAddress } = useWallet() // Alias for backward compatibility in the code const signer = transactionSigner // ===== Notifications ===== const { enqueueSnackbar } = useSnackbar() // ===== Algorand client ===== const algodConfig = getAlgodConfigFromViteEnvironment() const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig]) // Derived booleans for convenience (only valid when hasCheckedUsdcOnChain is true) const usdcOptedIn = usdcStatus === 'opted-in' const usdcStatusLoading = usdcStatus === 'loading' /** * Fetch USDC opt-in status from blockchain * Uses asset-specific API for reliable opt-in detection * Falls back to account information API if needed */ const checkUsdcOptInStatus = useCallback(async () => { if (!activeAddress) { setUsdcStatus('not-opted-in') setUsdcBalance(0n) setHasCheckedUsdcOnChain(false) hasCheckedUsdcOnChainRef.current = false isCheckingUsdcRef.current = false return } // Prevent duplicate concurrent calls if (isCheckingUsdcRef.current) { return } isCheckingUsdcRef.current = true // Only set loading if we haven't checked yet (preserve existing status during refresh) if (!hasCheckedUsdcOnChainRef.current) { setUsdcStatus('loading') } try { // Method 1: Use asset-specific API (most reliable) // Returns holding if opted in, throws if not opted in let holding: any = null let apiCallSucceeded = false try { holding = await algorand.asset.getAccountInformation(activeAddress, BigInt(TESTNET_USDC_ASSET_ID)) apiCallSucceeded = true } catch (assetApiError: unknown) { // API call failed - account is likely not opted in const error = assetApiError as any // Check if it's a 404/not found error (definitely not opted in) // vs a network error (should fall through to method 2) const isNotFoundError = error?.message?.includes('not found') || error?.message?.includes('404') || error?.status === 404 || error?.response?.status === 404 if (isNotFoundError) { setUsdcStatus('not-opted-in') setUsdcBalance(0n) setHasCheckedUsdcOnChain(true) hasCheckedUsdcOnChainRef.current = true return } // Non-404 error - fall through to method 2 for verification } // Process successful API call result if (apiCallSucceeded && holding) { const holdingAny = holding as any const amount = holdingAny?.amount ?? holdingAny?.balance ?? 0 const balance = typeof amount === 'bigint' ? amount : BigInt(amount ?? 0) setUsdcStatus('opted-in') setUsdcBalance(balance) setHasCheckedUsdcOnChain(true) hasCheckedUsdcOnChainRef.current = true return } // Method 2: Fallback to account information API // Used when method 1 has non-404 errors or for verification const info = await algorand.client.algod.accountInformation(activeAddress).do() const assets: Array<{ assetId: bigint; amount?: number | bigint }> = info?.assets ?? [] const usdcHolding = assets.find((a) => a.assetId === BigInt(TESTNET_USDC_ASSET_ID)) if (usdcHolding) { const balance = BigInt(usdcHolding.amount ?? 0) setUsdcStatus('opted-in') setUsdcBalance(balance) } else { setUsdcStatus('not-opted-in') setUsdcBalance(0n) } // Mark that we've successfully completed a blockchain check setHasCheckedUsdcOnChain(true) hasCheckedUsdcOnChainRef.current = true } catch (e) { // On error, set to not-opted-in but don't mark as checked // This allows retry on next render cycle setUsdcStatus('not-opted-in') setUsdcBalance(0n) setHasCheckedUsdcOnChain(false) hasCheckedUsdcOnChainRef.current = false } finally { isCheckingUsdcRef.current = false } // Note: hasCheckedUsdcOnChain is read from closure, not needed in deps // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeAddress, algorand]) // Effect: Check USDC status when address changes or on mount // Small delay allows wallet state to stabilize after reconnect useEffect(() => { // Reset state when address changes setHasCheckedUsdcOnChain(false) hasCheckedUsdcOnChainRef.current = false hasShownUsdcWarningRef.current = false isCheckingUsdcRef.current = false if (!activeAddress) { setUsdcStatus('not-opted-in') setUsdcBalance(0n) return } // Set loading immediately, then check after delay setUsdcStatus('loading') const timeoutId = setTimeout(() => { checkUsdcOptInStatus() }, 500) return () => clearTimeout(timeoutId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeAddress]) // Effect: Handle transfer mode changes and show appropriate warnings // Only shows warnings after blockchain state is confirmed (not during loading) useEffect(() => { const prevMode = lastTransferModeRef.current const modeChanged = prevMode !== transferMode lastTransferModeRef.current = transferMode // Set transfer asset ID based on mode if (transferMode === 'algo') { setTransferAssetId('ALGO') } else if (transferMode === 'usdc') { setTransferAssetId(String(TESTNET_USDC_ASSET_ID)) // Show warnings only when: // 1. Actually switching TO usdc mode (not just re-render) // 2. Blockchain check is complete (status confirmed) // 3. Warning hasn't been shown already if (modeChanged && hasCheckedUsdcOnChain && !hasShownUsdcWarningRef.current && usdcStatus === 'not-opted-in') { enqueueSnackbar('You are not opted in to USDC yet. Please opt in before receiving or sending USDC.', { variant: 'info', }) hasShownUsdcWarningRef.current = true } else if ( modeChanged && hasCheckedUsdcOnChain && !hasShownUsdcWarningRef.current && usdcStatus === 'opted-in' && usdcBalance === 0n ) { enqueueSnackbar('Heads up: you have 0 USDC to send.', { variant: 'info' }) hasShownUsdcWarningRef.current = true } } else { // Manual mode - reset asset ID if it was set to ALGO or USDC if (transferAssetId === 'ALGO' || transferAssetId === String(TESTNET_USDC_ASSET_ID)) { setTransferAssetId('') } // Prefill with latest created asset if available if (!transferAssetId && createdAssets.length > 0) { setTransferAssetId(String(createdAssets[0].assetId)) } } // Reset warning flag when leaving USDC mode if (prevMode === 'usdc' && transferMode !== 'usdc') { hasShownUsdcWarningRef.current = false } // Note: We intentionally don't include usdcStatus/usdcBalance in deps to avoid re-runs on status changes // We only want this to run when transferMode or hasCheckedUsdcOnChain changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [transferMode, hasCheckedUsdcOnChain, enqueueSnackbar]) /** * Opt-in to TestNet USDC * Opt-in is an asset transfer of 0 USDC to self */ const handleOptInUsdc = async () => { // Check for activeAddress first (primary indicator of connection) // transactionSigner might be available even if not explicitly set if (!activeAddress) { enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' }) return } if (!signer) { enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' }) return } // Prevent duplicate transactions if already opted in if (usdcOptedIn) { enqueueSnackbar('You are already opted in to USDC ✅', { variant: 'info' }) return } try { setUsdcOptInLoading(true) enqueueSnackbar('Opting into USDC...', { variant: 'info' }) // Opt-in = asset transfer of 0 to self const result = await algorand.send.assetTransfer({ sender: activeAddress, signer, assetId: BigInt(TESTNET_USDC_ASSET_ID), receiver: activeAddress, amount: 0n, }) const txId = (result as { txId?: string }).txId // Optimistically update status immediately after successful transaction setUsdcStatus('opted-in') setUsdcBalance(0n) setHasCheckedUsdcOnChain(true) hasCheckedUsdcOnChainRef.current = true hasShownUsdcWarningRef.current = true // Prevent warning since we just opted in enqueueSnackbar('✅ USDC opted in!', { variant: 'success', action: () => txId ? ( View Tx on Lora ↗ ) : null, }) // Verify with blockchain after delay to confirm opt-in setTimeout(() => { checkUsdcOptInStatus() }, 2000) } catch (e) { enqueueSnackbar('USDC opt-in failed.', { variant: 'error' }) } finally { setUsdcOptInLoading(false) } } useEffect(() => { setCreatedAssets(loadAssets()) }, []) useEffect(() => { if (activeAddress && !manager) setManager(activeAddress) }, [activeAddress, manager]) // NFT: default manager to connected address (same UX as ASA) useEffect(() => { if (activeAddress && !nftManager) setNftManager(activeAddress) }, [activeAddress, nftManager]) // Prefill transfer asset id from latest created asset (QoL) — only in manual mode useEffect(() => { if (transferMode !== 'manual') return if (!transferAssetId && createdAssets.length > 0) { setTransferAssetId(String(createdAssets[0].assetId)) } }, [createdAssets, transferAssetId, transferMode]) const resetDefaults = () => { setAssetName('Tokenized Coffee Membership') setUnitName('COFFEE') setTotal('1000') setDecimals('0') setUrl('') setShowAdvanced(false) setManager(activeAddress ?? '') setReserve('') setFreeze('') setClawback('') } const resetNftDefaults = () => { setSelectedFile(null) setPreviewUrl('') if (fileInputRef.current) fileInputRef.current.value = '' setNftName('MasterPass Ticket') setNftUnit('MTK') setNftSupply('1') setNftDecimals('0') setNftDefaultFrozen(false) setNftShowAdvanced(false) setNftManager(activeAddress ?? '') setNftReserve('') setNftFreeze('') setNftClawback('') } const isWholeNumber = (v: string) => /^\d+$/.test(v) const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text) enqueueSnackbar('Asset ID copied to clipboard', { variant: 'success' }) setTransferMode('manual') setTransferAssetId(text) } catch { enqueueSnackbar('Copy failed. Please copy manually.', { variant: 'warning' }) } } /** * Handle ASA creation with validation and on-chain transaction * Adjusts total supply by decimals and saves asset to localStorage */ const handleTokenize = async () => { // Check for activeAddress first (primary indicator of connection) if (!activeAddress) { enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' }) return } if (!signer) { enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' }) return } if (!assetName || !unitName) { enqueueSnackbar('Please enter an asset name and symbol.', { variant: 'warning' }) return } if (!isWholeNumber(total)) { enqueueSnackbar('Total supply must be a whole number.', { variant: 'warning' }) return } if (!isWholeNumber(decimals)) { enqueueSnackbar('Decimals must be a whole number (0–19).', { variant: 'warning' }) return } const d = Number(decimals) if (Number.isNaN(d) || d < 0 || d > 19) { enqueueSnackbar('Decimals must be between 0 and 19.', { variant: 'warning' }) return } try { setLoading(true) enqueueSnackbar('Tokenizing asset (creating ASA)...', { variant: 'info' }) const onChainTotal = BigInt(total) * 10n ** BigInt(d) const createResult = await algorand.send.assetCreate({ sender: activeAddress, signer, total: onChainTotal, decimals: d, assetName, unitName, url: url || undefined, defaultFrozen: false, manager: manager || undefined, reserve: reserve || undefined, freeze: freeze || undefined, clawback: clawback || undefined, }) const assetId = createResult.assetId const newEntry: CreatedAsset = { assetId: BigInt(assetId), assetName: String(assetName), unitName: String(unitName), total: String(total), decimals: String(decimals), url: url ? String(url) : undefined, manager: manager ? String(manager) : undefined, reserve: reserve ? String(reserve) : undefined, freeze: freeze ? String(freeze) : undefined, clawback: clawback ? String(clawback) : undefined, createdAt: new Date().toISOString(), } const next = persistAsset(newEntry) setCreatedAssets(next) enqueueSnackbar(`✅ Success! Asset ID: ${assetId}`, { variant: 'success', action: () => assetId ? ( View on Lora ↗ ) : null, }) resetDefaults() } catch (error: any) { console.error('[ASA create] error:', error) const msg = error?.response?.body?.message || error?.response?.text || error?.message || String(error) enqueueSnackbar(`ASA creation failed: ${msg}`, { variant: 'error' }) } finally { setLoading(false) } } /** * Transfer assets (Manual ASA / USDC ASA / ALGO payment) * Handles validation, amount conversion, and transaction submission */ const handleTransferAsset = async () => { // Check for activeAddress first (primary indicator of connection) if (!activeAddress) { enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' }) return } if (!signer) { enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' }) return } if (!receiverAddress) { enqueueSnackbar('Please enter a recipient address.', { variant: 'warning' }) return } if (!transferAmount || Number(transferAmount) <= 0) { enqueueSnackbar('Please enter an amount greater than 0.', { variant: 'warning' }) return } // Manual ASA: validate Asset ID and whole-number amount if (transferMode === 'manual') { if (!transferAssetId || !isWholeNumber(transferAssetId)) { enqueueSnackbar('Please enter a valid Asset ID (number).', { variant: 'warning' }) return } if (!isWholeNumber(transferAmount)) { enqueueSnackbar('Amount must be a whole number for manual ASA transfers.', { variant: 'warning' }) return } } // USDC + ALGO: allow decimals up to 6 places if (transferMode === 'algo' || transferMode === 'usdc') { if (!/^\d+(\.\d+)?$/.test(transferAmount.trim())) { enqueueSnackbar('Amount must be a valid number (decimals allowed).', { variant: 'warning' }) return } } // USDC: block transfer if not opted in (only if status is confirmed, not during loading) if (transferMode === 'usdc' && hasCheckedUsdcOnChain && !usdcOptedIn) { enqueueSnackbar('You must opt-in to USDC before you can send/receive it.', { variant: 'warning' }) return } try { setTransferLoading(true) if (transferMode === 'algo') { enqueueSnackbar('Sending ALGO...', { variant: 'info' }) const result = await algorand.send.payment({ sender: activeAddress, signer, receiver: receiverAddress, amount: microAlgos(decimalToBaseUnits(transferAmount, ALGO_DECIMALS)), }) const txId = (result as { txId?: string }).txId enqueueSnackbar('✅ ALGO sent!', { variant: 'success', action: () => txId ? ( View Tx on Lora ↗ ) : null, }) } else if (transferMode === 'usdc') { // Double-check opt-in status (in case it changed) if (hasCheckedUsdcOnChain && !usdcOptedIn) { enqueueSnackbar('You are not opted in to USDC yet. Please opt in first.', { variant: 'warning' }) return } if (usdcBalance === 0n) { enqueueSnackbar('You have 0 USDC to send.', { variant: 'warning' }) return } enqueueSnackbar('Sending USDC...', { variant: 'info' }) const usdcAmount = decimalToBaseUnits(transferAmount, USDC_DECIMALS) if (usdcAmount > usdcBalance) { enqueueSnackbar('Insufficient USDC balance for this transfer.', { variant: 'warning' }) return } const result = await algorand.send.assetTransfer({ sender: activeAddress, signer, assetId: BigInt(TESTNET_USDC_ASSET_ID), receiver: receiverAddress, amount: usdcAmount, }) const txId = (result as { txId?: string }).txId enqueueSnackbar('✅ USDC transfer complete!', { variant: 'success', action: () => txId ? ( View Tx on Lora ↗ ) : null, }) // Refresh balance after transfer to show updated amount setTimeout(() => { checkUsdcOptInStatus() }, 2000) } else { // manual ASA enqueueSnackbar('Transferring asset...', { variant: 'info' }) const result = await algorand.send.assetTransfer({ sender: activeAddress, signer, assetId: BigInt(transferAssetId), receiver: receiverAddress, amount: BigInt(transferAmount), }) const txId = (result as { txId?: string }).txId enqueueSnackbar('✅ Transfer complete!', { variant: 'success', action: () => txId ? ( View Tx on Lora ↗ ) : null, }) } setReceiverAddress('') setTransferAmount('1') } catch (error) { if (transferMode === 'algo') { enqueueSnackbar('ALGO send failed.', { variant: 'error' }) } else { enqueueSnackbar('Transfer failed. If sending an ASA (incl. USDC), make sure the recipient has opted in.', { variant: 'error', }) } } finally { setTransferLoading(false) } } /** * NFT mint helpers */ const handleFileChange = (e: ChangeEvent) => { const file = e.target.files?.[0] || null setSelectedFile(file) setPreviewUrl(file ? URL.createObjectURL(file) : '') } const handleDivClick = () => fileInputRef.current?.click() const handleMintNFT = async () => { // Check for activeAddress first (primary indicator of connection) if (!activeAddress) { enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' }) return } if (!signer) { enqueueSnackbar('Wallet signer not available. Please try reconnecting your wallet.', { variant: 'error' }) return } if (!selectedFile) { enqueueSnackbar('Please select an image file to mint.', { variant: 'warning' }) return } // Validate NFT fields if (!nftName || !nftUnit) { enqueueSnackbar('Please enter an NFT name and unit/symbol.', { variant: 'warning' }) return } if (!nftSupply || !isWholeNumber(nftSupply)) { enqueueSnackbar('Supply must be a whole number.', { variant: 'warning' }) return } if (!nftDecimals || !isWholeNumber(nftDecimals)) { enqueueSnackbar('Decimals must be a whole number (0–19).', { variant: 'warning' }) return } const d = Number(nftDecimals) if (Number.isNaN(d) || d < 0 || d > 19) { enqueueSnackbar('NFT decimals must be between 0 and 19.', { variant: 'warning' }) return } setNftLoading(true) enqueueSnackbar('Uploading and preparing NFT...', { variant: 'info' }) let metadataUrl = '' try { const backendBase = resolveBackendBase() const backendApiUrl = `${backendBase.replace(/\/$/, '')}/api/pin-image` const formData = new FormData() formData.append('file', selectedFile) const response = await fetch(backendApiUrl, { method: 'POST', body: formData, mode: 'cors', }) if (!response.ok) { const errorText = await response.text() throw new Error(`Backend request failed: ${response.status} - ${errorText}`) } const data = await response.json() metadataUrl = data.metadataUrl if (!metadataUrl) throw new Error('Backend did not return a valid metadata URL') } catch (e: any) { enqueueSnackbar('Error uploading to backend. If in Codespaces, make port 3001 Public.', { variant: 'error' }) setNftLoading(false) return } try { enqueueSnackbar('Minting NFT on Algorand...', { variant: 'info' }) // Demo shortcut: hash the metadata URL string (ARC-3 would hash JSON bytes) const metadataHash = Uint8Array.from(sha512_256.digest(metadataUrl)) const onChainTotal = BigInt(nftSupply) * 10n ** BigInt(d) const createNFTResult = await algorand.send.assetCreate({ sender: activeAddress, signer, total: onChainTotal, decimals: d, assetName: nftName, unitName: nftUnit, url: metadataUrl, metadataHash, defaultFrozen: nftDefaultFrozen, manager: nftManager || undefined, reserve: nftReserve || undefined, freeze: nftFreeze || undefined, clawback: nftClawback || undefined, }) const assetId = createNFTResult.assetId // ✅ Persist minted NFT into SAME history list (NFTs are ASAs) const nftEntry: CreatedAsset = { assetId: BigInt(assetId), assetName: String(nftName), unitName: String(nftUnit), total: String(nftSupply), decimals: String(nftDecimals), url: metadataUrl ? String(metadataUrl) : undefined, manager: nftManager ? String(nftManager) : undefined, reserve: nftReserve ? String(nftReserve) : undefined, freeze: nftFreeze ? String(nftFreeze) : undefined, clawback: nftClawback ? String(nftClawback) : undefined, createdAt: new Date().toISOString(), } const next = persistAsset(nftEntry) setCreatedAssets(next) // QoL: switch to manual mode + prefill transfer section with minted asset id setTransferMode('manual') setTransferAssetId(String(assetId)) enqueueSnackbar(`✅ Success! NFT Asset ID: ${assetId}`, { variant: 'success', action: () => assetId ? ( View on Lora ↗ ) : null, }) // Reset just the file picker + preview (keep fields, so they can mint many quickly) setSelectedFile(null) setPreviewUrl('') if (fileInputRef.current) fileInputRef.current.value = '' } catch (e: any) { enqueueSnackbar(`Failed to mint NFT: ${e?.message || 'Unknown error'}`, { variant: 'error' }) } finally { setNftLoading(false) } } const canSubmit = !!assetName && !!unitName && !!total && !loading && !!activeAddress const canMintNft = !!nftName && !!nftUnit && !!nftSupply && !!nftDecimals && !!selectedFile && !!activeAddress && !nftLoading const transferAmountLabel = transferMode === 'algo' ? 'Amount (ALGO)' : transferMode === 'usdc' ? 'Amount (USDC)' : 'Amount' const transferAssetIdLabel = transferMode === 'algo' ? 'Asset (ALGO)' : transferMode === 'usdc' ? 'Asset (USDC)' : 'Asset ID' // Helper to render USDC status text const renderUsdcStatusText = () => { if (usdcStatusLoading) { return Checking status... } if (usdcOptedIn) { return Already opted in ✅ } return Required before you can receive TestNet USDC. } // Helper to render opt-in button text const renderOptInButtonText = () => { if (usdcOptInLoading) { return ( Opting in… ) } if (usdcStatusLoading) { return ( Checking… ) } if (usdcOptedIn) { return 'USDC opted in ✅' } return 'Opt in USDC' } return (
{/* Top header */}

Tokenize on Algorand

Mint standard ASAs or mint an NFT-style ASA on TestNet.

{/* TestNet funding helper */}
Need TestNet ALGO to get started? Use the Algorand TestNet Dispenser. Tip: fund the connected address, then refresh your balance.
Open Dispenser ↗
{/* ASA loading bar */} {loading && (
)} {/* MAIN: 2-column panel (ASA left, NFT right) */}
{/* ===== LEFT: ASA TOKENIZE FORM ===== */}

Tokenize an Asset (Mint ASA)

Create a standard ASA on TestNet. Perfect for RWA POCs.

setAssetName(e.target.value)} />
setUnitName(e.target.value)} />
setTotal(e.target.value)} />
setDecimals(e.target.value)} />
setUrl(e.target.value)} />
{showAdvanced && (
{[ { label: 'Manager', tip: 'The manager can update or reconfigure asset settings. Often set to the issuer wallet.', value: manager, setValue: setManager, placeholder: 'Defaults to your wallet address', }, { label: 'Reserve', tip: 'Reserve may hold non-circulating supply depending on your design. Leave blank to disable.', value: reserve, setValue: setReserve, placeholder: 'Optional address', }, { label: 'Freeze', tip: 'Freeze can freeze/unfreeze holdings (useful for compliance). Leave blank to disable.', value: freeze, setValue: setFreeze, placeholder: 'Optional address', }, { label: 'Clawback', tip: 'Clawback can revoke tokens from accounts (recovery/compliance). Leave blank to disable.', value: clawback, setValue: setClawback, placeholder: 'Optional address', }, ].map((f) => (
f.setValue(e.target.value)} />
))}
)}
{/* ===== RIGHT: NFT MINT PANEL ===== */}

Tokenize an NFT (Mint ASA)

Upload an image → backend pins to IPFS → mint an ASA with metadata.

{/* NFT fields */}
setNftName(e.target.value)} />
setNftUnit(e.target.value)} />
setNftSupply(e.target.value)} />

For a true 1/1 NFT, set supply = 1.

setNftDecimals(e.target.value)} />
{/* NFT advanced options toggle */}
{nftShowAdvanced && (
{[ { label: 'Manager', tip: 'Manager can update or reconfigure asset settings. Often set to the issuer wallet.', value: nftManager, setValue: setNftManager, placeholder: 'Defaults to your wallet address', }, { label: 'Reserve', tip: 'Reserve can hold non-circulating supply depending on design. Leave blank to disable.', value: nftReserve, setValue: setNftReserve, placeholder: 'Optional address', }, { label: 'Freeze', tip: 'Freeze can freeze/unfreeze holdings (useful for compliance). Leave blank to disable.', value: nftFreeze, setValue: setNftFreeze, placeholder: 'Optional address', }, { label: 'Clawback', tip: 'Clawback can revoke tokens from accounts (recovery/compliance). Leave blank to disable.', value: nftClawback, setValue: setNftClawback, placeholder: 'Optional address', }, ].map((f) => (
f.setValue(e.target.value)} />
))}
)}
{/* Image upload */}
{previewUrl ? ( NFT preview ) : (

Drag and drop or click to upload

PNG, JPG, GIF up to 10MB

)}
{/* Buttons */}

Uses backend /api/pin-image. In Codespaces, make port 3001 Public.

{/* ===== MY CREATED ASSETS ===== */}

My Created Assets

{createdAssets.length === 0 ? ( ) : ( createdAssets.map((a) => ( window.open(`${LORA_BASE}/asset/${a.assetId}`, '_blank', 'noopener,noreferrer')} title="Open in Lora explorer" > )) )}
Asset ID Name Symbol Supply Decimals
No assets created yet. Mint one to see it here.
{String(a.assetId)}
{a.assetName} {a.unitName} {a.total} {a.decimals}

This list is stored locally in your browser (localStorage) to keep the template simple.

{/* ===== TRANSFER ===== */}

Transfer

Send ALGO, USDC, or any ASA (including NFTs) to another wallet.

{/* USDC Opt-in (only relevant for receiving USDC) */}
USDC Opt-In: {renderUsdcStatusText()}
Asset ID: {TESTNET_USDC_ASSET_ID}
{/* TestNet USDC helper */}
Need TestNet USDC? Use Circle's faucet, then transfer it like any ASA. Note: you may need to opt-in to the USDC asset before receiving it.
Open USDC Faucet ↗
{/* Mode selector */}
setTransferAssetId(e.target.value)} disabled={transferMode === 'algo' || transferMode === 'usdc'} /> {transferMode === 'usdc' && (

USDC TestNet Asset ID: {TESTNET_USDC_ASSET_ID}

)}
setReceiverAddress(e.target.value)} />
setTransferAmount(e.target.value)} placeholder={transferMode === 'manual' ? 'e.g. 1' : 'e.g. 1.5'} /> {transferMode === 'manual' && (

Manual ASA transfers use whole-number amounts.

)} {(transferMode === 'algo' || transferMode === 'usdc') && (

Decimals allowed (up to 6 places).

)}

{transferMode === 'algo' ? 'ALGO payments do not require opt-in.' : 'For ASAs (including USDC and NFTs), the recipient must opt-in to the asset before receiving it.'}

) }