feat: usdc optin, UI updates, comments
This commit is contained in:
@ -1,7 +1,7 @@
|
|||||||
import { AlgorandClient } from '@algorandfoundation/algokit-utils'
|
import { AlgorandClient } from '@algorandfoundation/algokit-utils'
|
||||||
import { sha512_256 } from 'js-sha512'
|
import { sha512_256 } from 'js-sha512'
|
||||||
import { useSnackbar } from 'notistack'
|
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 { AiOutlineCloudUpload, AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai'
|
||||||
import { BsCoin } from 'react-icons/bs'
|
import { BsCoin } from 'react-icons/bs'
|
||||||
import { useUnifiedWallet } from '../hooks/useUnifiedWallet'
|
import { useUnifiedWallet } from '../hooks/useUnifiedWallet'
|
||||||
@ -25,6 +25,14 @@ type CreatedAsset = {
|
|||||||
createdAt: 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 STORAGE_KEY = 'tokenize_assets'
|
||||||
const LORA_BASE = 'https://lora.algokit.io/testnet'
|
const LORA_BASE = 'https://lora.algokit.io/testnet'
|
||||||
|
|
||||||
@ -131,7 +139,23 @@ export default function TokenizeAsset() {
|
|||||||
const [transferAmount, setTransferAmount] = useState<string>('1')
|
const [transferAmount, setTransferAmount] = useState<string>('1')
|
||||||
const [transferLoading, setTransferLoading] = useState<boolean>(false)
|
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 [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
const [previewUrl, setPreviewUrl] = useState<string>('')
|
const [previewUrl, setPreviewUrl] = useState<string>('')
|
||||||
const [nftLoading, setNftLoading] = useState<boolean>(false)
|
const [nftLoading, setNftLoading] = useState<boolean>(false)
|
||||||
@ -161,6 +185,266 @@ export default function TokenizeAsset() {
|
|||||||
const algodConfig = getAlgodConfigFromViteEnvironment()
|
const algodConfig = getAlgodConfigFromViteEnvironment()
|
||||||
const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig])
|
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(() => {
|
useEffect(() => {
|
||||||
setCreatedAssets(loadAssets())
|
setCreatedAssets(loadAssets())
|
||||||
}, [])
|
}, [])
|
||||||
@ -182,25 +466,6 @@ export default function TokenizeAsset() {
|
|||||||
}
|
}
|
||||||
}, [createdAssets, transferAssetId, transferMode])
|
}, [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 = () => {
|
const resetDefaults = () => {
|
||||||
setAssetName('Tokenized Coffee Membership')
|
setAssetName('Tokenized Coffee Membership')
|
||||||
setUnitName('COFFEE')
|
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 () => {
|
const handleTransferAsset = async () => {
|
||||||
if (!signer || !activeAddress) {
|
if (!signer || !activeAddress) {
|
||||||
@ -357,7 +623,7 @@ export default function TokenizeAsset() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual ASA validates Asset ID + whole-number amount
|
// Manual ASA: validate Asset ID and whole-number amount
|
||||||
if (transferMode === 'manual') {
|
if (transferMode === 'manual') {
|
||||||
if (!transferAssetId || !isWholeNumber(transferAssetId)) {
|
if (!transferAssetId || !isWholeNumber(transferAssetId)) {
|
||||||
enqueueSnackbar('Please enter a valid Asset ID (number).', { variant: 'warning' })
|
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 (transferMode === 'algo' || transferMode === 'usdc') {
|
||||||
if (!/^\d+(\.\d+)?$/.test(transferAmount.trim())) {
|
if (!/^\d+(\.\d+)?$/.test(transferAmount.trim())) {
|
||||||
enqueueSnackbar('Amount must be a valid number (decimals allowed).', { variant: 'warning' })
|
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 {
|
try {
|
||||||
setTransferLoading(true)
|
setTransferLoading(true)
|
||||||
|
|
||||||
@ -409,10 +681,25 @@ export default function TokenizeAsset() {
|
|||||||
) : null,
|
) : null,
|
||||||
})
|
})
|
||||||
} else if (transferMode === 'usdc') {
|
} 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)
|
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({
|
const result = await algorand.send.assetTransfer({
|
||||||
sender: activeAddress,
|
sender: activeAddress,
|
||||||
signer,
|
signer,
|
||||||
@ -437,6 +724,11 @@ export default function TokenizeAsset() {
|
|||||||
</a>
|
</a>
|
||||||
) : null,
|
) : null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Refresh balance after transfer to show updated amount
|
||||||
|
setTimeout(() => {
|
||||||
|
checkUsdcOptInStatus()
|
||||||
|
}, 2000)
|
||||||
} else {
|
} else {
|
||||||
// manual ASA
|
// manual ASA
|
||||||
enqueueSnackbar('Transferring asset...', { variant: 'info' })
|
enqueueSnackbar('Transferring asset...', { variant: 'info' })
|
||||||
@ -470,11 +762,13 @@ export default function TokenizeAsset() {
|
|||||||
setReceiverAddress('')
|
setReceiverAddress('')
|
||||||
setTransferAmount('1')
|
setTransferAmount('1')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error('Transfer failed', error)
|
||||||
if (transferMode === 'algo') {
|
if (transferMode === 'algo') {
|
||||||
enqueueSnackbar('ALGO send failed.', { variant: 'error' })
|
enqueueSnackbar('ALGO send failed.', { variant: 'error' })
|
||||||
} else {
|
} 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 {
|
} finally {
|
||||||
setTransferLoading(false)
|
setTransferLoading(false)
|
||||||
@ -638,6 +932,41 @@ export default function TokenizeAsset() {
|
|||||||
|
|
||||||
const transferAssetIdLabel = transferMode === 'algo' ? 'Asset (ALGO)' : transferMode === 'usdc' ? 'Asset (USDC)' : 'Asset ID'
|
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 (
|
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">
|
<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 */}
|
{/* Top header */}
|
||||||
@ -669,7 +998,6 @@ export default function TokenizeAsset() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<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 */}
|
{/* 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="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="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">
|
<div className="text-sm text-slate-700 dark:text-slate-200">
|
||||||
Need TestNet USDC? Use Circle’s faucet, then transfer it like any ASA.
|
Need TestNet USDC? Use Circle's faucet, then transfer it like any ASA.
|
||||||
<span className="block text-xs text-slate-500 dark:text-slate-400 mt-1">
|
<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.
|
Note: you may need to opt-in to the USDC asset before receiving it.
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user