From 203580ffb0b28fa1f706f27bdee53a84083562fa Mon Sep 17 00:00:00 2001 From: SaraJane Date: Wed, 31 Dec 2025 12:33:58 +0000 Subject: [PATCH] TokenizeRWATemplate --- .../package-lock.json | 68 +++ .../TokenizeRWATemplate-frontend/package.json | 12 +- .../TokenizeRWATemplate-frontend/src/App.tsx | 20 +- .../TokenizeRWATemplate-frontend/src/Home.tsx | 227 +++++++--- .../src/Layout.tsx | 116 +++++ .../src/TokenizePage.tsx | 11 + .../src/components/ThemeToggle.tsx | 49 ++ .../src/components/TokenizeAsset.tsx | 421 ++++++++++++++++++ .../tailwind.config.cjs | 7 +- 9 files changed, 849 insertions(+), 82 deletions(-) create mode 100644 projects/TokenizeRWATemplate-frontend/src/Layout.tsx create mode 100644 projects/TokenizeRWATemplate-frontend/src/TokenizePage.tsx create mode 100644 projects/TokenizeRWATemplate-frontend/src/components/ThemeToggle.tsx create mode 100644 projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx diff --git a/projects/TokenizeRWATemplate-frontend/package-lock.json b/projects/TokenizeRWATemplate-frontend/package-lock.json index 8c043a1..991bd0b 100644 --- a/projects/TokenizeRWATemplate-frontend/package-lock.json +++ b/projects/TokenizeRWATemplate-frontend/package-lock.json @@ -18,6 +18,8 @@ "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.11.0", "tslib": "^2.6.2" }, "devDependencies": { @@ -5045,6 +5047,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -10106,6 +10121,15 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -10123,6 +10147,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz", + "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", + "license": "MIT", + "dependencies": { + "react-router": "7.11.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -10506,6 +10568,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/projects/TokenizeRWATemplate-frontend/package.json b/projects/TokenizeRWATemplate-frontend/package.json index 3c013b4..589f39b 100644 --- a/projects/TokenizeRWATemplate-frontend/package.json +++ b/projects/TokenizeRWATemplate-frontend/package.json @@ -13,24 +13,24 @@ }, "devDependencies": { "@algorandfoundation/algokit-client-generator": "^5.0.0", + "@playwright/test": "^1.35.0", + "@types/jest": "29.5.2", "@types/node": "^18.17.14", "@types/react": "^18.2.11", "@types/react-dom": "^18.2.4", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.14", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "@typescript-eslint/eslint-plugin": "^7.0.2", - "@typescript-eslint/parser": "^7.0.2", + "playwright": "^1.35.0", "postcss": "^8.4.24", "tailwindcss": "3.3.2", "ts-jest": "^29.1.1", - "@types/jest": "29.5.2", "ts-node": "^10.9.1", "typescript": "^5.1.6", - "@playwright/test": "^1.35.0", - "playwright": "^1.35.0", "vite": "^5.0.0", "vite-plugin-node-polyfills": "^0.22.0" }, @@ -45,6 +45,8 @@ "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.11.0", "tslib": "^2.6.2" }, "scripts": { diff --git a/projects/TokenizeRWATemplate-frontend/src/App.tsx b/projects/TokenizeRWATemplate-frontend/src/App.tsx index f346006..83c2153 100644 --- a/projects/TokenizeRWATemplate-frontend/src/App.tsx +++ b/projects/TokenizeRWATemplate-frontend/src/App.tsx @@ -1,6 +1,9 @@ import { SupportedWallet, WalletId, WalletManager, WalletProvider } from '@txnlab/use-wallet-react' import { SnackbarProvider } from 'notistack' +import { BrowserRouter, Routes, Route } from 'react-router-dom' import Home from './Home' +import Layout from './Layout' +import TokenizePage from './TokenizePage' import { getAlgodConfigFromViteEnvironment, getKmdConfigFromViteEnvironment } from './utils/network/getAlgoClientConfigs' let supportedWallets: SupportedWallet[] @@ -17,13 +20,7 @@ if (import.meta.env.VITE_ALGOD_NETWORK === 'localnet') { }, ] } else { - supportedWallets = [ - { id: WalletId.DEFLY }, - { id: WalletId.PERA }, - { id: WalletId.EXODUS }, - // If you are interested in WalletConnect v2 provider - // refer to https://github.com/TxnLab/use-wallet for detailed integration instructions - ] + supportedWallets = [{ id: WalletId.DEFLY }, { id: WalletId.PERA }, { id: WalletId.EXODUS }] } export default function App() { @@ -49,7 +46,14 @@ export default function App() { return ( - + + + }> + } /> + } /> + + + ) diff --git a/projects/TokenizeRWATemplate-frontend/src/Home.tsx b/projects/TokenizeRWATemplate-frontend/src/Home.tsx index 68313ee..25629a8 100644 --- a/projects/TokenizeRWATemplate-frontend/src/Home.tsx +++ b/projects/TokenizeRWATemplate-frontend/src/Home.tsx @@ -1,76 +1,175 @@ -// src/components/Home.tsx import { useWallet } from '@txnlab/use-wallet-react' -import React, { useState } from 'react' -import ConnectWallet from './components/ConnectWallet' -import Transact from './components/Transact' -import AppCalls from './components/AppCalls' +import { Link } from 'react-router-dom' -interface HomeProps {} - -const Home: React.FC = () => { - const [openWalletModal, setOpenWalletModal] = useState(false) - const [openDemoModal, setOpenDemoModal] = useState(false) - const [appCallsDemoModal, setAppCallsDemoModal] = useState(false) +export default function Home() { const { activeAddress } = useWallet() - const toggleWalletModal = () => { - setOpenWalletModal(!openWalletModal) - } - - const toggleDemoModal = () => { - setOpenDemoModal(!openDemoModal) - } - - const toggleAppCallsModal = () => { - setAppCallsDemoModal(!appCallsDemoModal) - } - return ( -
-
-
-

- Welcome to
AlgoKit πŸ™‚
-

-

- This starter has been generated using official AlgoKit React template. Refer to the resource below for next steps. -

- -
- - Getting started - - -
- - - {activeAddress && ( - - )} - - {activeAddress && ( - - )} +
+ {/* Hero Section */} +
+
+
+ RWA Tokenization Platform
- - - +

+ Tokenize Real-World Assets on Algorand +

+ +

+ Create Algorand Standard Assets (ASA) with built-in compliance features. Perfect for founders prototyping RWA solutions. +

+ +
+ + Start Tokenizing + + + + Learn about ASAs + +
+ + {!activeAddress && ( +

Connect your wallet using the button in the top-right to get started.

+ )} +
+
+ + {/* Features Section */} +
+
+
+

How It Works

+
+ +
+ {/* Step 1 */} +
+
+ 1 +
+

Connect Wallet

+

Use Pera, Defly, Exodus, or KMD on localnet. One click to connect.

+
+ + {/* Step 2 */} +
+
+ 2 +
+

Create ASA

+

+ Define asset properties: name, symbol, supply, and optional metadata URL. +

+
+ + {/* Step 3 */} +
+
+ 3 +
+

Track Assets

+

+ View your created assets in a local history table (stored in your browser). +

+
+
+
+
+ + {/* Features Highlight */} +
+
+
+

Compliance-Ready Features

+
    +
  • + βœ“ + + Manager Role: Update asset settings + +
  • +
  • + βœ“ + + Freeze Account: Restrict transfers + +
  • +
  • + βœ“ + + Clawback Authority: Recover tokens if needed + +
  • +
  • + βœ“ + + Metadata Support: Link off-chain documentation + +
  • +
+
+
+
+

Asset Configuration Example

+
+
+ Name:{' '} + Real Estate Token +
+
+ Symbol:{' '} + PROPERTY +
+
+ Total Supply:{' '} + 1,000,000 +
+
+ Decimals:{' '} + 2 +
+
+ Manager:{' '} + Your Wallet +
+
+
+
+
+
+ + {/* CTA Section */} +
+
+

Ready to get started?

+

+ Launch your first RWA token in minutes. No complicated setup, no hidden fees. +

+ + Create Your First Asset +
) } - -export default Home diff --git a/projects/TokenizeRWATemplate-frontend/src/Layout.tsx b/projects/TokenizeRWATemplate-frontend/src/Layout.tsx new file mode 100644 index 0000000..faecc7e --- /dev/null +++ b/projects/TokenizeRWATemplate-frontend/src/Layout.tsx @@ -0,0 +1,116 @@ +import { useWallet } from '@txnlab/use-wallet-react' +import { useState } from 'react' +import { NavLink, Outlet } from 'react-router-dom' +import ConnectWallet from './components/ConnectWallet' +import ThemeToggle from './components/ThemeToggle' + +export default function Layout() { + const [openWalletModal, setOpenWalletModal] = useState(false) + const { activeAddress } = useWallet() + + const toggleWalletModal = () => setOpenWalletModal(!openWalletModal) + + return ( +
+ {/* Navbar */} + + + {/* Main */} +
+ +
+ + {/* Footer */} +
+
+
+
+
TokenizeRWA
+

+ A lightweight proof-of-concept template for tokenizing real-world assets on Algorand. +

+
+ +
+
Resources
+ +
+ +
+
About
+

+ A POC template for founders. For production, add compliance workflows, identity verification, and audit logs. +

+
+
+ +
+ Β© {new Date().getFullYear()} TokenizeRWA. All rights reserved. + Built with AlgoKit + Algorand +
+
+
+ + +
+ ) +} diff --git a/projects/TokenizeRWATemplate-frontend/src/TokenizePage.tsx b/projects/TokenizeRWATemplate-frontend/src/TokenizePage.tsx new file mode 100644 index 0000000..f6b4c80 --- /dev/null +++ b/projects/TokenizeRWATemplate-frontend/src/TokenizePage.tsx @@ -0,0 +1,11 @@ +import TokenizeAsset from './components/TokenizeAsset' + +export default function TokenizePage() { + return ( +
+
+ +
+
+ ) +} diff --git a/projects/TokenizeRWATemplate-frontend/src/components/ThemeToggle.tsx b/projects/TokenizeRWATemplate-frontend/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..6644433 --- /dev/null +++ b/projects/TokenizeRWATemplate-frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' + +const THEME_KEY = 'tokenize_theme' +type Theme = 'light' | 'dark' + +function getInitialTheme(): Theme { + const saved = localStorage.getItem(THEME_KEY) + if (saved === 'light' || saved === 'dark') return saved + const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches + return prefersDark ? 'dark' : 'light' +} + +export default function ThemeToggle() { + const [theme, setTheme] = useState(() => getInitialTheme()) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + // Apply theme immediately on mount to prevent flash + const t = getInitialTheme() + if (t === 'dark') { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + }, []) + + useEffect(() => { + if (!mounted) return + // Apply Tailwind dark mode + if (theme === 'dark') { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + localStorage.setItem(THEME_KEY, theme) + }, [theme, mounted]) + + return ( + + ) +} diff --git a/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx b/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx new file mode 100644 index 0000000..d1d3852 --- /dev/null +++ b/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx @@ -0,0 +1,421 @@ +import { AlgorandClient } from '@algorandfoundation/algokit-utils' +import { useWallet } from '@txnlab/use-wallet-react' +import { useSnackbar } from 'notistack' +import { useEffect, useMemo, useState } from 'react' +import { AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai' +import { BsCoin } from 'react-icons/bs' +import { getAlgodConfigFromViteEnvironment } from '../utils/network/getAlgoClientConfigs' + +type CreatedAsset = { + assetId: number + assetName: string + unitName: string + total: string + decimals: string + url?: string + manager?: string + reserve?: string + freeze?: string + clawback?: string + createdAt: string +} + +const STORAGE_KEY = 'tokenize_assets' +const LORA_BASE = 'https://lora.algokit.io/testnet' + +function loadAssets(): CreatedAsset[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as CreatedAsset[]) : [] + } catch { + return [] + } +} + +function persistAsset(asset: CreatedAsset): CreatedAsset[] { + const existing = loadAssets() + const next = [asset, ...existing] + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + return next +} + +export default function TokenizeAsset() { + 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([]) + + const { transactionSigner, activeAddress } = useWallet() + const { enqueueSnackbar } = useSnackbar() + + const algodConfig = getAlgodConfigFromViteEnvironment() + const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig]) + + useEffect(() => { + setCreatedAssets(loadAssets()) + }, []) + + useEffect(() => { + if (activeAddress && !manager) setManager(activeAddress) + }, [activeAddress, manager]) + + const resetDefaults = () => { + setAssetName('Tokenized Coffee Membership') + setUnitName('COFFEE') + setTotal('1000') + setDecimals('0') + setUrl('') + setShowAdvanced(false) + setManager(activeAddress ?? '') + setReserve('') + setFreeze('') + setClawback('') + } + + const isWholeNumber = (v: string) => /^\d+$/.test(v) + + const handleTokenize = async () => { + if (!transactionSigner || !activeAddress) { + enqueueSnackbar('Please connect your wallet first.', { variant: 'warning' }) + 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: transactionSigner, + 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: Number(assetId), + assetName: String(assetName), + unitName: String(unitName), + total: String(total), // human total as string + decimals: String(decimals), // decimals as string + 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) { + console.error(error) + enqueueSnackbar('Failed to tokenize asset (ASA creation failed).', { variant: 'error' }) + } finally { + setLoading(false) + } + } + + const canSubmit = !!assetName && !!unitName && !!total && !loading && !!activeAddress + + return ( +
+
+
+ + + +
+

Tokenize an Asset (Mint ASA)

+

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

+
+
+
+ + {loading && ( +
+
+ +
+ )} + +
+
+
+ + 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)} + /> +
+ ))} +
+ )} +
+ +
+ +
+ +
+
+

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 IDNameSymbolSupplyDecimals
+ No assets created yet. Mint one to see it here. +
{a.assetId}{a.assetName}{a.unitName}{a.total}{a.decimals}
+
+ +

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

+
+
+
+ ) +} diff --git a/projects/TokenizeRWATemplate-frontend/tailwind.config.cjs b/projects/TokenizeRWATemplate-frontend/tailwind.config.cjs index eaaba6c..24cb1a8 100644 --- a/projects/TokenizeRWATemplate-frontend/tailwind.config.cjs +++ b/projects/TokenizeRWATemplate-frontend/tailwind.config.cjs @@ -1,12 +1,9 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: ['./src/**/*.{js,ts,jsx,tsx}'], + darkMode: 'class', theme: { extend: {}, }, - daisyui: { - themes: ['lofi'], - logs: false, - }, - plugins: [require('daisyui')], + plugins: [], }