From 6386a98986ed7fbad338f4b33b34693f01421aff Mon Sep 17 00:00:00 2001 From: SaraJane Date: Mon, 12 Jan 2026 13:42:36 +0000 Subject: [PATCH] Add algo / usdc transfers --- .../src/components/TokenizeAsset.tsx | 606 ++++++++++++------ 1 file changed, 404 insertions(+), 202 deletions(-) diff --git a/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx b/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx index e7cac64..a395b49 100644 --- a/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx +++ b/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx @@ -28,6 +28,13 @@ type CreatedAsset = { 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() @@ -68,6 +75,32 @@ function persistAsset(asset: CreatedAsset): CreatedAsset[] { 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) @@ -92,6 +125,7 @@ export default function TokenizeAsset() { const [createdAssets, setCreatedAssets] = useState([]) // ===== Transfer state ===== + const [transferMode, setTransferMode] = useState('manual') const [transferAssetId, setTransferAssetId] = useState('') const [receiverAddress, setReceiverAddress] = useState('') const [transferAmount, setTransferAmount] = useState('1') @@ -138,12 +172,32 @@ export default function TokenizeAsset() { if (activeAddress && !nftManager) setNftManager(activeAddress) }, [activeAddress, nftManager]) - // Prefill transfer asset id from latest created asset (QoL) + // 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]) + }, [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') @@ -182,6 +236,7 @@ export default function TokenizeAsset() { 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' }) @@ -282,7 +337,7 @@ export default function TokenizeAsset() { } /** - * Transfer ASA (works for NFTs too, since NFTs are ASAs) + * Transfer (Manual ASA / USDC ASA / ALGO payment) */ const handleTransferAsset = async () => { if (!transactionSigner || !activeAddress) { @@ -290,55 +345,135 @@ export default function TokenizeAsset() { return } - if (!transferAssetId || !isWholeNumber(transferAssetId)) { - enqueueSnackbar('Please enter a valid Asset ID (number).', { variant: 'warning' }) - return - } - if (!receiverAddress) { enqueueSnackbar('Please enter a recipient address.', { variant: 'warning' }) return } - if (!transferAmount || !isWholeNumber(transferAmount)) { - enqueueSnackbar('Amount must be a whole number.', { variant: 'warning' }) + if (!transferAmount || Number(transferAmount) <= 0) { + enqueueSnackbar('Please enter an amount greater than 0.', { variant: 'warning' }) return } + // Manual ASA validates Asset ID + 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 + if (transferMode === 'algo' || transferMode === 'usdc') { + if (!/^\d+(\.\d+)?$/.test(transferAmount.trim())) { + enqueueSnackbar('Amount must be a valid number (decimals allowed).', { variant: 'warning' }) + return + } + } + try { setTransferLoading(true) - enqueueSnackbar('Transferring asset...', { variant: 'info' }) - const result = await algorand.send.assetTransfer({ - sender: activeAddress, - signer: transactionSigner, - assetId: Number(transferAssetId), - receiver: receiverAddress, - amount: BigInt(transferAmount), - }) + if (transferMode === 'algo') { + enqueueSnackbar('Sending ALGO...', { variant: 'info' }) - const txId = (result as { txId?: string }).txId + const microAlgos = decimalToBaseUnits(transferAmount, ALGO_DECIMALS) - enqueueSnackbar('✅ Transfer complete!', { - variant: 'success', - action: () => - txId ? ( - - View Tx on Lora ↗ - - ) : null, - }) + const result = await algorand.send.payment({ + sender: activeAddress, + signer: transactionSigner, + receiver: receiverAddress, + amount: { microAlgo: Number(microAlgos) }, + }) + + const txId = (result as { txId?: string }).txId + + enqueueSnackbar('✅ ALGO sent!', { + variant: 'success', + action: () => + txId ? ( + + View Tx on Lora ↗ + + ) : null, + }) + } else if (transferMode === 'usdc') { + enqueueSnackbar('Sending USDC...', { variant: 'info' }) + + const usdcAmount = decimalToBaseUnits(transferAmount, USDC_DECIMALS) + + const result = await algorand.send.assetTransfer({ + sender: activeAddress, + signer: transactionSigner, + assetId: 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, + }) + } else { + // manual ASA + enqueueSnackbar('Transferring asset...', { variant: 'info' }) + + const result = await algorand.send.assetTransfer({ + sender: activeAddress, + signer: transactionSigner, + assetId: Number(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) { console.error(error) - enqueueSnackbar('Transfer failed. Make sure the recipient has opted in.', { variant: '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) } @@ -462,7 +597,8 @@ export default function TokenizeAsset() { const next = persistAsset(nftEntry) setCreatedAssets(next) - // QoL: prefill transfer section with minted asset id + // QoL: switch to manual mode + prefill transfer section with minted asset id + setTransferMode('manual') setTransferAssetId(String(assetId)) enqueueSnackbar(`✅ Success! NFT Asset ID: ${assetId}`, { @@ -503,6 +639,12 @@ export default function TokenizeAsset() { !!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' + return (
{/* Top header */} @@ -543,177 +685,181 @@ export default function TokenizeAsset() {
-
-
- - 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)} - /> -
- ))} +
+
+ + setAssetName(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 on Algorand TestNet. + Upload an image → backend pins to IPFS → mint an ASA with metadata.

-
+
{/* NFT fields */}
@@ -897,7 +1043,9 @@ export default function TokenizeAsset() { onClick={handleMintNFT} disabled={!canMintNft} className={`px-6 py-3 rounded-lg font-semibold transition ${ - canMintNft ? 'bg-teal-600 hover:bg-teal-700 text-white shadow-md' : 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400' + canMintNft + ? 'bg-teal-600 hover:bg-teal-700 text-white shadow-md' + : 'bg-slate-300 text-slate-500 cursor-not-allowed dark:bg-slate-700 dark:text-slate-400' }`} > {nftLoading ? ( @@ -994,21 +1142,66 @@ export default function TokenizeAsset() {

- {/* ===== TRANSFER ASSET ===== */} + {/* ===== TRANSFER ===== */}
-

Transfer Asset

-

Send units of an Algorand Standard Asset (ASA) to another wallet.

+

Transfer

+

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

+ + {/* Mode selector */} +
+ +
+ + + + + +
+
- + setTransferAssetId(e.target.value)} + disabled={transferMode === 'algo' || transferMode === 'usdc'} /> + {transferMode === 'usdc' && ( +

+ USDC TestNet Asset ID: {TESTNET_USDC_ASSET_ID} +

+ )}
@@ -1023,14 +1216,21 @@ export default function TokenizeAsset() {
- + 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).

+ )}
@@ -1045,13 +1245,15 @@ export default function TokenizeAsset() { : 'bg-teal-600 hover:bg-teal-700 text-white shadow-md' }`} > - {transferLoading ? 'Transferring…' : 'Transfer Asset'} + {transferLoading ? 'Transferring…' : transferMode === 'algo' ? 'Send ALGO' : transferMode === 'usdc' ? 'Send USDC' : 'Transfer Asset'}

- The recipient must opt-in to the asset before receiving it. + {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.'}