diff --git a/sample-dapps/rwa-tokenizer/.env.example b/sample-dapps/rwa-tokenizer/.env.example new file mode 100644 index 0000000..e332d48 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/.env.example @@ -0,0 +1,45 @@ +# RPC URLs +NEXT_PUBLIC_RPC_BASE_SEPOLIA=your-qn-endpoint +NEXT_PUBLIC_RPC_SEPOLIA=your-qn-endpoint + +# Private Keys (for deployment) +PRIVATE_KEY= + +# WalletConnect/Rainbow +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= + +# IPFS - Pinata +NEXT_PUBLIC_PINATA_JWT= +NEXT_PUBLIC_PINATA_GATEWAY=gateway.pinata.cloud + +# Google Maps API (for location picker) +NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= + +# Contracts (filled after deploy) +NEXT_PUBLIC_RWA721_ADDRESS_BASE_SEPOLIA= +NEXT_PUBLIC_MARKETPLACE_ADDRESS_BASE_SEPOLIA= +NEXT_PUBLIC_RWA721_ADDRESS_SEPOLIA= +NEXT_PUBLIC_MARKETPLACE_ADDRESS_SEPOLIA= + +# USDC Addresses (Testnet) +NEXT_PUBLIC_USDC_BASE_SEPOLIA=0x036CbD53842c5426634e7929541eC2318f3dCF7e +NEXT_PUBLIC_USDC_SEPOLIA=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 + +# Permit2 Address (Universal) +NEXT_PUBLIC_PERMIT2_ADDRESS=0x000000000022D473030F116dDEE9F6B43aC78BA3 + +# LayerZero V2 Endpoints +NEXT_PUBLIC_LZ_ENDPOINT_BASE_SEPOLIA=0x6EDCE65403992e310A62460808c4b910D972f10f +NEXT_PUBLIC_LZ_ENDPOINT_SEPOLIA=0x6EDCE65403992e310A62460808c4b910D972f10f + +# LayerZero V2 Endpoint IDs (not V1 chain IDs!) +NEXT_PUBLIC_LZ_CHAIN_ID_BASE_SEPOLIA=40245 +NEXT_PUBLIC_LZ_CHAIN_ID_SEPOLIA=40161 + +# CCTP Domain IDs +NEXT_PUBLIC_CCTP_DOMAIN_BASE_SEPOLIA=6 +NEXT_PUBLIC_CCTP_DOMAIN_SEPOLIA=0 + +# Etherscan API Keys (for verification) +ETHERSCAN_API_KEY= +BASESCAN_API_KEY= diff --git a/sample-dapps/rwa-tokenizer/.gitignore b/sample-dapps/rwa-tokenizer/.gitignore new file mode 100644 index 0000000..5326c64 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/.gitignore @@ -0,0 +1,29 @@ +# Environment +.env +.env.local + +# Foundry +cache/ +out/ +broadcast/ +/lib/ + +# Node +node_modules/ +.next/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/sample-dapps/rwa-tokenizer/PROJECT_STRUCTURE.md b/sample-dapps/rwa-tokenizer/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..d40bcda --- /dev/null +++ b/sample-dapps/rwa-tokenizer/PROJECT_STRUCTURE.md @@ -0,0 +1,194 @@ +# RWA Tokenizer v2 - Project Structure + +## Directory Layout + +``` +rwa-tokenizer-v2/ +├── contracts/ # Smart contracts +│ ├── interfaces/ # Contract interfaces +│ │ ├── IONFT721.sol # LayerZero ONFT v2 interface +│ │ ├── IPermit2.sol # Uniswap Permit2 interface +│ │ ├── IERC20.sol # Standard ERC20 interface +│ │ ├── ICCTPScaffold.sol # Circle CCTP scaffold +│ │ └── ILayerZeroEndpoint.sol # LayerZero endpoint interface +│ ├── libraries/ # Shared libraries +│ │ ├── Errors.sol # Custom error definitions +│ │ └── Types.sol # Shared type definitions +│ ├── Config.sol # Chain configuration constants +│ ├── RWA721ONFT.sol # Main RWA NFT contract +│ └── Marketplace.sol # USDC marketplace contract +│ +├── script/ # Deployment scripts +│ ├── DeployRWA.s.sol # Deploy RWA721ONFT +│ ├── DeployMarketplace.s.sol # Deploy Marketplace +│ └── WireONFT.s.sol # Wire cross-chain trust +│ +├── test/ # Foundry tests +│ ├── RWA.t.sol # RWA721ONFT tests +│ ├── Marketplace.t.sol # Marketplace tests +│ └── BridgeStub.t.sol # Bridge simulation tests +│ +├── foundry.toml # Foundry configuration +├── remappings.txt # Import remappings +├── .env.example # Environment variables template +├── .gitignore # Git ignore rules +├── install.sh # Dependency installation script +├── README.md # Main documentation +└── PROJECT_STRUCTURE.md # This file +``` + +## Smart Contract Overview + +### RWA721ONFT.sol +- **Purpose**: ERC-721 NFT with LayerZero ONFT v2 bridging +- **Key Features**: + - Mint RWA NFTs with IPFS metadata URIs + - Cross-chain NFT transfers via LayerZero + - Bridge origin tracking + - ERC-2981 royalty support + - Pausable and ownable +- **Dependencies**: OpenZeppelin ERC721, ERC2981, Ownable, Pausable + +### Marketplace.sol +- **Purpose**: Fixed-price NFT marketplace with USDC payments +- **Key Features**: + - Create/cancel listings + - Buy with Permit2 (gasless USDC approvals) + - Platform fee collection (250 bps default) + - Scaffolded cross-chain purchases via CCTP + - ReentrancyGuard protection +- **Dependencies**: OpenZeppelin ReentrancyGuard, Pausable, Ownable + +### Config.sol +- **Purpose**: Centralized configuration +- **Contains**: + - LayerZero endpoint addresses + - LayerZero chain IDs + - USDC token addresses (testnet) + - Permit2 address + - CCTP domain IDs + - Platform fee constants + +## Test Coverage + +### RWA.t.sol +- Minting functionality +- Token URI storage +- Bridge info tracking +- Access control (onlyOwner) +- Pause/unpause functionality +- Trusted remote configuration +- Royalty settings +- Bridge fee estimation + +### Marketplace.t.sol +- Listing creation +- Listing cancellation +- Same-chain purchases with Permit2 +- Platform fee calculations +- Access control validations +- Pause functionality +- Revert conditions + +### BridgeStub.t.sol +- Bridge send operations +- Bridge receive operations +- Token burning on send +- Token minting on receive +- Bridge info updates +- Access control (trusted remotes) +- Full cross-chain flow simulation + +## Deployment Flow + +1. **Install Dependencies** + ```bash + ./install.sh + ``` + +2. **Configure Environment** + ```bash + cp .env.example .env + # Edit .env with PRIVATE_KEY and RPC URLs + ``` + +3. **Deploy to Base Sepolia** + ```bash + forge script script/DeployRWA.s.sol --rpc-url base_sepolia --broadcast --verify + forge script script/DeployMarketplace.s.sol --rpc-url base_sepolia --broadcast --verify + ``` + +4. **Deploy to Sepolia** + ```bash + forge script script/DeployRWA.s.sol --rpc-url sepolia --broadcast --verify + forge script script/DeployMarketplace.s.sol --rpc-url sepolia --broadcast --verify + ``` + +5. **Wire ONFT Contracts** + - Update addresses in `script/WireONFT.s.sol` + - Run on both chains to establish trust + +## Key Design Decisions + +### 1. LayerZero ONFT v2 +- Chosen for mature cross-chain NFT standard +- Burn-and-mint model preserves token IDs +- Trusted remote pattern for security + +### 2. Permit2 Integration +- Gasless USDC approvals for better UX +- Industry standard (Uniswap) +- Reduces transaction friction + +### 3. CCTP Scaffolding +- Prepared for Circle's CCTP integration +- Clear TODOs for production implementation +- Allows same-chain functionality now + +### 4. Custom Errors +- Gas-efficient reverts +- Better developer experience +- Clear error messages + +### 5. Modular Design +- Separated concerns (NFT vs Marketplace) +- Reusable libraries +- Clear interfaces + +## Security Features + +- OpenZeppelin audited contracts +- ReentrancyGuard on value transfers +- Pausable for emergency stops +- Access control (Ownable) +- Checks-Effects-Interactions pattern +- Custom error types +- Comprehensive input validation + +## Next Steps + +1. **Frontend Development** + - Next.js app with wagmi/viem + - IPFS integration (web3.storage/Pinata) + - Mint studio UI + - Marketplace UI + - Bridge UI + +2. **CCTP Production Integration** + - Replace scaffold with Circle SDK + - Implement attestation fetching + - Add cross-chain settlement logic + - Test on supported chains + +3. **Advanced Features** + - Auction functionality + - Offer system + - Batch operations + - Analytics dashboard + +## Resources + +- [Foundry Book](https://book.getfoundry.sh/) +- [LayerZero Docs](https://layerzero.gitbook.io/) +- [Circle CCTP](https://developers.circle.com/stablecoins/docs/cctp-getting-started) +- [Permit2 Docs](https://github.com/Uniswap/permit2) diff --git a/sample-dapps/rwa-tokenizer/README.md b/sample-dapps/rwa-tokenizer/README.md new file mode 100644 index 0000000..985b3fb --- /dev/null +++ b/sample-dapps/rwa-tokenizer/README.md @@ -0,0 +1,434 @@ +# RWA Tokenizer v2 + +A decentralized platform for tokenizing Real World Assets (RWAs) as NFTs with cross-chain bridging and USDC-based marketplace functionality. + +## Features + +- **No-Code RWA Mint Studio**: Create ERC-721 NFTs with IPFS metadata storage +- **LayerZero ONFT v2 Bridging**: Cross-chain NFT transfers between Base and Ethereum +- **Multichain USDC Marketplace**: Fixed-price listings with Permit2 gasless approvals +- **CCTP Integration** (Scaffolded): Cross-chain USDC settlement via Circle's CCTP + +## Architecture + +### Smart Contracts + +- **RWA721ONFT.sol**: ERC-721 NFT with LayerZero ONFT v2 for cross-chain transfers +- **Marketplace.sol**: Fixed-price marketplace with USDC payments and platform fees +- **Config.sol**: Centralized chain configuration and constants +- **Libraries**: Custom errors and shared types + +### Supported Chains + +- Base Sepolia (Testnet) +- Ethereum Sepolia (Testnet) + +## Quick Start + +### Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) +- Node.js 18+ +- Private key for deployment +- [Google Maps API Key](https://developers.google.com/maps/documentation/javascript/get-api-key) (optional, for location features) + +### Installation + +```bash +# Clone the repository +git clone +cd rwa-tokenizer + +# Install Foundry dependencies +forge install OpenZeppelin/openzeppelin-contracts + +# Copy environment file +cp .env.example .env +# Edit .env and add your PRIVATE_KEY +``` + +#### Google Maps API Setup (Optional) + +For location features in the minting studio: + +1. Get a Google Maps API key from the [Google Cloud Console](https://console.cloud.google.com/) +2. Enable the following APIs: + - Maps JavaScript API + - Places API + - Geocoding API + - Maps Static API +3. Add the key to your `frontend/.env.local` file: +```bash +NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_api_key_here +``` + +**Note:** Without this key, the location picker will show a warning but the mint studio will still work. + +### Build & Test + +```bash +# Compile contracts +forge build + +# Run tests +forge test + +# Run tests with gas reporting +forge test --gas-report + +# Run tests with verbosity +forge test -vvv +``` + +### Deploy + +Complete deployment guide for fresh contract deployment. + +#### Step 1: Deploy RWA721ONFT to Both Chains + +Deploy to Ethereum Sepolia: +```bash +forge script script/DeployRWA.s.sol:DeployRWA \ + --rpc-url sepolia \ + --broadcast +``` + +**Save the deployed address** (shown as "RWA721ONFT deployed at:") + +Deploy to Base Sepolia: +```bash +forge script script/DeployRWA.s.sol:DeployRWA \ + --rpc-url base_sepolia \ + --broadcast +``` + +**Save the deployed address** + +#### Step 2: Update WireONFT Script + +Edit `script/WireONFT.s.sol` with your deployed addresses: + +```solidity +address constant BASE_SEPOLIA_RWA = 0xYourBaseSepolia Address; +address constant SEPOLIA_RWA = 0xYourSepoliaAddress; +``` + +#### Step 3: Wire ONFT Contracts (Set Trusted Remotes) + +**CRITICAL: Must be run on BOTH chains** + +Wire Base Sepolia → Sepolia: +```bash +forge script script/WireONFT.s.sol:WireONFT \ + --rpc-url base_sepolia \ + --broadcast +``` + +Wire Sepolia → Base Sepolia: +```bash +forge script script/WireONFT.s.sol:WireONFT \ + --rpc-url sepolia \ + --broadcast +``` + +#### Step 4: Deploy Marketplace Contracts + +Deploy to Base Sepolia: +```bash +forge script script/DeployMarketplace.s.sol:DeployMarketplace \ + --rpc-url base_sepolia \ + --broadcast +``` + +**Save the deployed address** + +Deploy to Ethereum Sepolia: +```bash +forge script script/DeployMarketplace.s.sol:DeployMarketplace \ + --rpc-url sepolia \ + --broadcast +``` + +**Save the deployed address** + +#### Step 5: Update Frontend Environment Variables + +Edit `frontend/.env.local` with your deployed addresses: + +```bash +# Contract Addresses (UPDATE THESE) +NEXT_PUBLIC_RWA721_ADDRESS_BASE_SEPOLIA=0xYourBaseSepolia RWA Address +NEXT_PUBLIC_MARKETPLACE_ADDRESS_BASE_SEPOLIA=0xYourBaseSepoliaMarketplaceAddress +NEXT_PUBLIC_RWA721_ADDRESS_SEPOLIA=0xYourSepoliaRWAAddress +NEXT_PUBLIC_MARKETPLACE_ADDRESS_SEPOLIA=0xYourSepoliaMarketplaceAddress + +# LayerZero V2 Configuration (DO NOT CHANGE) +NEXT_PUBLIC_LZ_CHAIN_ID_BASE_SEPOLIA=40245 +NEXT_PUBLIC_LZ_CHAIN_ID_SEPOLIA=40161 + +# Other Configuration (from .env.example) +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_project_id +NEXT_PUBLIC_PINATA_JWT=your_pinata_jwt +NEXT_PUBLIC_PINATA_GATEWAY=gateway.pinata.cloud +NEXT_PUBLIC_USDC_BASE_SEPOLIA=0x036CbD53842c5426634e7929541eC2318f3dCF7e +NEXT_PUBLIC_USDC_SEPOLIA=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 +NEXT_PUBLIC_PERMIT2_ADDRESS=0x000000000022D473030F116dDEE9F6B43aC78BA3 +``` + +#### Step 6: Verify Deployment + +Verify trusted remotes are set correctly: + +```bash +# Check Sepolia contract knows about Base Sepolia +cast call 0xYourSepoliaRWAAddress "trustedRemoteLookup(uint32)(bytes)" 40245 \ + --rpc-url sepolia + +# Check Base Sepolia contract knows about Sepolia +cast call 0xYourBaseSepoliaRWAAddress "trustedRemoteLookup(uint32)(bytes)" 40161 \ + --rpc-url base_sepolia +``` + +Both should return non-zero addresses. If they return `0x`, the wiring failed. + +#### Step 7: Test the Deployment + +1. Mint an NFT on one chain +2. Approve it for bridging +3. Bridge it to the other chain +4. Verify it appears on the destination chain + +### Complete Fresh Deployment Checklist + +- [ ] Deploy RWA721ONFT to Ethereum Sepolia +- [ ] Deploy RWA721ONFT to Base Sepolia +- [ ] Update WireONFT.s.sol with both addresses +- [ ] Wire Base Sepolia → Sepolia +- [ ] Wire Sepolia → Base Sepolia +- [ ] Verify wiring with `cast call trustedRemoteLookup` +- [ ] Deploy Marketplace to Base Sepolia +- [ ] Deploy Marketplace to Ethereum Sepolia +- [ ] Update frontend/.env.local with all 4 addresses +- [ ] Restart frontend dev server +- [ ] Test mint, bridge, and marketplace functions + +## Contract Addresses + +### Base Sepolia +- **RWA721ONFTV2**: `0xd85db7a6E816Ef8898e5790767718cA0e6438D7B` ✅ **LayerZero V2 + TokenURI Transfer** +- Marketplace: `0x5605CEf208c1BBDCE0ad9E3fDa9f6C53F64b73aE` +- USDC: `0x036CbD53842c5426634e7929541eC2318f3dCF7e` +- LayerZero V2 Endpoint: `0x6EDCE65403992e310A62460808c4b910D972f10f` +- LayerZero V2 Endpoint ID: `40245` + +### Ethereum Sepolia +- **RWA721ONFTV2**: `0xBa1361556Dd87a05b276963Df9FE3A52CaAd5f17` ✅ **LayerZero V2 + TokenURI Transfer** +- Marketplace: `0x7973Da8485Adf37D00A8aD2967d490B7A01e88F4` +- USDC: `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` +- LayerZero V2 Endpoint: `0x6EDCE65403992e310A62460808c4b910D972f10f` +- LayerZero V2 Endpoint ID: `40161` + +### Universal +- Permit2: `0x000000000022D473030F116dDEE9F6B43aC78BA3` + +**Note:** Using LayerZero V2 with `uint32` chain IDs (40245/40161), not V1 `uint16` IDs (10245/10161) + +## Usage + +### Minting an RWA NFT + +```solidity +// Owner mints NFT with IPFS metadata URI +uint256 tokenId = rwa.mint( + recipient, + "ipfs://QmYourMetadataHash" +); +``` + +### Bridging NFT Cross-Chain + +```solidity +// Approve ONFT contract +nft.approve(address(rwa), tokenId); + +// Estimate fees +(uint256 nativeFee, ) = rwa.estimateSendFee( + destinationChainId, + abi.encodePacked(recipient), + tokenId, + false, + "" +); + +// Send NFT to destination chain +rwa.sendFrom{value: nativeFee}( + msg.sender, + destinationChainId, + abi.encodePacked(recipient), + tokenId, + payable(msg.sender), + address(0), + "" +); +``` + +### Creating a Marketplace Listing + +```solidity +// Approve marketplace to transfer NFT +nft.approve(address(marketplace), tokenId); + +// Create listing (price in USDC, 6 decimals) +uint256 listingId = marketplace.createListing( + address(nft), + tokenId, + 1000 * 1e6 // 1000 USDC +); +``` + +### Buying from Marketplace + +```solidity +// Prepare Permit2 data +IPermit2.PermitTransferFrom memory permit = IPermit2.PermitTransferFrom({ + permitted: IPermit2.TokenPermissions({ + token: address(usdc), + amount: listingPrice + }), + nonce: nonce, + deadline: block.timestamp + 1 hours +}); + +// Sign permit off-chain (EIP-712) +bytes memory signature = signPermit(permit); + +// Buy NFT (gasless USDC approval) +marketplace.buy( + listingId, + msg.sender, + permit, + IPermit2.SignatureTransferDetails({ + to: address(marketplace), + requestedAmount: listingPrice + }), + signature +); +``` + +## RWA Metadata Schema + +The metadata follows ERC-721 standard with RWA-specific attributes: + +```json +{ + "name": "Property Title", + "description": "Asset description", + "image": "ipfs://QmImageHash", + "external_url": "https://example.com", + "location": { + "lat": 40.7128, + "lng": -74.0060, + "formatted_address": "New York, NY, USA", + "place_id": "ChIJOwg_06VPwokRYv534QaPC8g" + }, + "attributes": [ + { + "trait_type": "Asset Type", + "value": "RealEstate" + }, + { + "trait_type": "Valuation (USD)", + "value": "500000" + }, + { + "trait_type": "Issuance Date", + "value": "2025-01-15" + }, + { + "trait_type": "Country", + "value": "United States" + }, + { + "trait_type": "Verification Status", + "value": "Unverified" + }, + { + "trait_type": "Bridge Origin Chain", + "value": "Base" + }, + { + "trait_type": "Token Standard", + "value": "ERC-721 (ONFT)" + } + ] +} +``` + +### Location Data Format + +The `location` field is optional and optimized for minimal storage: + +- **lat** (number): Latitude coordinate +- **lng** (number): Longitude coordinate +- **formatted_address** (string): Human-readable address from Google Maps +- **place_id** (string, optional): Google Maps Place ID for reference + +The location data is powered by Google Maps API and can be set via: +- **Search**: Type an address using Google Maps Autocomplete +- **Current Location**: Use browser geolocation to automatically fetch your current position + +## Security Considerations + +- All contracts use OpenZeppelin audited libraries +- ReentrancyGuard on marketplace buy functions +- Pausable functionality for emergency stops +- Custom errors for gas-efficient reverts +- Permit2 for gasless USDC approvals +- LayerZero trusted remotes for cross-chain security + +## Testing + +Test coverage includes: +- RWA721ONFT minting, URI storage, and ownership +- Bridge send/receive with LayerZero simulation +- Marketplace listing, cancellation, and purchases +- Platform fee calculations +- Access control and pause functionality +- Revert conditions and error handling + +Run specific test file: +```bash +forge test --match-contract RWATest +forge test --match-contract MarketplaceTest +forge test --match-contract BridgeStubTest +``` + +## CCTP Integration (TODO) + +The marketplace includes scaffolded cross-chain purchase functionality. To complete: + +1. Integrate Circle's CCTP TokenMessenger contract +2. Implement attestation fetching from Circle API +3. Add cross-chain message handling for listing settlement +4. Test on CCTP-supported testnets + +See `ICCTPScaffold.sol` and `Marketplace.buyCrossChain()` for implementation notes. + +## Frontend (Coming Soon) + +Next.js application with: +- Mint studio with IPFS upload +- Asset gallery with bridge UI +- Marketplace with Permit2 integration +- WalletConnect/RainbowKit support + +## License + +MIT + +## Resources + +- [LayerZero Docs](https://layerzero.gitbook.io/) +- [Circle CCTP](https://developers.circle.com/stablecoins/docs/cctp-getting-started) +- [Uniswap Permit2](https://github.com/Uniswap/permit2) +- [OpenZeppelin Contracts](https://docs.openzeppelin.com/contracts/) diff --git a/sample-dapps/rwa-tokenizer/contracts/Config.sol b/sample-dapps/rwa-tokenizer/contracts/Config.sol new file mode 100644 index 0000000..a149407 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/Config.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/** + * @title Config + * @notice Centralized configuration constants for RWA Tokenizer + * @dev Chain-specific addresses and LayerZero configuration + */ +library Config { + // LayerZero V2 Endpoint IDs + uint32 public constant LZ_CHAIN_ID_BASE_SEPOLIA = 40245; + uint32 public constant LZ_CHAIN_ID_SEPOLIA = 40161; + + address public constant LZ_ENDPOINT_BASE_SEPOLIA = + 0x6EDCE65403992e310A62460808c4b910D972f10f; + address public constant LZ_ENDPOINT_SEPOLIA = + 0x6EDCE65403992e310A62460808c4b910D972f10f; + + address public constant USDC_BASE_SEPOLIA = 0x036CbD53842c5426634e7929541eC2318f3dCF7e; + address public constant USDC_SEPOLIA = 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238; + + address public constant PERMIT2_ADDRESS = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + + uint32 public constant CCTP_DOMAIN_BASE_SEPOLIA = 6; + uint32 public constant CCTP_DOMAIN_SEPOLIA = 0; + + uint256 public constant DEFAULT_PLATFORM_FEE_BPS = 250; + uint256 public constant MAX_PLATFORM_FEE_BPS = 1000; + uint256 public constant BPS_DENOMINATOR = 10000; +} diff --git a/sample-dapps/rwa-tokenizer/contracts/Marketplace.sol b/sample-dapps/rwa-tokenizer/contracts/Marketplace.sol new file mode 100644 index 0000000..68f4da5 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/Marketplace.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "./interfaces/IERC20.sol"; +import "./interfaces/IPermit2.sol"; +import "./interfaces/ICCTPScaffold.sol"; +import "./libraries/Errors.sol"; +import "./libraries/Types.sol"; +import "./Config.sol"; + +/** + * @title Marketplace + * @notice Fixed-price NFT marketplace with USDC payments via Permit2 + * @dev Supports same-chain purchases and scaffolded cross-chain via CCTP + */ +contract Marketplace is ReentrancyGuard, Pausable, Ownable { + IERC20 public immutable usdc; + IPermit2 public immutable permit2; + uint256 public immutable platformFeeBps; + address public immutable feeRecipient; + + uint256 private _nextListingId; + mapping(uint256 => Types.Listing) public listings; + + event Listed( + uint256 indexed listingId, + address indexed nftContract, + uint256 indexed tokenId, + address seller, + uint256 price + ); + event Canceled(uint256 indexed listingId); + event Bought( + uint256 indexed listingId, + address indexed buyer, + address indexed seller, + uint256 price, + uint256 platformFee + ); + event BoughtCrossChain( + uint256 indexed listingId, + address indexed buyer, + uint16 indexed dstChainId, + uint256 price + ); + + constructor( + address _usdc, + address _permit2, + address _feeRecipient, + uint256 _platformFeeBps + ) Ownable(msg.sender) { + if (_usdc == address(0) || _permit2 == address(0) || _feeRecipient == address(0)) { + revert Errors.InvalidAddress(); + } + if (_platformFeeBps > Config.MAX_PLATFORM_FEE_BPS) { + revert Errors.InvalidFee(); + } + + usdc = IERC20(_usdc); + permit2 = IPermit2(_permit2); + feeRecipient = _feeRecipient; + platformFeeBps = _platformFeeBps; + _nextListingId = 1; + } + + function createListing( + address nftContract, + uint256 tokenId, + uint256 price + ) external whenNotPaused returns (uint256) { + if (nftContract == address(0)) revert Errors.InvalidAddress(); + if (price == 0) revert Errors.InvalidPrice(); + + IERC721 nft = IERC721(nftContract); + if (nft.ownerOf(tokenId) != msg.sender) revert Errors.TokenNotOwned(); + if ( + nft.getApproved(tokenId) != address(this) && + !nft.isApprovedForAll(msg.sender, address(this)) + ) { + revert Errors.NotApprovedForTransfer(); + } + + uint256 listingId = _nextListingId++; + listings[listingId] = Types.Listing({ + nftContract: nftContract, + tokenId: tokenId, + seller: msg.sender, + price: price, + active: true + }); + + emit Listed(listingId, nftContract, tokenId, msg.sender, price); + return listingId; + } + + function cancelListing(uint256 listingId) external { + Types.Listing storage listing = listings[listingId]; + if (!listing.active) revert Errors.ListingNotActive(); + if (listing.seller != msg.sender) revert Errors.NotListingSeller(); + + listing.active = false; + emit Canceled(listingId); + } + + function buy( + uint256 listingId, + address recipient, + IPermit2.PermitTransferFrom calldata permit, + IPermit2.SignatureTransferDetails calldata transferDetails, + bytes calldata signature + ) external nonReentrant whenNotPaused { + Types.Listing storage listing = listings[listingId]; + if (!listing.active) revert Errors.ListingNotActive(); + if (recipient == address(0)) revert Errors.InvalidRecipient(); + + if (permit.permitted.token != address(usdc)) { + revert Errors.InvalidCurrency(); + } + if (transferDetails.requestedAmount < listing.price) { + revert Errors.InvalidAmount(); + } + + listing.active = false; + + uint256 platformFee = (listing.price * platformFeeBps) / Config.BPS_DENOMINATOR; + uint256 sellerProceeds = listing.price - platformFee; + + permit2.permitTransferFrom( + permit, + IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: listing.price + }), + msg.sender, + signature + ); + + if (platformFee > 0) { + bool feeSuccess = usdc.transfer(feeRecipient, platformFee); + if (!feeSuccess) revert Errors.TransferFailed(); + } + + bool sellerSuccess = usdc.transfer(listing.seller, sellerProceeds); + if (!sellerSuccess) revert Errors.TransferFailed(); + + IERC721(listing.nftContract).safeTransferFrom( + listing.seller, + recipient, + listing.tokenId + ); + + emit Bought(listingId, msg.sender, listing.seller, listing.price, platformFee); + } + + /** + * @notice Cross-chain purchase via CCTP (scaffold implementation) + * @dev TODO: Integrate Circle CCTP SDK for production + * + * Flow: + * 1. Buyer calls this on source chain with USDC + * 2. Contract burns USDC via CCTP TokenMessenger.depositForBurn() + * 3. Circle attestation service signs the burn + * 4. Relayer (or buyer) submits attestation to destination chain + * 5. CCTP mints USDC on destination, triggers marketplace settlement + * 6. NFT transferred to buyer on destination chain + * + * Requires: + * - CCTP TokenMessenger contract integration + * - Attestation fetching from Circle API + * - Cross-chain message passing for listing resolution + */ + function buyCrossChain( + uint256 listingId, + address recipient, + uint16 dstChainId, + IPermit2.PermitTransferFrom calldata permit, + bytes calldata signature, + address cctpTokenMessenger + ) external nonReentrant whenNotPaused { + Types.Listing storage listing = listings[listingId]; + if (!listing.active) revert Errors.ListingNotActive(); + if (recipient == address(0)) revert Errors.InvalidRecipient(); + + permit2.permitTransferFrom( + permit, + IPermit2.SignatureTransferDetails({ + to: address(this), + requestedAmount: listing.price + }), + msg.sender, + signature + ); + + listing.active = false; + + emit BoughtCrossChain(listingId, msg.sender, dstChainId, listing.price); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function getListing(uint256 listingId) + external + view + returns (Types.Listing memory) + { + return listings[listingId]; + } +} diff --git a/sample-dapps/rwa-tokenizer/contracts/RWA721ONFT.sol b/sample-dapps/rwa-tokenizer/contracts/RWA721ONFT.sol new file mode 100644 index 0000000..97b2255 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/RWA721ONFT.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@openzeppelin/contracts/token/common/ERC2981.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; +import "./interfaces/IONFT721.sol"; +import "./interfaces/ILayerZeroEndpoint.sol"; +import "./libraries/Errors.sol"; +import "./libraries/Types.sol"; + +/** + * @title RWA721ONFT + * @notice ERC-721 NFT for Real World Assets with LayerZero ONFT v2 bridging + * @dev Supports cross-chain transfers via LayerZero while preserving metadata + */ +contract RWA721ONFT is ERC721, ERC721URIStorage, ERC2981, Ownable, Pausable { + ILayerZeroEndpoint public immutable lzEndpoint; + uint32 public immutable originChainId; + + uint256 private _nextTokenId; + mapping(uint256 => Types.BridgeInfo) public bridgeInfo; + mapping(uint32 => bytes) public trustedRemoteLookup; + + event Minted(address indexed to, uint256 indexed tokenId, string uri); + event BridgeSent( + uint32 indexed dstChainId, + address indexed from, + address indexed to, + uint256 tokenId + ); + event BridgeReceived( + uint32 indexed srcChainId, + address indexed to, + uint256 tokenId + ); + event SetTrustedRemote(uint32 indexed chainId, bytes remoteAddress); + + constructor( + string memory name, + string memory symbol, + address _lzEndpoint, + uint32 _originChainId + ) ERC721(name, symbol) Ownable(msg.sender) { + if (_lzEndpoint == address(0)) revert Errors.InvalidAddress(); + lzEndpoint = ILayerZeroEndpoint(_lzEndpoint); + originChainId = _originChainId; + _nextTokenId = 1; + } + + function mint(address to, string calldata uri) + external + whenNotPaused + returns (uint256) + { + if (to == address(0)) revert Errors.InvalidAddress(); + + uint256 tokenId = _nextTokenId++; + _safeMint(to, tokenId); + _setTokenURI(tokenId, uri); + + bridgeInfo[tokenId] = Types.BridgeInfo({ + originChainId: originChainId, + isBridged: false, + bridgeCount: 0 + }); + + emit Minted(to, tokenId, uri); + return tokenId; + } + + function sendFrom( + address from, + uint32 dstChainId, + bytes calldata toAddress, + uint256 tokenId, + address payable refundAddress, + address zroPaymentAddress, + bytes calldata adapterParams + ) external payable whenNotPaused { + if (!_isAuthorized(ownerOf(tokenId), msg.sender, tokenId)) { + revert Errors.Unauthorized(); + } + if (trustedRemoteLookup[dstChainId].length == 0) { + revert Errors.InvalidChainId(); + } + + string memory uri = tokenURI(tokenId); + + _burn(tokenId); + + bridgeInfo[tokenId].isBridged = true; + bridgeInfo[tokenId].bridgeCount++; + + bytes memory payload = abi.encode(toAddress, tokenId, uri); + + lzEndpoint.send{value: msg.value}( + dstChainId, + trustedRemoteLookup[dstChainId], + payload, + refundAddress, + zroPaymentAddress, + adapterParams + ); + + emit BridgeSent(dstChainId, from, _bytesToAddress(toAddress), tokenId); + } + + function lzReceive( + uint32 srcChainId, + bytes calldata srcAddress, + uint64, + bytes calldata payload + ) external { + if (msg.sender != address(lzEndpoint)) revert Errors.Unauthorized(); + if ( + keccak256(trustedRemoteLookup[srcChainId]) != keccak256(srcAddress) + ) { + revert Errors.Unauthorized(); + } + + (bytes memory toAddressBytes, uint256 tokenId, string memory uri) = + abi.decode(payload, (bytes, uint256, string)); + address toAddress = _bytesToAddress(toAddressBytes); + + if (!_exists(tokenId)) { + _safeMint(toAddress, tokenId); + _setTokenURI(tokenId, uri); + if (bridgeInfo[tokenId].originChainId == 0) { + bridgeInfo[tokenId].originChainId = srcChainId; + } + } else { + _safeMint(toAddress, tokenId); + } + + bridgeInfo[tokenId].isBridged = true; + bridgeInfo[tokenId].bridgeCount++; + + emit BridgeReceived(srcChainId, toAddress, tokenId); + } + + function estimateSendFee( + uint32 dstChainId, + bytes calldata toAddress, + uint256 tokenId, + bool useZro, + bytes calldata adapterParams + ) external view returns (uint256 nativeFee, uint256 zroFee) { + string memory uri = ""; + if (_exists(tokenId)) { + uri = tokenURI(tokenId); + } + bytes memory payload = abi.encode(toAddress, tokenId, uri); + return lzEndpoint.estimateFees( + dstChainId, + address(this), + payload, + useZro, + adapterParams + ); + } + + function setTrustedRemote(uint32 _srcChainId, bytes calldata _srcAddress) + external + onlyOwner + { + trustedRemoteLookup[_srcChainId] = _srcAddress; + emit SetTrustedRemote(_srcChainId, _srcAddress); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function setDefaultRoyalty(address receiver, uint96 feeNumerator) + external + onlyOwner + { + _setDefaultRoyalty(receiver, feeNumerator); + } + + function _exists(uint256 tokenId) internal view returns (bool) { + return _ownerOf(tokenId) != address(0); + } + + function _bytesToAddress(bytes memory _bytes) + internal + pure + returns (address) + { + require(_bytes.length >= 20, "Invalid address bytes"); + address tempAddress; + assembly { + tempAddress := mload(add(_bytes, 20)) + } + return tempAddress; + } + + function tokenURI(uint256 tokenId) + public + view + override(ERC721, ERC721URIStorage) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC721URIStorage, ERC2981) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/sample-dapps/rwa-tokenizer/contracts/RWA721ONFTV2.sol b/sample-dapps/rwa-tokenizer/contracts/RWA721ONFTV2.sol new file mode 100644 index 0000000..560f30b --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/RWA721ONFTV2.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { ONFT721 } from "@layerzerolabs/onft-evm/contracts/onft721/ONFT721.sol"; +import { SendParam } from "@layerzerolabs/onft-evm/contracts/onft721/interfaces/IONFT721.sol"; +import { Origin } from "@layerzerolabs/lz-evm-protocol-v2/contracts/interfaces/ILayerZeroEndpointV2.sol"; +import { IOAppMsgInspector } from "@layerzerolabs/oapp-evm/contracts/oapp/interfaces/IOAppMsgInspector.sol"; +import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { ERC2981 } from "@openzeppelin/contracts/token/common/ERC2981.sol"; +import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import "./libraries/Errors.sol"; +import "./libraries/Types.sol"; + +/** + * @title RWA721ONFTV2 + * @notice ERC-721 NFT for Real World Assets with LayerZero ONFT v2 bridging + * @dev Built on LayerZero V2 ONFT721 standard with metadata preservation + */ +contract RWA721ONFTV2 is ONFT721, ERC2981, Pausable { + using Strings for uint256; + + uint32 public immutable ORIGIN_CHAIN_ID; + uint256 private _nextTokenId; + + mapping(uint256 => Types.BridgeInfo) public bridgeInfo; + mapping(uint256 => string) private _tokenURIs; + + event Minted(address indexed to, uint256 indexed tokenId, string uri); + + /** + * @dev Constructor for RWA721ONFTV2 + * @param name Name of the NFT collection + * @param symbol Symbol of the NFT collection + * @param lzEndpoint LayerZero V2 endpoint address + * @param delegate Address that can configure the OApp + * @param originChainId LayerZero chain ID where contract is deployed + */ + constructor( + string memory name, + string memory symbol, + address lzEndpoint, + address delegate, + uint32 originChainId + ) ONFT721(name, symbol, lzEndpoint, delegate) { + ORIGIN_CHAIN_ID = originChainId; + _nextTokenId = 1; + } + + /** + * @notice Mint a new RWA NFT with metadata + * @param to Recipient address + * @param uri IPFS URI for token metadata + * @return tokenId The ID of the newly minted token + */ + function mint(address to, string calldata uri) + external + whenNotPaused + returns (uint256) + { + if (to == address(0)) revert Errors.InvalidAddress(); + + uint256 tokenId = _nextTokenId++; + _safeMint(to, tokenId); + _tokenURIs[tokenId] = uri; + + bridgeInfo[tokenId] = Types.BridgeInfo({ + originChainId: ORIGIN_CHAIN_ID, + isBridged: false, + bridgeCount: 0 + }); + + emit Minted(to, tokenId, uri); + return tokenId; + } + + /** + * @dev Override _debit to burn token and track bridge info + * @param from Address sending the token + * @param tokenId Token ID being sent + * @param dstEid Destination endpoint ID + */ + function _debit( + address from, + uint256 tokenId, + uint32 dstEid + ) internal virtual override whenNotPaused { + if (from != ownerOf(tokenId)) revert Errors.Unauthorized(); + + // Bridge info is updated + bridgeInfo[tokenId].isBridged = true; + bridgeInfo[tokenId].bridgeCount++; + + // Burn token (tokenURI stays in _tokenURIs mapping for encoding) + _burn(tokenId); + } + + /** + * @dev Override _credit to mint token and restore URI from extraData + * @param to Address receiving the token + * @param tokenId Token ID being received + * @param srcEid Source endpoint ID + */ + function _credit( + address to, + uint256 tokenId, + uint32 srcEid + ) internal virtual override { + // Mint the token + _safeMint(to, tokenId); + + // Initialize bridge info if this is first time seeing this token + if (bridgeInfo[tokenId].originChainId == 0) { + bridgeInfo[tokenId].originChainId = srcEid; + } + + bridgeInfo[tokenId].isBridged = true; + bridgeInfo[tokenId].bridgeCount++; + + // Note: tokenURI is set in _lzReceive after decoding the message + } + + /** + * @dev Override to build custom message with tokenURI included + */ + function _buildMsgAndOptions( + SendParam calldata _sendParam + ) internal view virtual override returns (bytes memory message, bytes memory options) { + // Encode: to (bytes32) + tokenId (uint256) + tokenURI (string) + string memory uri = _tokenURIs[_sendParam.tokenId]; + message = abi.encode(_sendParam.to, _sendParam.tokenId, uri); + + // Use SEND message type (1) + options = combineOptions(_sendParam.dstEid, SEND, _sendParam.extraOptions); + + // Inspect if inspector is set + address inspector = msgInspector; + if (inspector != address(0)) IOAppMsgInspector(inspector).inspect(message, options); + } + + /** + * @dev Override to decode custom message with tokenURI + */ + function _lzReceive( + Origin calldata _origin, + bytes32 _guid, + bytes calldata _message, + address _executor, + bytes calldata _extraData + ) internal virtual override { + // Decode: to (bytes32) + tokenId (uint256) + tokenURI (string) + (bytes32 toBytes32, uint256 tokenId, string memory uri) = abi.decode( + _message, + (bytes32, uint256, string) + ); + + address toAddress = bytes32ToAddress(toBytes32); + + // Credit the token (mints it) + _credit(toAddress, tokenId, _origin.srcEid); + + // Set the tokenURI after minting + _tokenURIs[tokenId] = uri; + + emit ONFTReceived(_guid, _origin.srcEid, toAddress, tokenId); + } + + /** + * @dev Helper to convert bytes32 to address + */ + function bytes32ToAddress(bytes32 _b) internal pure returns (address) { + return address(uint160(uint256(_b))); + } + + /** + * @notice Get the token URI for a given token ID + * @param tokenId Token ID to query + * @return Token URI string + */ + function tokenURI(uint256 tokenId) + public + view + virtual + override + returns (string memory) + { + _requireOwned(tokenId); + + string memory _tokenURI = _tokenURIs[tokenId]; + string memory base = _baseURI(); + + // If there is no base URI, return the token URI. + if (bytes(base).length == 0) { + return _tokenURI; + } + // If both are set, concatenate the baseURI and tokenURI (via string.concat). + if (bytes(_tokenURI).length > 0) { + return string.concat(base, _tokenURI); + } + + return super.tokenURI(tokenId); + } + + /** + * @notice Pause contract operations + */ + function pause() external onlyOwner { + _pause(); + } + + /** + * @notice Unpause contract operations + */ + function unpause() external onlyOwner { + _unpause(); + } + + /** + * @notice Set default royalty for all tokens + * @param receiver Royalty recipient address + * @param feeNumerator Royalty fee in basis points + */ + function setDefaultRoyalty(address receiver, uint96 feeNumerator) + external + onlyOwner + { + _setDefaultRoyalty(receiver, feeNumerator); + } + + /** + * @dev Override supportsInterface for ERC2981 royalty standard + */ + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC721, ERC2981) + returns (bool) + { + return ERC721.supportsInterface(interfaceId) || ERC2981.supportsInterface(interfaceId); + } + + /** + * @dev Override _update to handle pausable + */ + function _update(address to, uint256 tokenId, address auth) + internal + virtual + override + whenNotPaused + returns (address) + { + return super._update(to, tokenId, auth); + } +} diff --git a/sample-dapps/rwa-tokenizer/contracts/interfaces/ICCTPScaffold.sol b/sample-dapps/rwa-tokenizer/contracts/interfaces/ICCTPScaffold.sol new file mode 100644 index 0000000..12bdb95 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/interfaces/ICCTPScaffold.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/** + * @title ICCTPScaffold + * @notice Scaffold interface for Circle's Cross-Chain Transfer Protocol + * @dev This is a simplified interface. Replace with official CCTP SDK in production. + * + * CCTP Flow (TODO - implement with Circle SDK): + * 1. Source chain: burn USDC via TokenMessenger + * 2. Receive attestation from Circle's attestation service + * 3. Destination chain: mint USDC via MessageTransmitter with attestation + * + * Official contracts: + * - TokenMessenger: handles USDC burn/mint + * - MessageTransmitter: handles cross-chain message verification + */ +interface ICCTPScaffold { + /** + * @notice Burn USDC on source chain to initiate cross-chain transfer + * @param amount USDC amount to burn (6 decimals) + * @param destinationDomain CCTP domain ID of destination chain + * @param mintRecipient Recipient address on destination chain (bytes32) + * @param burnToken USDC address on source chain + * @return nonce Message nonce for tracking + */ + function depositForBurn( + uint256 amount, + uint32 destinationDomain, + bytes32 mintRecipient, + address burnToken + ) external returns (uint64 nonce); + + /** + * @notice Receive minted USDC on destination chain + * @param message Encoded message from source chain + * @param attestation Circle attestation signature + * @return success Whether the message was successfully received + */ + function receiveMessage( + bytes calldata message, + bytes calldata attestation + ) external returns (bool success); + + event MessageSent(bytes message); + event MessageReceived( + address indexed caller, + uint32 sourceDomain, + uint64 indexed nonce, + bytes32 sender, + bytes messageBody + ); +} diff --git a/sample-dapps/rwa-tokenizer/contracts/interfaces/IERC20.sol b/sample-dapps/rwa-tokenizer/contracts/interfaces/IERC20.sol new file mode 100644 index 0000000..3268175 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/interfaces/IERC20.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/** + * @title IERC20 + * @notice Standard ERC20 interface + */ +interface IERC20 { + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); +} diff --git a/sample-dapps/rwa-tokenizer/contracts/interfaces/ILayerZeroEndpoint.sol b/sample-dapps/rwa-tokenizer/contracts/interfaces/ILayerZeroEndpoint.sol new file mode 100644 index 0000000..7e7e436 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/interfaces/ILayerZeroEndpoint.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/** + * @title ILayerZeroEndpoint + * @notice Interface for LayerZero endpoint contract + */ +interface ILayerZeroEndpoint { + function send( + uint32 _dstChainId, + bytes calldata _destination, + bytes calldata _payload, + address payable _refundAddress, + address _zroPaymentAddress, + bytes calldata _adapterParams + ) external payable; + + function estimateFees( + uint32 _dstChainId, + address _userApplication, + bytes calldata _payload, + bool _payInZRO, + bytes calldata _adapterParam + ) external view returns (uint256 nativeFee, uint256 zroFee); + + function getInboundNonce(uint32 _srcChainId, bytes calldata _srcAddress) + external + view + returns (uint64); + + function getOutboundNonce(uint32 _dstChainId, address _srcAddress) + external + view + returns (uint64); +} diff --git a/sample-dapps/rwa-tokenizer/contracts/interfaces/IONFT721.sol b/sample-dapps/rwa-tokenizer/contracts/interfaces/IONFT721.sol new file mode 100644 index 0000000..32f42a2 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/interfaces/IONFT721.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/** + * @title IONFT721 + * @notice Interface for LayerZero ONFT v2 (ERC-721) standard + * @dev Minimal adapter for cross-chain NFT bridging via LayerZero + */ +interface IONFT721 is IERC721 { + /** + * @notice Emitted when an NFT is sent to another chain + */ + event SendToChain( + uint16 indexed dstChainId, + address indexed from, + bytes indexed toAddress, + uint256 tokenId + ); + + /** + * @notice Emitted when an NFT is received from another chain + */ + event ReceiveFromChain( + uint16 indexed srcChainId, + bytes indexed srcAddress, + address indexed toAddress, + uint256 tokenId + ); + + /** + * @notice Send an NFT to another chain via LayerZero + * @param dstChainId LayerZero chain ID of destination + * @param from Address sending the NFT + * @param tokenId ID of the token to send + * @param refundAddress Address to refund excess gas fees + * @param zroPaymentAddress ZRO token payment address (can be zero) + * @param adapterParams Custom adapter parameters for LayerZero + */ + function sendFrom( + address from, + uint16 dstChainId, + bytes calldata toAddress, + uint256 tokenId, + address payable refundAddress, + address zroPaymentAddress, + bytes calldata adapterParams + ) external payable; + + /** + * @notice Estimate gas fees for sending NFT cross-chain + * @param dstChainId Destination LayerZero chain ID + * @param toAddress Recipient address on destination chain + * @param tokenId Token ID to send + * @param useZro Whether to pay in ZRO token + * @param adapterParams Custom adapter parameters + * @return nativeFee Native token fee amount + * @return zroFee ZRO token fee amount + */ + function estimateSendFee( + uint16 dstChainId, + bytes calldata toAddress, + uint256 tokenId, + bool useZro, + bytes calldata adapterParams + ) external view returns (uint256 nativeFee, uint256 zroFee); +} diff --git a/sample-dapps/rwa-tokenizer/contracts/interfaces/IPermit2.sol b/sample-dapps/rwa-tokenizer/contracts/interfaces/IPermit2.sol new file mode 100644 index 0000000..82ab3d9 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/interfaces/IPermit2.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/** + * @title IPermit2 + * @notice Interface for Uniswap Permit2 contract + * @dev Enables gasless token approvals and transfers + */ +interface IPermit2 { + struct TokenPermissions { + address token; + uint256 amount; + } + + struct PermitTransferFrom { + TokenPermissions permitted; + uint256 nonce; + uint256 deadline; + } + + struct SignatureTransferDetails { + address to; + uint256 requestedAmount; + } + + /** + * @notice Transfer tokens using a signed permit + * @param permit Permit data structure + * @param transferDetails Transfer recipient and amount + * @param owner Token owner who signed the permit + * @param signature EIP-712 signature + */ + function permitTransferFrom( + PermitTransferFrom calldata permit, + SignatureTransferDetails calldata transferDetails, + address owner, + bytes calldata signature + ) external; + + struct PermitSingle { + PermitDetails details; + address spender; + uint256 sigDeadline; + } + + struct PermitDetails { + address token; + uint160 amount; + uint48 expiration; + uint48 nonce; + } + + /** + * @notice Approve a spender using a signed permit + * @param owner Token owner + * @param permitSingle Permit data + * @param signature EIP-712 signature + */ + function permit( + address owner, + PermitSingle calldata permitSingle, + bytes calldata signature + ) external; +} diff --git a/sample-dapps/rwa-tokenizer/contracts/libraries/Errors.sol b/sample-dapps/rwa-tokenizer/contracts/libraries/Errors.sol new file mode 100644 index 0000000..bed05a7 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/libraries/Errors.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/** + * @title Errors + * @notice Custom error definitions for gas-efficient reverts + */ +library Errors { + error Unauthorized(); + error InvalidAddress(); + error InvalidAmount(); + error InvalidTokenId(); + error InvalidChainId(); + error TokenNotFound(); + error TokenAlreadyExists(); + error TokenNotOwned(); + error InsufficientBalance(); + error InsufficientAllowance(); + error TransferFailed(); + error InvalidCurrency(); + error InvalidPrice(); + error ListingNotActive(); + error ListingNotFound(); + error NotListingSeller(); + error AlreadyListed(); + error NotApprovedForTransfer(); + error Paused(); + error BridgeInProgress(); + error InvalidRecipient(); + error InvalidFee(); + error InvalidSignature(); + error ExpiredSignature(); + error InvalidPermit(); +} diff --git a/sample-dapps/rwa-tokenizer/contracts/libraries/Types.sol b/sample-dapps/rwa-tokenizer/contracts/libraries/Types.sol new file mode 100644 index 0000000..eabd02c --- /dev/null +++ b/sample-dapps/rwa-tokenizer/contracts/libraries/Types.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/** + * @title Types + * @notice Shared type definitions for RWA Tokenizer contracts + */ +library Types { + struct Listing { + address nftContract; + uint256 tokenId; + address seller; + uint256 price; + bool active; + } + + struct BridgeInfo { + uint32 originChainId; + bool isBridged; + uint256 bridgeCount; + } + + enum AssetCategory { + RealEstate, + Art, + Vehicle, + Commodity, + Other + } +} diff --git a/sample-dapps/rwa-tokenizer/foundry.toml b/sample-dapps/rwa-tokenizer/foundry.toml new file mode 100644 index 0000000..c8e8ca3 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/foundry.toml @@ -0,0 +1,35 @@ +[profile.default] +src = "contracts" +out = "out" +libs = ["lib"] +solc_version = "0.8.23" +optimizer = true +optimizer_runs = 200 +via_ir = false +remappings = [ + "@layerzerolabs/oapp-evm/=lib/LayerZero-v2/packages/layerzero-v2/evm/oapp/", + "@layerzerolabs/onft-evm/=lib/devtools/packages/onft-evm/", + "@layerzerolabs/lz-evm-protocol-v2/=lib/LayerZero-v2/packages/layerzero-v2/evm/protocol/", + "@layerzerolabs/lz-evm-messagelib-v2/=lib/LayerZero-v2/packages/layerzero-v2/evm/messagelib/", + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", + "solidity-bytes-utils/=lib/solidity-bytes-utils/" +] + +# Test configuration +fs_permissions = [{ access = "read-write", path = "./" }] +gas_reports = ["*"] + +# Formatting +line_length = 100 +tab_width = 4 +bracket_spacing = true + +# RPC endpoints +[rpc_endpoints] +sepolia = "${NEXT_PUBLIC_RPC_SEPOLIA}" +base_sepolia = "${NEXT_PUBLIC_RPC_BASE_SEPOLIA}" + +# Etherscan configuration +[etherscan] +sepolia = { key = "${ETHERSCAN_API_KEY}" } +base_sepolia = { key = "${BASESCAN_API_KEY}" } diff --git a/sample-dapps/rwa-tokenizer/frontend/.gitignore b/sample-dapps/rwa-tokenizer/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/sample-dapps/rwa-tokenizer/frontend/README.md b/sample-dapps/rwa-tokenizer/frontend/README.md new file mode 100644 index 0000000..5accef1 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/frontend/README.md @@ -0,0 +1,42 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +Update Environment variables: +Copy the + + + + +Now, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/sample-dapps/rwa-tokenizer/frontend/app/assets/page.tsx b/sample-dapps/rwa-tokenizer/frontend/app/assets/page.tsx new file mode 100644 index 0000000..2b6e6b2 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/frontend/app/assets/page.tsx @@ -0,0 +1,516 @@ +'use client' + +import { useState, useEffect } from 'react' +import { motion } from 'framer-motion' +import { useAccount, useReadContract, usePublicClient } from 'wagmi' +import { Navigation } from '@/components/navigation' +import { OutrunBackground } from '@/components/outrun-background' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { NFTCardSkeleton } from '@/components/nft-card-skeleton' +import { NFTCardLoading } from '@/components/nft-card-loading' +import { NFTDetailModal } from '@/components/nft-detail-modal' +import { BridgeButton } from '@/components/bridge-button' +import { BridgeNFTDialog } from '@/components/bridge-nft-dialog' +import { getContractAddress } from '@/config/addresses' +import { RWA721_ABI } from '@/lib/abi/rwa721' +import { getIPFSGatewayUrl, fetchFromIPFS, NFTMetadata } from '@/lib/ipfs' +import { TokenCache, initializeCache } from '@/lib/cache' +import { staggerContainer, staggerItem, fadeInUp, hoverLift } from '@/lib/animations' + +interface TokenData { + tokenId: bigint + metadata?: NFTMetadata + tokenURI?: string + isLoading: boolean +} + +export default function AssetsPage() { + const { address, chainId, isConnected } = useAccount() + const publicClient = usePublicClient() + const [tokens, setTokens] = useState([]) + const [isLoadingTokens, setIsLoadingTokens] = useState(false) + const [selectedToken, setSelectedToken] = useState(null) + const [bridgeDialogOpen, setBridgeDialogOpen] = useState(false) + const [bridgeTokenId, setBridgeTokenId] = useState(null) + const [bridgeTokenName, setBridgeTokenName] = useState(undefined) + + const contractAddress = chainId ? getContractAddress(chainId, 'rwa721') : undefined + + // Initialize cache on mount + useEffect(() => { + initializeCache() + }, []) + + const { data: balance } = useReadContract({ + address: contractAddress, + abi: RWA721_ABI, + functionName: 'balanceOf', + args: address ? [address] : undefined, + query: { + enabled: !!address && !!contractAddress, + }, + }) + + useEffect(() => { + if (!address || !contractAddress || !publicClient || !chainId) { + console.log('[Assets] Missing required data:', { address, contractAddress, chainId }) + setTokens([]) // Clear tokens when disconnected or chain not ready + return + } + + const loadTokens = async () => { + console.log('[Assets] Starting to load tokens for address:', address) + console.log('[Assets] Chain ID:', chainId) + // Clear existing tokens immediately when loading starts (prevents showing wrong chain's tokens) + setTokens([]) + setIsLoadingTokens(true) + + // Track loaded token IDs to prevent duplicates + const loadedTokenIds = new Set() + + // Check cache first - but still show loading animation briefly + const cachedTokens = TokenCache.get(chainId, address) + if (cachedTokens && cachedTokens.length > 0) { + console.log('[Assets] Loading tokens from cache:', cachedTokens.length) + + // First show skeleton card for 800ms + await new Promise(resolve => setTimeout(resolve, 800)) + + // Then show actual cards directly (skip individual loading for cached tokens) + const tokenData: TokenData[] = cachedTokens.map(ct => { + loadedTokenIds.add(ct.tokenId) + return { + tokenId: BigInt(ct.tokenId), + metadata: ct.metadata, + tokenURI: ct.tokenURI, + isLoading: false, // Show actual cards immediately for cached tokens + } + }) + setTokens(tokenData) + setIsLoadingTokens(false) + + // Still fetch in background to check for updates + console.log('[Assets] Cache loaded, checking for updates in background...') + } else { + setTokens([]) // Clear existing tokens to prevent flash + } + + try { + const currentBlock = await publicClient.getBlockNumber() + console.log('[Assets] Current block:', currentBlock) + + // Use 5000 blocks to stay well under RPC limits (many providers limit to 10k) + const blockRange = BigInt(5000) + const fromBlock = currentBlock > blockRange ? currentBlock - blockRange : BigInt(0) + console.log('[Assets] Searching from block:', fromBlock, 'to', currentBlock) + + console.log('[Assets] Fetching Minted events...') + let mintedEvents: Awaited> = [] + let transferToEvents: Awaited> = [] + let transferFromEvents: Awaited> = [] + + try { + mintedEvents = await publicClient.getLogs({ + address: contractAddress, + event: { + type: 'event', + name: 'Minted', + inputs: [ + { name: 'to', type: 'address', indexed: true }, + { name: 'tokenId', type: 'uint256', indexed: true }, + { name: 'uri', type: 'string', indexed: false }, + ], + }, + args: { + to: address, + }, + fromBlock, + toBlock: 'latest', + }) + console.log('[Assets] Minted events found:', mintedEvents.length, mintedEvents) + } catch (error) { + console.warn('[Assets] Error fetching Minted events, continuing...', error) + } + + console.log('[Assets] Fetching Transfer (to) events...') + try { + transferToEvents = await publicClient.getLogs({ + address: contractAddress, + event: { + type: 'event', + name: 'Transfer', + inputs: [ + { name: 'from', type: 'address', indexed: true }, + { name: 'to', type: 'address', indexed: true }, + { name: 'tokenId', type: 'uint256', indexed: true }, + ], + }, + args: { + to: address, + }, + fromBlock, + toBlock: 'latest', + }) + console.log('[Assets] Transfer (to) events found:', transferToEvents.length) + } catch (error) { + console.warn('[Assets] Error fetching Transfer (to) events, continuing...', error) + } + + console.log('[Assets] Fetching Transfer (from) events...') + try { + transferFromEvents = await publicClient.getLogs({ + address: contractAddress, + event: { + type: 'event', + name: 'Transfer', + inputs: [ + { name: 'from', type: 'address', indexed: true }, + { name: 'to', type: 'address', indexed: true }, + { name: 'tokenId', type: 'uint256', indexed: true }, + ], + }, + args: { + from: address, + }, + fromBlock, + toBlock: 'latest', + }) + console.log('[Assets] Transfer (from) events found:', transferFromEvents.length) + } catch (error) { + console.warn('[Assets] Error fetching Transfer (from) events, continuing...', error) + } + + // Instead of trying to track ownership through events, collect ALL token IDs + // that ever interacted with this address, then verify ownership with ownerOf + const allTokenIds = new Set() + + mintedEvents.forEach(event => { + const tokenId = (event as { args?: { tokenId?: bigint } }).args?.tokenId + if (tokenId) { + allTokenIds.add(tokenId.toString()) + console.log('[Assets] Added token from mint:', tokenId.toString()) + } + }) + + transferToEvents.forEach(event => { + const tokenId = (event as { args?: { tokenId?: bigint } }).args?.tokenId + if (tokenId) { + allTokenIds.add(tokenId.toString()) + console.log('[Assets] Added token from transfer (to):', tokenId.toString()) + } + }) + + // Also include tokens transferred FROM user (they might have been bridged back) + transferFromEvents.forEach(event => { + const tokenId = (event as { args?: { tokenId?: bigint } }).args?.tokenId + if (tokenId) { + allTokenIds.add(tokenId.toString()) + console.log('[Assets] Added token from transfer (from) for verification:', tokenId.toString()) + } + }) + + console.log('[Assets] Total unique token IDs to verify:', allTokenIds.size, Array.from(allTokenIds)) + + // Verify ownership and load tokens one by one, only adding verified ones to state + for (const tokenIdStr of Array.from(allTokenIds)) { + // Skip if already loaded from cache + if (loadedTokenIds.has(tokenIdStr)) { + console.log(`[Assets] Token ${tokenIdStr} already loaded from cache, skipping`) + continue + } + + const tokenId = BigInt(tokenIdStr) + try { + console.log(`[Assets] Verifying token ${tokenId}...`) + + const currentOwner = await publicClient.readContract({ + address: contractAddress, + abi: RWA721_ABI, + functionName: 'ownerOf', + args: [tokenId], + }) + console.log(`[Assets] Token ${tokenId} owner:`, currentOwner) + + if (currentOwner.toLowerCase() !== address.toLowerCase()) { + console.log(`[Assets] Token ${tokenId} not owned by user, skipping`) + continue + } + + // Token is owned by user, add it to state in loading state + console.log(`[Assets] Token ${tokenId} verified, adding to state`) + loadedTokenIds.add(tokenIdStr) + setTokens(prev => [...prev, { tokenId, isLoading: true }]) + + // Fetch tokenURI and metadata + console.log(`[Assets] Fetching tokenURI for ${tokenId}...`) + const uri = await publicClient.readContract({ + address: contractAddress, + abi: RWA721_ABI, + functionName: 'tokenURI', + args: [tokenId], + }) + console.log(`[Assets] Token ${tokenId} URI:`, uri) + + if (uri) { + console.log(`[Assets] Fetching metadata for token ${tokenId}`) + + // Start timing for minimum display duration + const startTime = Date.now() + const metadata = await fetchFromIPFS(uri as string) + + // Ensure loading animation displays for at least 1 second for better UX + const elapsedTime = Date.now() - startTime + const minDisplayTime = 1000 // 1 second + if (elapsedTime < minDisplayTime) { + await new Promise(resolve => setTimeout(resolve, minDisplayTime - elapsedTime)) + } + + setTokens(prev => + prev.map(t => + t.tokenId === tokenId + ? { ...t, metadata, tokenURI: uri as string, isLoading: false } + : t + ) + ) + console.log(`[Assets] Token ${tokenId} loaded successfully`) + + // Cache the token with metadata + TokenCache.addToken(chainId, address, { + tokenId: tokenId.toString(), + owner: address, + tokenURI: uri as string, + metadata, + lastUpdated: Date.now(), + }) + } + } catch (error) { + // Check if this is the expected "token doesn't exist" error (bridged away) + const errorMessage = error instanceof Error ? error.message : String(error) + if (errorMessage.includes('0x7e273289') || errorMessage.includes('ERC721NonexistentToken')) { + console.log(`[Assets] Token ${tokenId} does not exist on this chain (likely bridged away), skipping`) + } else { + // Unexpected error - log full details + console.error(`[Assets] Unexpected error loading token ${tokenId}:`, error) + } + // Don't add to state at all + } + } + console.log('[Assets] Finished loading all tokens') + } catch (error) { + console.error('[Assets] Error loading tokens:', error) + console.error('[Assets] Error details:', { + message: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }) + } finally { + console.log('[Assets] Setting isLoadingTokens to false') + setIsLoadingTokens(false) + } + } + + console.log('[Assets] Calling loadTokens()') + loadTokens() + }, [address, contractAddress, publicClient, chainId]) + + return ( + <> + + +
+ +

