feat: usdc optin, UI updates, comments

This commit is contained in:
SaraJane
2026-01-16 00:14:07 +00:00
parent f6f24257d5
commit 00c87280f6

View File

@ -1,7 +1,7 @@
import { AlgorandClient } from '@algorandfoundation/algokit-utils'
import { sha512_256 } from 'js-sha512'
import { useSnackbar } from 'notistack'
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AiOutlineCloudUpload, AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai'
import { BsCoin } from 'react-icons/bs'
import { useUnifiedWallet } from '../hooks/useUnifiedWallet'
@ -25,6 +25,14 @@ type CreatedAsset = {
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'
@ -131,7 +139,23 @@ export default function TokenizeAsset() {
const [transferAmount, setTransferAmount] = useState<string>('1')
const [transferLoading, setTransferLoading] = useState<boolean>(false)
// ===== NFT mint state (new) =====
// ===== 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<UsdcStatus>('loading')
const [usdcBalance, setUsdcBalance] = useState<bigint>(0n)
const [usdcOptInLoading, setUsdcOptInLoading] = useState<boolean>(false)
// Track if we've completed at least one successful blockchain check for this address
const [hasCheckedUsdcOnChain, setHasCheckedUsdcOnChain] = useState<boolean>(false)
// Refs to prevent circular dependencies and duplicate operations
const hasShownUsdcWarningRef = useRef<boolean>(false) // Prevent duplicate snackbar warnings
const lastTransferModeRef = useRef<TransferMode>('manual') // Track mode changes
const isCheckingUsdcRef = useRef<boolean>(false) // Prevent duplicate status checks
const hasCheckedUsdcOnChainRef = useRef<boolean>(false) // Track checked state (avoids stale closures)
// ===== NFT mint state =====
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string>('')
const [nftLoading, setNftLoading] = useState<boolean>(false)
@ -161,6 +185,266 @@ export default function TokenizeAsset() {
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<{ ['asset-id']: number; amount?: number }> = info?.assets ?? []
const usdcHolding = assets.find((a) => a['asset-id'] === 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) {
console.error('Failed to check USDC opt-in', 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 () => {
if (!signer || !activeAddress) {
enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' })
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: 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 ? (
<a
href={`${LORA_BASE}/transaction/${txId}`}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: 'underline', marginLeft: 8 }}
>
View Tx on Lora
</a>
) : null,
})
// Verify with blockchain after delay to confirm opt-in
setTimeout(() => {
checkUsdcOptInStatus()
}, 2000)
} catch (e) {
console.error('USDC opt-in failed', e)
enqueueSnackbar('USDC opt-in failed.', { variant: 'error' })
} finally {
setUsdcOptInLoading(false)
}
}
useEffect(() => {
setCreatedAssets(loadAssets())
}, [])
@ -182,25 +466,6 @@ export default function TokenizeAsset() {
}
}, [createdAssets, transferAssetId, transferMode])
// When switching transfer mode, set the asset id display/value accordingly
useEffect(() => {
if (transferMode === 'algo') {
setTransferAssetId('ALGO')
} else if (transferMode === 'usdc') {
setTransferAssetId(String(TESTNET_USDC_ASSET_ID))
} else {
// manual: keep whatever the user had, but if it was ALGO then clear
if (transferAssetId === 'ALGO' || transferAssetId === String(TESTNET_USDC_ASSET_ID)) {
setTransferAssetId('')
}
// If we have history, prefill from latest
if (!transferAssetId && createdAssets.length > 0) {
setTransferAssetId(String(createdAssets[0].assetId))
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transferMode])
const resetDefaults = () => {
setAssetName('Tokenized Coffee Membership')
setUnitName('COFFEE')
@ -339,7 +604,8 @@ export default function TokenizeAsset() {
}
/**
* Transfer (Manual ASA / USDC ASA / ALGO payment)
* Transfer assets (Manual ASA / USDC ASA / ALGO payment)
* Handles validation, amount conversion, and transaction submission
*/
const handleTransferAsset = async () => {
if (!signer || !activeAddress) {
@ -357,7 +623,7 @@ export default function TokenizeAsset() {
return
}
// Manual ASA validates Asset ID + whole-number amount
// 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' })
@ -369,7 +635,7 @@ export default function TokenizeAsset() {
}
}
// USDC + ALGO allow decimals up to 6
// 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' })
@ -377,6 +643,12 @@ export default function TokenizeAsset() {
}
}
// 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)
@ -409,10 +681,25 @@ export default function TokenizeAsset() {
) : null,
})
} else if (transferMode === 'usdc') {
enqueueSnackbar('Sending USDC...', { variant: 'info' })
// 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,
@ -437,6 +724,11 @@ export default function TokenizeAsset() {
</a>
) : null,
})
// Refresh balance after transfer to show updated amount
setTimeout(() => {
checkUsdcOptInStatus()
}, 2000)
} else {
// manual ASA
enqueueSnackbar('Transferring asset...', { variant: 'info' })
@ -470,11 +762,13 @@ export default function TokenizeAsset() {
setReceiverAddress('')
setTransferAmount('1')
} catch (error) {
console.error(error)
console.error('Transfer failed', 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' })
enqueueSnackbar('Transfer failed. If sending an ASA (incl. USDC), make sure the recipient has opted in.', {
variant: 'error',
})
}
} finally {
setTransferLoading(false)
@ -638,6 +932,41 @@ export default function TokenizeAsset() {
const transferAssetIdLabel = transferMode === 'algo' ? 'Asset (ALGO)' : transferMode === 'usdc' ? 'Asset (USDC)' : 'Asset ID'
// Helper to render USDC status text
const renderUsdcStatusText = () => {
if (usdcStatusLoading) {
return <span className="text-slate-500 dark:text-slate-400">Checking status...</span>
}
if (usdcOptedIn) {
return <span className="text-teal-700 dark:text-teal-300">Already opted in </span>
}
return <span className="text-slate-600 dark:text-slate-300">Required before you can receive TestNet USDC.</span>
}
// Helper to render opt-in button text
const renderOptInButtonText = () => {
if (usdcOptInLoading) {
return (
<span className="flex items-center gap-2">
<AiOutlineLoading3Quarters className="animate-spin" />
Opting in
</span>
)
}
if (usdcStatusLoading) {
return (
<span className="flex items-center gap-2">
<AiOutlineLoading3Quarters className="animate-spin" />
Checking
</span>
)
}
if (usdcOptedIn) {
return 'USDC opted in ✅'
}
return 'Opt in USDC'
}
return (
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-lg p-6 sm:p-8">
{/* Top header */}
@ -669,7 +998,6 @@ export default function TokenizeAsset() {
</a>
</div>
</div>
</div>
</div>
</div>
@ -1164,11 +1492,35 @@ export default function TokenizeAsset() {
<div className="mt-12 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-lg p-6 sm:p-8">
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-2">Transfer</h3>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6">Send ALGO, USDC, or any ASA (including NFTs) to another wallet.</p>
{/* USDC Opt-in (only relevant for receiving USDC) */}
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 p-4">
<div className="text-sm text-slate-700 dark:text-slate-200">
<span className="font-semibold">USDC Opt-In:</span> {renderUsdcStatusText()}
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Asset ID: <span className="font-mono">{TESTNET_USDC_ASSET_ID}</span>
</div>
</div>
<button
type="button"
onClick={handleOptInUsdc}
disabled={!activeAddress || usdcOptedIn || usdcOptInLoading || usdcStatusLoading}
className={`inline-flex items-center justify-center px-4 py-2 rounded-lg font-semibold transition ${
!activeAddress || usdcOptedIn || usdcOptInLoading || usdcStatusLoading
? 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400'
: 'bg-teal-600 hover:bg-teal-700 text-white shadow-md'
}`}
>
{renderOptInButtonText()}
</button>
</div>
{/* TestNet USDC helper */}
<div className="mb-6 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="text-sm text-slate-700 dark:text-slate-200">
Need TestNet USDC? Use Circles faucet, then transfer it like any ASA.
Need TestNet USDC? Use Circle&apos;s faucet, then transfer it like any ASA.
<span className="block text-xs text-slate-500 dark:text-slate-400 mt-1">
Note: you may need to opt-in to the USDC asset before receiving it.
</span>