+ My RWA Assets +

+

+ {isConnected + ? `Viewing assets on ${chainId === 84532 ? 'Base Sepolia' : 'Ethereum Sepolia'}` + : 'Connect your wallet to view your assets'} +

+ {balance !== undefined && ( + + + Total balance: {balance.toString()} token{balance === BigInt(1) ? '' : 's'} + + )} + {isConnected && ( +

+ Showing assets from the last ~5,000 blocks. Switch chains to see assets on other networks. +

+ )} +
+ + {!isConnected && ( + + + +

+ Please connect your wallet to view your assets +

+
+
+
+ )} + + {isConnected && isLoadingTokens && tokens.length === 0 && ( + + + + )} + + {isConnected && !isLoadingTokens && tokens.length === 0 && ( + + + +

+ No assets found. Mint your first RWA token! +

+
+
+
+ )} + + + {tokens.map((token) => ( + + {token.isLoading ? ( + + ) : ( + setSelectedToken(token)} + > + + {token.metadata ? ( + <> + {/* Image */} + {token.metadata.image && ( + + {token.metadata.name} +
+ + {/* Overlay info */} +
+

+ {token.metadata.name || `Token #${token.tokenId.toString()}`} +

+

+ Token ID: {token.tokenId.toString()} +

+
+ + )} + + {/* Quick info */} +
+

+ {token.metadata.description} +

+ + {/* Attributes count */} + {token.metadata.attributes && token.metadata.attributes.length > 0 && ( +
+ + {token.metadata.attributes.length} attribute{token.metadata.attributes.length !== 1 ? 's' : ''} +
+ )} + + {/* Bridge button */} + { + e?.stopPropagation() + setBridgeTokenId(token.tokenId) + setBridgeTokenName(token.metadata?.name) + setBridgeDialogOpen(true) + }} + size="lg" + className="w-full" + /> +
+ + ) : ( +
+

Failed to load metadata

+
+ )} + + + )} + + ))} + + + {/* NFT Detail Modal */} + {selectedToken && ( + !open && setSelectedToken(null)} + tokenId={selectedToken.tokenId} + metadata={selectedToken.metadata} + tokenURI={selectedToken.tokenURI} + onBridge={() => { + setBridgeTokenId(selectedToken.tokenId) + setBridgeTokenName(selectedToken.metadata?.name) + setSelectedToken(null) + setBridgeDialogOpen(true) + }} + /> + )} + + {/* Bridge Dialog */} + {bridgeTokenId && ( + + )} +
+ + ) +} diff --git a/sample-dapps/rwa-tokenizer/frontend/app/favicon.ico b/sample-dapps/rwa-tokenizer/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/sample-dapps/rwa-tokenizer/frontend/app/favicon.ico differ diff --git a/sample-dapps/rwa-tokenizer/frontend/app/globals.css b/sample-dapps/rwa-tokenizer/frontend/app/globals.css new file mode 100644 index 0000000..cc7679b --- /dev/null +++ b/sample-dapps/rwa-tokenizer/frontend/app/globals.css @@ -0,0 +1,292 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: var(--font-inter); + --font-mono: var(--font-jetbrains-mono); + --font-heading: var(--font-space-grotesk); + + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.75rem; + + /* Synthwave Color System */ + --neon-pink: 330 100% 50%; + --neon-cyan: 187 100% 50%; + --neon-purple: 270 100% 50%; + --neon-orange: 16 100% 50%; + + /* Base dark theme - will be used by default */ + --background: 260 80% 3%; + --foreground: 0 0% 100%; + --card: 264 45% 12%; + --card-foreground: 0 0% 100%; + --popover: 264 45% 12%; + --popover-foreground: 0 0% 100%; + --primary: 330 100% 50%; + --primary-foreground: 0 0% 100%; + --secondary: 187 100% 50%; + --secondary-foreground: 260 80% 3%; + --muted: 264 45% 20%; + --muted-foreground: 270 20% 75%; + --accent: 270 100% 50%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --border: 270 60% 30%; + --input: 270 60% 30%; + --ring: 330 100% 50%; + + --chart-1: 330 100% 50%; + --chart-2: 187 100% 50%; + --chart-3: 270 100% 50%; + --chart-4: 16 100% 50%; + --chart-5: 50 100% 50%; + + --sidebar: 264 45% 12%; + --sidebar-foreground: 0 0% 100%; + --sidebar-primary: 330 100% 50%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 264 45% 20%; + --sidebar-accent-foreground: 0 0% 100%; + --sidebar-border: 270 60% 30%; + --sidebar-ring: 330 100% 50%; +} + +/* Dark mode is default - keep same values for consistency */ +.dark { + --background: 260 80% 3%; + --foreground: 0 0% 100%; + --card: 264 45% 12%; + --card-foreground: 0 0% 100%; + --popover: 264 45% 12%; + --popover-foreground: 0 0% 100%; + --primary: 330 100% 50%; + --primary-foreground: 0 0% 100%; + --secondary: 187 100% 50%; + --secondary-foreground: 260 80% 3%; + --muted: 264 45% 20%; + --muted-foreground: 270 20% 75%; + --accent: 270 100% 50%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --border: 270 60% 30%; + --input: 270 60% 30%; + --ring: 330 100% 50%; + + --chart-1: 330 100% 50%; + --chart-2: 187 100% 50%; + --chart-3: 270 100% 50%; + --chart-4: 16 100% 50%; + --chart-5: 50 100% 50%; + + --sidebar: 264 45% 12%; + --sidebar-foreground: 0 0% 100%; + --sidebar-primary: 330 100% 50%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 264 45% 20%; + --sidebar-accent-foreground: 0 0% 100%; + --sidebar-border: 270 60% 30%; + --sidebar-ring: 330 100% 50%; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +@layer utilities { + /* Neon glow effects */ + .glow-pink { + box-shadow: 0 0 20px rgba(255, 0, 110, 0.3), + 0 0 40px rgba(255, 0, 110, 0.2), + 0 0 60px rgba(255, 0, 110, 0.1); + } + + .glow-cyan { + box-shadow: 0 0 20px rgba(0, 245, 255, 0.3), + 0 0 40px rgba(0, 245, 255, 0.2), + 0 0 60px rgba(0, 245, 255, 0.1); + } + + .glow-purple { + box-shadow: 0 0 20px rgba(139, 0, 255, 0.3), + 0 0 40px rgba(139, 0, 255, 0.2), + 0 0 60px rgba(139, 0, 255, 0.1); + } + + .glow-orange { + box-shadow: 0 0 20px rgba(255, 69, 0, 0.3), + 0 0 40px rgba(255, 69, 0, 0.2), + 0 0 60px rgba(255, 69, 0, 0.1); + } + + .glow-text-pink { + text-shadow: 0 0 10px rgba(255, 0, 110, 0.8), + 0 0 20px rgba(255, 0, 110, 0.6), + 0 0 30px rgba(255, 0, 110, 0.4); + } + + .glow-text-cyan { + text-shadow: 0 0 10px rgba(0, 245, 255, 0.8), + 0 0 20px rgba(0, 245, 255, 0.6), + 0 0 30px rgba(0, 245, 255, 0.4); + } + + /* Gradient backgrounds */ + .bg-synthwave-sunset { + background: linear-gradient(180deg, #FF006E 0%, #FF4500 50%, #FFD700 100%); + } + + .bg-synthwave-sky { + background: linear-gradient(180deg, #1A0B2E 0%, #3B0F70 50%, #8B00FF 100%); + } + + .bg-synthwave-neon { + background: linear-gradient(135deg, #00F5FF 0%, #8B00FF 50%, #FF006E 100%); + } + + /* Grid background */ + .bg-grid { + background-image: + linear-gradient(rgba(139, 0, 255, 0.15) 1px, transparent 1px), + linear-gradient(90deg, rgba(139, 0, 255, 0.15) 1px, transparent 1px); + background-size: 50px 50px; + } + + .bg-grid-large { + background-image: + linear-gradient(rgba(139, 0, 255, 0.15) 2px, transparent 2px), + linear-gradient(90deg, rgba(139, 0, 255, 0.15) 2px, transparent 2px); + background-size: 100px 100px; + } + + /* Border gradients */ + .border-gradient-pink-cyan { + border: 2px solid transparent; + background: linear-gradient(var(--background), var(--background)) padding-box, + linear-gradient(135deg, #FF006E, #00F5FF) border-box; + } + + .border-gradient-purple-pink { + border: 2px solid transparent; + background: linear-gradient(var(--background), var(--background)) padding-box, + linear-gradient(135deg, #8B00FF, #FF006E) border-box; + } +} + +/* Keyframe animations */ +@keyframes glow-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-20px); + } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +@keyframes grid-flow { + 0% { + background-position: 0 0; + } + 100% { + background-position: 50px 50px; + } +} + +@keyframes gradient-shift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +@keyframes pulse-glow { + 0%, 100% { + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 0 20px rgba(255, 0, 110, 0.2); + } + 33% { + border-color: hsl(var(--secondary) / 0.3); + box-shadow: 0 0 20px rgba(0, 245, 255, 0.2); + } + 66% { + border-color: hsl(var(--accent) / 0.3); + box-shadow: 0 0 20px rgba(139, 0, 255, 0.2); + } +} + +/* Animation utilities */ +.animate-glow-pulse { + animation: glow-pulse 2s ease-in-out infinite; +} + +.animate-pulse-glow { + animation: pulse-glow 3s ease-in-out infinite; +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-shimmer { + animation: shimmer 2s linear infinite; +} + +.animate-grid-flow { + animation: grid-flow 20s linear infinite; +} + +.animate-gradient-shift { + background-size: 200% 200%; + animation: gradient-shift 3s ease infinite; +} diff --git a/sample-dapps/rwa-tokenizer/frontend/app/layout.tsx b/sample-dapps/rwa-tokenizer/frontend/app/layout.tsx new file mode 100644 index 0000000..949aa51 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/frontend/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Space_Grotesk, Inter, JetBrains_Mono } from "next/font/google"; +import "./globals.css"; +import { Providers } from "@/components/providers"; +import { Toaster } from "@/components/ui/toaster"; + +const spaceGrotesk = Space_Grotesk({ + variable: "--font-space-grotesk", + subsets: ["latin"], + weight: ["300", "400", "500", "600", "700"], +}); + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], +}); + +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "RWA Tokenizer | Tokenize Real World Assets", + description: "Mint, bridge, and trade tokenized real world assets across chains", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/sample-dapps/rwa-tokenizer/frontend/app/mint/page.tsx b/sample-dapps/rwa-tokenizer/frontend/app/mint/page.tsx new file mode 100644 index 0000000..9261614 --- /dev/null +++ b/sample-dapps/rwa-tokenizer/frontend/app/mint/page.tsx @@ -0,0 +1,815 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { uploadImageToIPFS, uploadMetadataToIPFS, NFTAttribute, NFTMetadata, NFTLocation } from '@/lib/ipfs' +import { getContractAddress } from '@/config/addresses' +import { RWA721_ABI } from '@/lib/abi/rwa721' +import { Navigation } from '@/components/navigation' +import { LocationPicker } from '@/components/location-picker' +import { OutrunBackground } from '@/components/outrun-background' +import { toast } from 'sonner' +import Link from 'next/link' + +export default function MintPage() { + const { address, chainId, isConnected } = useAccount() + const { writeContract, data: hash, isPending } = useWriteContract() + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }) + + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [imageFile, setImageFile] = useState(null) + const [imagePreview, setImagePreview] = useState(null) + const [attributes, setAttributes] = useState([]) + const [newAttrType, setNewAttrType] = useState('') + const [newAttrValue, setNewAttrValue] = useState('') + const [location, setLocation] = useState(null) + const [error, setError] = useState(null) + const [isUploading, setIsUploading] = useState(false) + + // Helper to get block explorer URL + const getBlockExplorerUrl = (txHash: string, chainId: number) => { + if (chainId === 84532) { + return `https://sepolia.basescan.org/tx/${txHash}` + } else if (chainId === 11155111) { + return `https://sepolia.etherscan.io/tx/${txHash}` + } + return '' + } + + // Handle transaction state changes with toasts + useEffect(() => { + if (hash && chainId) { + const explorerUrl = getBlockExplorerUrl(hash, chainId) + toast.success('Transaction Sent!', { + description: ( + + View on Explorer → + + ), + duration: 5000, + }) + } + }, [hash, chainId]) + + useEffect(() => { + if (isSuccess && hash) { + toast.success('NFT Minted Successfully!', { + description: ( +
+ Your RWA token has been minted! + + View in My Assets → + +
+ ), + duration: 10000, + }) + // Clear form after successful mint + resetForm() + } + }, [isSuccess, hash]) + + // Standard RWA attributes + const [category, setCategory] = useState('') // RealEstate, Art, Vehicle, Commodity + const [externalUrl, setExternalUrl] = useState('') + const [valuation, setValuation] = useState('') + const [issuanceDate, setIssuanceDate] = useState(() => { + // Auto-fill with today's date in YYYY-MM-DD format + const today = new Date() + return today.toISOString().split('T')[0] + }) + + // Category-specific attributes - Real Estate + const [squareFootage, setSquareFootage] = useState('') + const [propertyType, setPropertyType] = useState('') + const [bedrooms, setBedrooms] = useState('') + const [bathrooms, setBathrooms] = useState('') + const [yearBuilt, setYearBuilt] = useState('') + + // Category-specific attributes - Art + const [artist, setArtist] = useState('') + const [medium, setMedium] = useState('') + const [yearCreated, setYearCreated] = useState('') + const [style, setStyle] = useState('') + const [dimensions, setDimensions] = useState('') + + // Category-specific attributes - Vehicle + const [make, setMake] = useState('') + const [model, setModel] = useState('') + const [year, setYear] = useState('') + const [vin, setVin] = useState('') + const [mileage, setMileage] = useState('') + + // Category-specific attributes - Commodity + const [weight, setWeight] = useState('') + const [purity, setPurity] = useState('') + const [origin, setOrigin] = useState('') + const [certification, setCertification] = useState('') + + // Category-specific attributes - Collectibles + const [condition, setCondition] = useState('') + const [grader, setGrader] = useState('') + const [collectibleCategory, setCollectibleCategory] = useState('') + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + setImageFile(file) + const reader = new FileReader() + reader.onloadend = () => { + setImagePreview(reader.result as string) + } + reader.readAsDataURL(file) + } + } + + const addAttribute = () => { + if (newAttrType && newAttrValue) { + setAttributes([...attributes, { trait_type: newAttrType, value: newAttrValue }]) + setNewAttrType('') + setNewAttrValue('') + } + } + + const removeAttribute = (index: number) => { + setAttributes(attributes.filter((_, i) => i !== index)) + } + + const handleMint = async () => { + console.log('[Mint] Starting mint process...') + + if (!isConnected || !chainId || !address) { + console.error('[Mint] Wallet not connected') + setError('Please connect your wallet') + return + } + + if (!name || !description || !imageFile || !category || !issuanceDate) { + console.error('[Mint] Missing required fields') + setError('Please fill in all required fields (name, description, image, category, and issuance date)') + return + } + + console.log('[Mint] Minting on chain:', chainId) + console.log('[Mint] NFT details:', { name, description, attributesCount: attributes.length }) + + try { + setIsUploading(true) + setError(null) + + console.log('[Mint] Uploading image to IPFS...', { fileName: imageFile.name, fileSize: imageFile.size }) + const imageUri = await uploadImageToIPFS(imageFile) + console.log('[Mint] Image uploaded successfully:', imageUri) + + // Build standard attributes + const standardAttributes: NFTAttribute[] = [] + + standardAttributes.push({ trait_type: 'Asset Type', value: category }) + + if (valuation) { + standardAttributes.push({ trait_type: 'Valuation (USD)', value: valuation }) + } + + standardAttributes.push({ trait_type: 'Issuance Date', value: issuanceDate }) + + // Extract country from location if available + if (location?.formatted_address) { + // Try to extract country from formatted address (usually last part after last comma) + const addressParts = location.formatted_address.split(',').map(p => p.trim()) + const country = addressParts[addressParts.length - 1] + standardAttributes.push({ trait_type: 'Country', value: country }) + } + + standardAttributes.push({ + trait_type: 'Bridge Origin Chain', + value: chainId === 84532 ? 'Base' : 'Ethereum' + }) + standardAttributes.push({ trait_type: 'Token Standard', value: 'ERC-721 (ONFT)' }) + + // Build category-specific attributes + const categoryAttributes: NFTAttribute[] = [] + + if (category === 'RealEstate') { + if (squareFootage) categoryAttributes.push({ trait_type: 'Square Footage', value: squareFootage }) + if (propertyType) categoryAttributes.push({ trait_type: 'Property Type', value: propertyType }) + if (bedrooms) categoryAttributes.push({ trait_type: 'Bedrooms', value: bedrooms }) + if (bathrooms) categoryAttributes.push({ trait_type: 'Bathrooms', value: bathrooms }) + if (yearBuilt) categoryAttributes.push({ trait_type: 'Year Built', value: yearBuilt }) + } else if (category === 'Art') { + if (artist) categoryAttributes.push({ trait_type: 'Artist', value: artist }) + if (medium) categoryAttributes.push({ trait_type: 'Medium', value: medium }) + if (yearCreated) categoryAttributes.push({ trait_type: 'Year Created', value: yearCreated }) + if (style) categoryAttributes.push({ trait_type: 'Style', value: style }) + if (dimensions) categoryAttributes.push({ trait_type: 'Dimensions', value: dimensions }) + } else if (category === 'Vehicle') { + if (make) categoryAttributes.push({ trait_type: 'Make', value: make }) + if (model) categoryAttributes.push({ trait_type: 'Model', value: model }) + if (year) categoryAttributes.push({ trait_type: 'Year', value: year }) + if (vin) categoryAttributes.push({ trait_type: 'VIN', value: vin }) + if (mileage) categoryAttributes.push({ trait_type: 'Mileage', value: mileage }) + } else if (category === 'Commodity') { + if (weight) categoryAttributes.push({ trait_type: 'Weight', value: weight }) + if (purity) categoryAttributes.push({ trait_type: 'Purity', value: purity }) + if (origin) categoryAttributes.push({ trait_type: 'Origin', value: origin }) + if (certification) categoryAttributes.push({ trait_type: 'Certification', value: certification }) + } else if (category === 'Collectibles') { + if (condition) categoryAttributes.push({ trait_type: 'Condition', value: condition }) + if (grader) categoryAttributes.push({ trait_type: 'Grader', value: grader }) + if (collectibleCategory) categoryAttributes.push({ trait_type: 'Collectible Type', value: collectibleCategory }) + } + + const metadata: NFTMetadata = { + name, + description, + image: imageUri, + attributes: [...attributes, ...standardAttributes, ...categoryAttributes], // Combine custom, standard, and category-specific attributes + ...(location && { location }), // Only include location if it exists + ...(externalUrl && { external_url: externalUrl }), // Only include external_url if provided + } + console.log('[Mint] Metadata prepared:', metadata) + + console.log('[Mint] Uploading metadata to IPFS...') + const metadataUri = await uploadMetadataToIPFS(metadata) + console.log('[Mint] Metadata uploaded successfully:', metadataUri) + + setIsUploading(false) + + const contractAddress = getContractAddress(chainId, 'rwa721') + console.log('[Mint] Contract address:', contractAddress) + console.log('[Mint] Calling mint function with args:', [address, metadataUri]) + + writeContract({ + address: contractAddress, + abi: RWA721_ABI, + functionName: 'mint', + args: [address, metadataUri], + }) + + console.log('[Mint] Transaction submitted') + } catch (err) { + console.error('[Mint] Error during mint process:', err) + setError(err instanceof Error ? err.message : 'Failed to mint NFT') + setIsUploading(false) + } + } + + const resetForm = () => { + setName('') + setDescription('') + setImageFile(null) + setImagePreview(null) + setAttributes([]) + setLocation(null) + setError(null) + + // Reset standard RWA attributes + setCategory('') + setExternalUrl('') + setValuation('') + // Reset to today's date + const today = new Date() + setIssuanceDate(today.toISOString().split('T')[0]) + + // Reset category-specific attributes + setSquareFootage('') + setPropertyType('') + setBedrooms('') + setBathrooms('') + setYearBuilt('') + setArtist('') + setMedium('') + setYearCreated('') + setStyle('') + setDimensions('') + setMake('') + setModel('') + setYear('') + setVin('') + setMileage('') + setWeight('') + setPurity('') + setOrigin('') + setCertification('') + setCondition('') + setGrader('') + setCollectibleCategory('') + } + + return ( + <> + + +
+
+ + + + Mint RWA Token + + + Create a new tokenized real world asset on {chainId === 84532 ? 'Base Sepolia' : 'Ethereum Sepolia'} + + + + {!isConnected && ( +
+ Please connect your wallet to mint an NFT +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* 2-column grid for desktop, single column for mobile */} +
+ {/* Left Column - Basic Info */} +
+
+ + setName(e.target.value)} + placeholder="Gold Bar Certificate" + disabled={isPending || isConfirming || isUploading} + /> +
+ +
+ +