diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..84f03935 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "1-xcm-hyperbridge/hyperbridge-token-bridge/lib/openzeppelin-contracts"] + path = 1-xcm-hyperbridge/hyperbridge-token-bridge/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/1-xcm-hyperbridge/README.md b/1-xcm-hyperbridge/README.md index 56842898..4fb954d8 100644 --- a/1-xcm-hyperbridge/README.md +++ b/1-xcm-hyperbridge/README.md @@ -140,22 +140,22 @@ Create a basic UI to interact with your bridge: Your submission should include: 1. **Smart Contracts** - - [ ] `TokenBridge.sol` - Bridge logic contract - - [ ] Deployment scripts - - [ ] Bridge token script + - [x] `TokenBridge.sol` - Bridge logic contract + - [x] Deployment scripts + - [x] Bridge token script 2. **Documentation** - - [ ] README explaining your implementation - - [ ] Deployment addresses + - [x] README explaining your implementation + - [x] Deployment addresses 3. **Testing** - - [ ] Unit tests for `TokenBridge.sol` contract + - [x] Unit tests for `TokenBridge.sol` contract 4. **Frontend** - - [ ] Basic UI for bridging tokens - - [ ] Screenshots of your UI in action - - [ ] Accessible Recording Link - - [ ] Minimum supported network pairs (pick at least one) + - [x] Basic UI for bridging tokens + - [x] Screenshots of your UI in action + - [x] Accessible Recording Link + - [x] Minimum supported network pairs (pick at least one) ## šŸ”— Resources diff --git a/1-xcm-hyperbridge/docs/CONTRACT_ADDRESSES.md b/1-xcm-hyperbridge/docs/CONTRACT_ADDRESSES.md new file mode 100644 index 00000000..eb7ca731 --- /dev/null +++ b/1-xcm-hyperbridge/docs/CONTRACT_ADDRESSES.md @@ -0,0 +1,11 @@ +## Contract Addresses + +| Network | WETH | FeeToken | Bridge | +| ------------------ | ------------------------------------------ | ------------------------------------------ | ------------------------------------------ | +| Optimistic Sepolia | 0x4200000000000000000000000000000000000006 | 0xA801da100bF16D07F668F4A49E1f71fc54D05177 | 0x8903331DfE3dcFd8E23bBA2D716B692f1510491e | +| Sepolia | 0x4200000000000000000000000000000000000006 | 0xA801da100bF16D07F668F4A49E1f71fc54D05177 | 0x5169Fc3372a06375c6B1C4E47d00AeEd42b1b80F | + +Notes: + +- Addresses were taken from the previous sections and consolidated into a table for easier copy-paste. +- If you want automated writes from the deployment script, run the script and update this file or deployments.toml accordingly. diff --git a/1-xcm-hyperbridge/docs/DEPLOYMENT.md b/1-xcm-hyperbridge/docs/DEPLOYMENT.md new file mode 100644 index 00000000..74bfa257 --- /dev/null +++ b/1-xcm-hyperbridge/docs/DEPLOYMENT.md @@ -0,0 +1,205 @@ +# Deployment Guide + +This guide covers deployment and interaction with the Hyperbridge Token Bridge contracts. + +## Security: Managing Private Keys + +We use `cast wallet` to securely manage private keys instead of `.env` files. + +### Import Your Private Key + +```bash +cast wallet import defaultKey --interactive +``` + +You'll be prompted to enter your private key and set a password. The key will be encrypted and stored securely. + +## Prerequisites + +1. **Configure Token Gateway Addresses**: Update `deployments.toml` with the TokenGateway addresses for each chain: + +```toml +[sepolia] +endpoint_url = "https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY" + +[sepolia.address] +token_gateway = "0xYourTokenGatewayAddress" + +[bsc-testnet] +endpoint_url = "https://data-seed-prebsc-1-s1.binance.org:8545" + +[bsc-testnet.address] +token_gateway = "0xYourTokenGatewayAddress" + +[optimism-sepolia] +endpoint_url = "https://sepolia.optimism.io" + +[optimism-sepolia.address] +token_gateway = "0xYourTokenGatewayAddress" +``` + +2. **Get Testnet Funds**: Ensure your wallet has native tokens for gas fees on each chain. + +## Step 1: Deploy Contracts + +Deploy the contracts on each chain where you want to enable bridging. + +### Deploy on Sepolia + +```bash +ETH_FROM=0x5984A519fFfE5aFc5e8bBA233DCc01AC774f4301 forge script script/Deployment.s.sol:DeploymentScript \ + --rpc-url https://1rpc.io/sepolia \ + --account defaultKey \ + --sender 0x5984A519fFfE5aFc5e8bBA233DCc01AC774f4301 \ + --broadcast \ + --verifier etherscan \ + --etherscan-api-key \ + -vvvvv +``` + +### Deploy on BSC Testnet + +```bash +ETH_FROM=0x5984A519fFfE5aFc5e8bBA233DCc01AC774f4301 forge script script/Deployment.s.sol:DeploymentScript \ + --rpc-url bsc-testnet \ + --account defaultKey \ + --sender \ + --broadcast \ + --verifier etherscan \ + --etherscan-api-key \ + -vvvvv +``` + +### Deploy on Optimism Sepolia + +```bash +ETH_FROM=0x5984A519fFfE5aFc5e8bBA233DCc01AC774f4301 forge script script/Deployment.s.sol:DeploymentScript \ + --rpc-url https://sepolia.optimism.io \ + --account defaultKey \ + --sender 0x5984A519fFfE5aFc5e8bBA233DCc01AC774f4301 \ + --broadcast \ + --verifier etherscan \ + --etherscan-api-key \ + -vvvvv +``` + +**Note**: Deployment addresses will be automatically saved to `deployments.toml` after each deployment. + +## Step 2: Configure Bridge Parameters + +After deployment, configure the bridge parameters in `bridge.toml`: + +```toml +[sepolia] +endpoint_url = "https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY" + +[sepolia.address] +token_gateway = "0xYourTokenGatewayAddress" +bridgeable_token = "0xDeployedBridgeableTokenAddress" +fee_token = "0xDeployedFeeTokenAddress" +token_bridge = "0xDeployedTokenBridgeAddress" + +[sepolia.bridge_params] +amount = "1000000000000000000000" # 1000 tokens (with 18 decimals) +recipient = "0xRecipientAddressOnDestinationChain" +dest_chain = "optimism-sepolia" # Destination chain name +native_cost = "1000000000000000" # 0.001 ETH (in wei) for gas +``` + +Repeat for all chains (`bsc-testnet`, `optimism-sepolia`, etc.). + +## Step 3: Mint Test Tokens (Optional) + +Before bridging, you may want to mint some test tokens to your address: + +```bash +# Mint BridgeableTokens +cast send \ + "mint(address,uint256)" \ + \ + 1000000000000000000000 \ + --rpc-url sepolia \ + --account defaultKey + +# Mint FeeTokens +cast send \ + "mint(address,uint256)" \ + \ + 1000000000000000000000 \ + --rpc-url sepolia \ + --account defaultKey +``` + +## Step 4: Bridge Tokens + +Execute the bridge transaction using the configured parameters in `bridge.toml`. + +### Bridge from Sepolia to Optimism Sepolia + +```bash +forge script script/BridgeToken.s.sol:BridgeTokenScript \ + --rpc-url sepolia \ + --account defaultKey \ + --sender \ + --broadcast \ + -vvvvv +``` + +### Bridge from BSC Testnet to Sepolia + +First, update `bridge.toml` to set the correct `dest_chain` for BSC Testnet, then: + +```bash +forge script script/BridgeToken.s.sol:BridgeTokenScript \ + --rpc-url bsc-testnet \ + --account defaultKey \ + --sender \ + --broadcast \ + -vvvvv +``` + +### Bridge from Optimism Sepolia to Sepolia + +```bash +forge script script/BridgeToken.s.sol:BridgeTokenScript \ + --rpc-url optimism-sepolia \ + --account defaultKey \ + --sender \ + --broadcast \ + -vvvvv +``` + +## Step 5: Verify Bridge Transaction + +After bridging, you can verify: + +1. **Check token balance on destination chain**: +```bash +cast call \ + "balanceOf(address)(uint256)" \ + \ + --rpc-url +``` + +2. **Monitor transaction status** through the Hyperbridge explorer or chain explorer. + +## Configuration Files + +- **`deployments.toml`**: Stores deployment addresses (auto-updated by deployment script) +- **`bridge.toml`**: Stores bridge parameters and deployed contract addresses for bridging operations + +## Troubleshooting + +### Insufficient Balance Error +- Ensure you have minted enough tokens +- Check your token balance: `cast call "balanceOf(address)(uint256)" --rpc-url ` + +### Transaction Reverts +- Verify TokenGateway address is correct +- Ensure you have enough native tokens for gas +- Check that bridge parameters in `bridge.toml` are valid + +### Script Fails to Load Config +- Verify TOML file paths are correct +- Ensure addresses are properly formatted with `0x` prefix +- Check that all required fields are populated \ No newline at end of file diff --git a/1-xcm-hyperbridge/docs/README.md b/1-xcm-hyperbridge/docs/README.md new file mode 100644 index 00000000..46d15efc --- /dev/null +++ b/1-xcm-hyperbridge/docs/README.md @@ -0,0 +1,349 @@ +# Hyperbridge Token Bridge + +A cross-chain token bridge implementation using Hyperbridge's TokenGateway infrastructure, enabling seamless token transfers across multiple blockchain networks. + +## Overview + +This project implements a token bridging solution that leverages Hyperbridge's cross-chain messaging protocol to transfer tokens between different EVM-compatible chains. The implementation includes smart contracts, deployment scripts, and comprehensive testing. + +## Architecture + +### Core Components + +#### 1. **BridgeableToken.sol** +An ERC20 token that extends Hyperbridge's `HyperFungibleToken` standard, making it natively compatible with cross-chain transfers. + +**Key Features:** +- Inherits from `HyperFungibleToken` +- Configurable gateway address +- Standard ERC20 functionality with cross-chain capabilities + +**Implementation:** +```solidity +contract BridgeableToken is HyperFungibleToken { + address private immutable _gateway; + + constructor( + string memory name, + string memory symbol, + address gatewayAddress + ) HyperFungibleToken(name, symbol) { + _gateway = gatewayAddress; + } + + function gateway() public view override returns (address) { + return _gateway; + } +} +``` + +#### 2. **TokenBridge.sol** +The main bridge contract that coordinates token transfers between chains using Hyperbridge's TokenGateway. + +**Key Features:** +- Integrates with Hyperbridge's `ITokenGateway` +- Handles token approvals and transfers +- Manages cross-chain teleport parameters +- 24-hour timeout for cross-chain operations + +**Core Function:** +```solidity +function bridgeTokens( + address token, + uint256 amount, + address recipient, + bytes memory destChain +) external payable +``` + +**Parameters:** +- `token`: Address of the token to bridge +- `amount`: Amount to transfer (in token's smallest unit) +- `recipient`: Destination address on target chain +- `destChain`: Identifier of the destination chain (e.g., "sepolia", "optimism-sepolia") + +**Process Flow:** +1. Validates token address (non-zero) +2. Transfers tokens from user to bridge contract +3. Approves TokenGateway to spend tokens +4. Approves fee token spending +5. Constructs `TeleportParams` with transfer details +6. Calls `tokenGateway.teleport()` to initiate cross-chain transfer + +#### 3. **MockToken.sol** +A simple ERC20 token used for fee payments and testing purposes. + +**Key Features:** +- Mintable by owner +- Burnable by owner +- Standard ERC20 implementation + +## How It Works + +### Cross-Chain Token Transfer Flow + +``` +User (Chain A) + ↓ + 1. Approve tokens + ↓ +TokenBridge (Chain A) + ↓ + 2. Transfer tokens to bridge + 3. Approve gateway + 4. Call teleport() + ↓ +TokenGateway (Chain A) + ↓ + 5. Lock tokens + 6. Create cross-chain message + ↓ +Hyperbridge Network + ↓ + 7. Verify and relay message + ↓ +TokenGateway (Chain B) + ↓ + 8. Process message + 9. Mint/unlock tokens + ↓ +Recipient (Chain B) +``` + +### Key Concepts + +**Teleport Parameters:** +- `amount`: Token amount to bridge +- `assetId`: Token contract address (as bytes32) +- `to`: Recipient address (as bytes32) +- `dest`: Destination chain identifier +- `timeout`: Unix timestamp for operation expiry +- `nativeCost`: Native token (ETH) for gas on destination +- `relayerFee`: Fee for message relayers (set to 0) +- `redeem`: Whether to redeem wrapped tokens (set to false) + +## Smart Contracts + +### Deployment Addresses + +Addresses are stored in `deployments.toml` and `bridge.toml` after deployment: + +- **BridgeableToken**: Cross-chain compatible ERC20 +- **FeeToken**: Token used for fee payments +- **TokenBridge**: Main bridge orchestrator +- **TokenGateway**: Hyperbridge's gateway (pre-deployed) + +## Configuration Files + +### `deployments.toml` +Stores deployment information for each chain: +```toml +[sepolia] +endpoint_url = "https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY" + +[sepolia.address] +token_gateway = "0x..." # Pre-deployed by Hyperbridge +bridgeable_token = "0x..." # Deployed by you +fee_token = "0x..." # Deployed by you +token_bridge = "0x..." # Deployed by you +``` + +### `bridge.toml` +Stores bridge operation parameters: +```toml +[sepolia.bridge_params] +amount = "1000000000000000000000" # 1000 tokens (18 decimals) +recipient = "0x..." # Destination address +dest_chain = "optimism-sepolia" # Target chain +native_cost = "1000000000000000" # 0.001 ETH +``` + +## Scripts + +### Deployment Script (`Deployment.s.sol`) +Deploys all contracts on a specified chain: +1. Loads TokenGateway address from config +2. Deploys BridgeableToken +3. Deploys MockToken (fee token) +4. Deploys TokenBridge +5. Saves deployment addresses back to config + +### Bridge Script (`BridgeToken.s.sol`) +Executes a token bridge operation: +1. Loads contract addresses and parameters from config +2. Checks token balances +3. Approves token spending +4. Mints fee tokens if needed (testing) +5. Calls `bridgeTokens()` to initiate transfer +6. Logs transaction details + +## Testing + +Comprehensive test suite in `TokenBridge.t.sol` covers: + +### Unit Tests +- āœ… Constructor initialization +- āœ… Successful token bridging +- āœ… Zero address validation +- āœ… Insufficient allowance handling +- āœ… Insufficient balance handling +- āœ… Different amounts, recipients, and chains +- āœ… Teleport parameter correctness +- āœ… Edge cases (zero amount, max amount) +- āœ… Multiple consecutive bridges + +### Fuzz Tests +- āœ… Random amounts and recipients +- āœ… Boundary conditions + +**Run Tests:** +```bash +forge test -vvv +``` + +**Run Specific Test:** +```bash +forge test --match-test testBridgeTokensSuccess -vvv +``` + +**Test Coverage:** +```bash +forge coverage +``` + +## Deployment & Usage + +### Prerequisites +1. Install Foundry +2. Configure RPC endpoints in `deployments.toml` +3. Import private key securely: +```bash +cast wallet import defaultKey --interactive +``` + +### Deploy Contracts + +**On Sepolia:** +```bash +forge script script/Deployment.s.sol:DeploymentScript \ + --rpc-url sepolia \ + --account defaultKey \ + --sender \ + --broadcast \ + -vvvvv +``` + +**On Other Chains:** +Repeat for `bsc-testnet`, `optimism-sepolia`, etc. + +### Bridge Tokens + +1. **Mint Test Tokens (optional):** +```bash +cast send \ + "mint(address,uint256)" \ + \ + 1000000000000000000000 \ + --rpc-url sepolia \ + --account defaultKey +``` + +2. **Update `bridge.toml`** with bridge parameters + +3. **Execute Bridge:** +```bash +forge script script/BridgeToken.s.sol:BridgeTokenScript \ + --rpc-url sepolia \ + --account defaultKey \ + --sender \ + --broadcast \ + -vvvvv +``` + +### Verify Transaction + +**Check Balance on Destination:** +```bash +cast call \ + "balanceOf(address)(uint256)" \ + \ + --rpc-url +``` + +## Security Considerations + +1. **Private Key Management**: Uses `cast wallet` for encrypted key storage +2. **Token Approvals**: Bridge only gets approved for specific amounts +3. **Address Validation**: Zero address checks prevent invalid transfers +4. **Timeout Protection**: 24-hour timeout prevents stuck transactions +5. **Testing**: Comprehensive test coverage for edge cases + +## Supported Chains + +Currently configured for: +- Ethereum Sepolia (testnet) +- BSC Testnet +- Optimism Sepolia + +Can be extended to any EVM chain supported by Hyperbridge. + +## Project Structure + +``` +hyperbridge-token-bridge/ +ā”œā”€ā”€ src/ +│ ā”œā”€ā”€ BridgeableToken.sol # Cross-chain ERC20 token +│ ā”œā”€ā”€ MockToken.sol # Fee token for testing +│ └── TokenBridge.sol # Main bridge logic +ā”œā”€ā”€ script/ +│ ā”œā”€ā”€ Base.s.sol # Base script utilities +│ ā”œā”€ā”€ Deployment.s.sol # Deployment script +│ └── BridgeToken.s.sol # Bridge execution script +ā”œā”€ā”€ test/ +│ └── TokenBridge.t.sol # Comprehensive test suite +ā”œā”€ā”€ deployments.toml # Deployment addresses +└── bridge.toml # Bridge parameters +``` + +## References + +- [Hyperbridge Documentation](https://docs.hyperbridge.network/) +- [TokenGateway Interface](https://github.com/polytope-labs/hyperbridge) +- [Deployment Guide](./DEPLOYMENT.md) +- [Test Plan](./TEST_PLAN.md) + +## License + +MIT License - See individual contract files for details. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Write tests for new features +4. Ensure all tests pass: `forge test` +5. Submit a pull request + +## Troubleshooting + +**Issue: "Token address cannot be zero"** +- Ensure you're providing a valid token address + +**Issue: "Transfer failed"** +- Check token balance: `cast call "balanceOf(address)" ` +- Verify token approval: `cast call "allowance(address,address)" ` + +**Issue: "Insufficient token balance"** +- Mint more tokens or reduce bridge amount + +**Issue: Transaction times out** +- Check destination chain status +- Verify TokenGateway is operational +- Monitor Hyperbridge network status + +## Contact & Support + +For issues and questions: +- Open an issue in the repository +- Check Hyperbridge documentation +- Join Hyperbridge community channels diff --git a/1-xcm-hyperbridge/frontend/.eslintrc.json b/1-xcm-hyperbridge/frontend/.eslintrc.json new file mode 100644 index 00000000..37224185 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/1-xcm-hyperbridge/frontend/.gitignore b/1-xcm-hyperbridge/frontend/.gitignore new file mode 100644 index 00000000..fd3dbb57 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/1-xcm-hyperbridge/frontend/.papi/descriptors/.gitignore b/1-xcm-hyperbridge/frontend/.papi/descriptors/.gitignore new file mode 100644 index 00000000..557cc814 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/.papi/descriptors/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!package.json diff --git a/1-xcm-hyperbridge/frontend/.papi/descriptors/package.json b/1-xcm-hyperbridge/frontend/.papi/descriptors/package.json new file mode 100644 index 00000000..03fd9cde --- /dev/null +++ b/1-xcm-hyperbridge/frontend/.papi/descriptors/package.json @@ -0,0 +1,24 @@ +{ + "version": "0.1.0-autogenerated.13184609127501774991", + "name": "@polkadot-api/descriptors", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "module": "./dist/index.mjs", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "browser": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "peerDependencies": { + "polkadot-api": ">=1.21.0" + } +} diff --git a/1-xcm-hyperbridge/frontend/.papi/metadata/paseo.scale b/1-xcm-hyperbridge/frontend/.papi/metadata/paseo.scale new file mode 100644 index 00000000..b86b8bfd Binary files /dev/null and b/1-xcm-hyperbridge/frontend/.papi/metadata/paseo.scale differ diff --git a/1-xcm-hyperbridge/frontend/.papi/metadata/paseo_asset_hub.scale b/1-xcm-hyperbridge/frontend/.papi/metadata/paseo_asset_hub.scale new file mode 100644 index 00000000..fb647736 Binary files /dev/null and b/1-xcm-hyperbridge/frontend/.papi/metadata/paseo_asset_hub.scale differ diff --git a/1-xcm-hyperbridge/frontend/.papi/polkadot-api.json b/1-xcm-hyperbridge/frontend/.papi/polkadot-api.json new file mode 100644 index 00000000..1e8a2856 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/.papi/polkadot-api.json @@ -0,0 +1,18 @@ +{ + "version": 0, + "descriptorPath": ".papi/descriptors", + "entries": { + "paseo": { + "chain": "paseo", + "metadata": ".papi/metadata/paseo.scale", + "genesis": "0x77afd6190f1554ad45fd0d31aee62aacc33c6db0ea801129acb813f913e0764f", + "codeHash": "0xbe23bf1f10e3704e762c9f3533f36645a286af995a52efd6eb784c81af4dbc9c" + }, + "paseo_asset_hub": { + "chain": "paseo_asset_hub", + "metadata": ".papi/metadata/paseo_asset_hub.scale", + "genesis": "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", + "codeHash": "0x580f8c8474f117c97f7027f89598e7dfa83d1c9cb5cdd24aee121a70787be206" + } + } +} diff --git a/1-xcm-hyperbridge/frontend/CONTRIBUTING.md b/1-xcm-hyperbridge/frontend/CONTRIBUTING.md new file mode 100644 index 00000000..1367914d --- /dev/null +++ b/1-xcm-hyperbridge/frontend/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Welcome to DotUI Contributing Guide + +Thank you for investing your time in contributing to DotUI! + +This guide aims to provide an overview of the contribution workflow to help us make the contribution process effective for everyone involved. + +## About the Project + +DotUI is a minimal and forkable repo providing builders with a starter kit to build decentralized applications on Polkadot. + +Read the [README](README.md) to get an overview of the project. + +### Vision + +The goal of DotUI is to provide the primary building blocks for a decentralized application. + +The repo can be forked to include integrations and more features, but we want to keep the master branch simple and minimal. + +### Project Status + +The project is under active development. + +You can view the open Issues, follow the development process and contribute to the project. + +## Getting started + +You can contribute to this repo in many ways: + +- Solve open issues +- Report bugs or feature requests +- Improve the documentation + +Contributions are made via Issues and Pull Requests (PRs). A few general guidelines for contributions: + +- Search for existing Issues and PRs before creating your own. +- Contributions should only fix/add the functionality in the issue OR address style issues, not both. +- If you're running into an error, please give context. Explain what you're trying to do and how to reproduce the error. +- Please use the same formatting in the code repository. You can configure your IDE to do it by using the prettier / linting config files included in each package. +- If applicable, please edit the README.md file to reflect the changes. + +### Issues + +Issues should be used to report problems, request a new feature, or discuss potential changes before a PR is created. + +#### Solve an issue + +Scan through our [existing issues](https://github.com/buildstationorg/dotui/issues) to find one that interests you. + +If a contributor is working on the issue, they will be assigned to the individual. If you find an issue to work on, you are welcome to assign it to yourself and open a PR with a fix for it. + +#### Create a new issue + +If a related issue doesn't exist, you can open a new issue. + +Some tips to follow when you are creating an issue: + +- Provide as much context as possible. Over-communicate to give the most details to the reader. +- Include the steps to reproduce the issue or the reason for adding the feature. +- Screenshots, videos etc., are highly appreciated. + +### Pull Requests + +#### Pull Request Process + +We follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr) + +1. Fork the repo +2. Clone the project +3. Create a new branch with a descriptive name +4. Commit your changes to the new branch +5. Push changes to your fork +6. Open a PR in our repository and tag one of the maintainers to review your PR + +Here are some tips for a high-quality pull request: + +- Create a title for the PR that accurately defines the work done. +- Structure the description neatly to make it easy to consume by the readers. For example, you can include bullet points and screenshots instead of having one large paragraph. +- Add the link to the issue if applicable. +- Have a good commit message that summarises the work done. + +Once you submit your PR: + +- We may ask questions, request additional information or ask for changes to be made before a PR can be merged. Please note that these are to make the PR clear for everyone involved and aims to create a frictionless interaction process. +- As you update your PR and apply changes, mark each conversation resolved. + +Once the PR is approved, we'll "squash-and-merge" to keep the git commit history clean. \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/LICENSE b/1-xcm-hyperbridge/frontend/LICENSE new file mode 100644 index 00000000..783de167 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 buildstation and OpenGuild + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/README.md b/1-xcm-hyperbridge/frontend/README.md new file mode 100644 index 00000000..14f673a6 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/README.md @@ -0,0 +1,75 @@ +![DOT UI Kit](/public/frontend-kit-challenge.webp) + +# DOT UI Kit + +An open-source, up-to-date, opinionated UI scaffolding kit for the Polkadot ecosystem (starting with Asset Hub). The technical stack is: +- [Next.js](https://nextjs.org/) +- [Tailwind CSS](https://tailwindcss.com/) +- [Lucide icons](https://lucide.dev/) +- [ShadCN UI](https://ui.shadcn.com/) +- [RainbowKit](https://www.rainbowkit.com/) +- [Wagmi](https://wagmi.sh/) +- [Viem](https://viem.sh/) +- [Jotai](https://jotai.org/) +- [Tanstack React Query](https://tanstack.com/query) +- [Vaul](https://vaul.fun/) +- [Zod](https://zod.dev/) +- [React Hook Form](https://react-hook-form.com/) + +## Features + +- [x] Multi-chain support +- [x] In-dapp-wallet support +- [x] WalletConnect support +- [x] Collection of web3 components to quickly build your frontend or use as a reference +- [x] React hooks for various onchain interactions with Wagmi + +## Requirements + +Before you begin, you need to install the following tools: + +- [Node (current LTS version)](https://nodejs.org/en/download/) +- [npm (latest version or > v10)](https://www.npmjs.com/get-npm) +- [Git](https://git-scm.com/downloads) + + +## Getting started + +``` +git clone https://github.com/buildstationorg/dotui.git +cd dotui +npm install +``` + +## Running the project + +``` +npm run dev +``` +Default port is 3002. You can change the port in the `package.json` file. + +```json +"scripts": { + "dev": "next dev -p 3002", // Change the port here to -p + "build": "next build", + "start": "next start", + "lint": "next lint" +}, +``` + +## Building the project + +``` +npm run build +``` + +## Documentation + +Please see [`docs`](docs) for more information and guidelines for contributing to DotUI. + +## Contributing to DotUI + +We welcome contributions to DotUI! + +Please see [`CONTRIBUTING.md`](CONTRIBUTING.md) for more information and guidelines for contributing to DotUI. + diff --git a/1-xcm-hyperbridge/frontend/api/asset-hub-chain.ts.bak b/1-xcm-hyperbridge/frontend/api/asset-hub-chain.ts.bak new file mode 100644 index 00000000..0306cc37 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/api/asset-hub-chain.ts.bak @@ -0,0 +1,20 @@ +import { TypedApi, createClient } from "polkadot-api"; +import { getSmProvider } from "polkadot-api/sm-provider"; +import { smoldotRelayChain } from "./relay-chain"; +import { paseo_asset_hub as paseoAssetHub } from "@polkadot-api/descriptors"; +import { smoldot } from "./smoldot"; + + +const smoldotParaChain = Promise.all([ + smoldotRelayChain, + import("polkadot-api/chains/paseo_asset_hub"), +]).then(([relayChain, { chainSpec }]) => + smoldot.addChain({ chainSpec, potentialRelayChains: [relayChain] }), +); + +const provider = getSmProvider(smoldotParaChain); +export const paraChain = createClient(provider); + +export const PASEO_ASSET_HUB_CHAIN_ID = 1000; +export const paseoAssetHubChainApi = paraChain.getTypedApi(paseoAssetHub); +export type PaseoAssetHubChainApi = TypedApi; \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/api/index.ts b/1-xcm-hyperbridge/frontend/api/index.ts new file mode 100644 index 00000000..31b12765 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/api/index.ts @@ -0,0 +1,9 @@ +// Polkadot API exports - temporarily disabled for EVM-only bridging +// export * from "./asset-hub-chain"; +// export * from "./relay-chain"; +// export * from "./smoldot"; +// export * from "./teleport"; + +// Placeholder exports for compatibility +export const paraChain = null; +export const paseoAssetHubChainApi = null; diff --git a/1-xcm-hyperbridge/frontend/api/relay-chain.ts.bak b/1-xcm-hyperbridge/frontend/api/relay-chain.ts.bak new file mode 100644 index 00000000..a2af64d9 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/api/relay-chain.ts.bak @@ -0,0 +1,5 @@ +import { smoldot } from "./smoldot"; + +export const smoldotRelayChain = import("polkadot-api/chains/paseo").then( + ({ chainSpec }) => smoldot.addChain({ chainSpec }) +); diff --git a/1-xcm-hyperbridge/frontend/api/smoldot.ts.bak b/1-xcm-hyperbridge/frontend/api/smoldot.ts.bak new file mode 100644 index 00000000..d2acaa4e --- /dev/null +++ b/1-xcm-hyperbridge/frontend/api/smoldot.ts.bak @@ -0,0 +1,15 @@ +import { startFromWorker } from "polkadot-api/smoldot/from-worker"; + +// 1. Initialize a variable to hold the instance +let smoldotInstance = null; + +// 2. Only instantiate the Worker if we are in the browser (client-side) +if (typeof window !== "undefined") { + const SmWorker = new Worker( + new URL("polkadot-api/smoldot/worker", import.meta.url) + ); + smoldotInstance = startFromWorker(SmWorker); +} + +// 3. Export the instance (it will be null on the server) +export const smoldot = smoldotInstance; \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/api/teleport.ts.bak b/1-xcm-hyperbridge/frontend/api/teleport.ts.bak new file mode 100644 index 00000000..f01c5b5c --- /dev/null +++ b/1-xcm-hyperbridge/frontend/api/teleport.ts.bak @@ -0,0 +1,101 @@ +import { AccountId, Binary, SS58String } from "polkadot-api"; +import { + PASEO_ASSET_HUB_CHAIN_ID, + paseoAssetHubChainApi, +} from "./asset-hub-chain"; +import { + XcmVersionedLocation, + XcmV3MultiassetFungibility, + XcmV3Junctions, + XcmVersionedAssets, + XcmV3WeightLimit, + XcmV3Junction, + XcmV5Junctions, +} from "@polkadot-api/descriptors"; + +const encodeAccount = AccountId().enc; + +export const reserveTransferToParachain = ( + address: SS58String, + amount: bigint +): any => { + // TODO: Implement a logic to reserve transfer to parachain + const xcmTx = paseoAssetHubChainApi.tx.PolkadotXcm.reserve_transfer_assets({ + dest: XcmVersionedLocation.V4({ + parents: 1, + interior: XcmV3Junctions.X1( + XcmV3Junction.Parachain(PASEO_ASSET_HUB_CHAIN_ID) + ), + }), + beneficiary: getBeneficiary(0, address), + assets: getNativeAsset(0, amount), + fee_asset_item: 0, + }); + return xcmTx; +}; + +export const teleportToParaChain = (address: SS58String, amount: bigint) => { + // TODO: Implement a logic to teleport to parachain + + // Construct XCM transaction to teleport from relay chain (PASEO) to parachain (PASEO Asset Hub) + const xcmTx = paseoAssetHubChainApi.tx.PolkadotXcm.transfer_assets({ + dest: XcmVersionedLocation.V4({ + parents: 0, // Because we are in the relay chain at the moment + interior: XcmV3Junctions.X1( + XcmV3Junction.Parachain(PASEO_ASSET_HUB_CHAIN_ID) + ), + }), + beneficiary: getBeneficiary(0, address), + assets: getNativeAsset(0, amount), + fee_asset_item: 0, + weight_limit: XcmV3WeightLimit.Unlimited(), + }); + + return xcmTx; +}; + +export const teleportToRelayChain = ( + address: SS58String, + amount: bigint +): any => { + // TODO: Implement a logic to teleport to relaychain + + // Construct XCM transaction to teleport from parachain (PASEO Asset Hub) to relay chain (PASEO) + const xcmTx = paseoAssetHubChainApi.tx.PolkadotXcm.transfer_assets({ + dest: XcmVersionedLocation.V4({ + parents: 1, // Because we are in the parachain which is the "child" of the relay chain + interior: XcmV3Junctions.Here(), + }), + beneficiary: getBeneficiary(0, address), + assets: getNativeAsset(1, amount), + fee_asset_item: 0, + weight_limit: XcmV3WeightLimit.Unlimited(), + }); + + return xcmTx; +}; + +const getBeneficiary = (parents: number, address: SS58String | Uint8Array) => + XcmVersionedLocation.V4({ + parents, + interior: XcmV3Junctions.X1( + XcmV3Junction.AccountId32({ + network: undefined, + id: Binary.fromBytes( + address instanceof Uint8Array ? address : encodeAccount(address) + ), + }) + ), + }); + +// Get the native asset in the form of XCM +const getNativeAsset = (parents: number, amount: bigint) => + XcmVersionedAssets.V4([ + { + id: { + parents, + interior: XcmV5Junctions.Here(), + }, + fun: XcmV3MultiassetFungibility.Fungible(amount), + }, + ]); \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/api/worker.ts.bak b/1-xcm-hyperbridge/frontend/api/worker.ts.bak new file mode 100644 index 00000000..e94a89f3 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/api/worker.ts.bak @@ -0,0 +1,12 @@ +import * as smoldot from 'smoldot/worker'; +import { compileBytecode } from 'smoldot/bytecode'; + +compileBytecode().then((bytecode) => { + self.postMessage(bytecode); +}); + +self.onmessage = (event) => { + if (event.data instanceof MessagePort) { + smoldot.run(event.data); + } +}; \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/app/balance/loading.tsx b/1-xcm-hyperbridge/frontend/app/balance/loading.tsx new file mode 100644 index 00000000..64936374 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/balance/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return ( +
+ +
+ + + + +
+ +
+ ) +} \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/app/balance/page.tsx b/1-xcm-hyperbridge/frontend/app/balance/page.tsx new file mode 100644 index 00000000..be0f63ab --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/balance/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import SigpassKit from "@/components/sigpasskit"; +import Navbar from "@/components/navbar"; + + +export default function BalancePage() { + return ( +
+ +

Balance

+
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/app/faucet/loading.tsx b/1-xcm-hyperbridge/frontend/app/faucet/loading.tsx new file mode 100644 index 00000000..2e63fbf0 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/faucet/loading.tsx @@ -0,0 +1,12 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return ( +
+ + + +
+ ) +} diff --git a/1-xcm-hyperbridge/frontend/app/faucet/page.tsx b/1-xcm-hyperbridge/frontend/app/faucet/page.tsx new file mode 100644 index 00000000..c629ddf4 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/faucet/page.tsx @@ -0,0 +1,25 @@ +"use client"; +import FaucetWriteContract from "@/components/faucet-write-contract"; +import SigpassKit from "@/components/sigpasskit"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +export default function FaucetPage() { + return ( +
+ {/* Back Link */} + + Back to Bridge + + + {/* Wallet */} + + + {/* Faucet */} + +
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/app/favicon.ico b/1-xcm-hyperbridge/frontend/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/1-xcm-hyperbridge/frontend/app/favicon.ico differ diff --git a/1-xcm-hyperbridge/frontend/app/fonts/GeistMonoVF.woff b/1-xcm-hyperbridge/frontend/app/fonts/GeistMonoVF.woff new file mode 100644 index 00000000..f2ae185c Binary files /dev/null and b/1-xcm-hyperbridge/frontend/app/fonts/GeistMonoVF.woff differ diff --git a/1-xcm-hyperbridge/frontend/app/fonts/GeistVF.woff b/1-xcm-hyperbridge/frontend/app/fonts/GeistVF.woff new file mode 100644 index 00000000..1b62daac Binary files /dev/null and b/1-xcm-hyperbridge/frontend/app/fonts/GeistVF.woff differ diff --git a/1-xcm-hyperbridge/frontend/app/globals.css b/1-xcm-hyperbridge/frontend/app/globals.css new file mode 100644 index 00000000..cba52ee4 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/globals.css @@ -0,0 +1,77 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/1-xcm-hyperbridge/frontend/app/layout.tsx b/1-xcm-hyperbridge/frontend/app/layout.tsx new file mode 100644 index 00000000..2286fe13 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { Unbounded } from "next/font/google"; +import "./globals.css"; +import '@rainbow-me/rainbowkit/styles.css'; +import { Providers } from '@/app/providers'; +import Navbar from "@/components/navbar"; + +const unbounded = Unbounded({ + subsets: ['latin'], + weight: ['400', '700'], + display: 'swap', +}) + +export const metadata: Metadata = { + title: "DOT UI kit", + description: "a UI kit for Polkadot DApps", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + +
+ {children} +
+
+ + + ); +} diff --git a/1-xcm-hyperbridge/frontend/app/loading.tsx b/1-xcm-hyperbridge/frontend/app/loading.tsx new file mode 100644 index 00000000..e0260638 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + + + + +
+ +
+ + +
+
+ + + + + + +
+
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/app/page.tsx b/1-xcm-hyperbridge/frontend/app/page.tsx new file mode 100644 index 00000000..e7e8d659 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/page.tsx @@ -0,0 +1,41 @@ +import TokenBridge from "@/components/token-bridge"; +import SigpassKit from "@/components/sigpasskit"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; + +export default function Home() { + return ( +
+ {/* Header */} +
+

Hyperbridge

+

+ Cross-chain USDC bridge powered by Polkadot +

+
+ + {/* Wallet */} + + + {/* Bridge */} + + + {/* Quick Links */} +
+ + Get test USDC + + + Explorer + +
+
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/app/providers.tsx b/1-xcm-hyperbridge/frontend/app/providers.tsx new file mode 100644 index 00000000..506bef9a --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/providers.tsx @@ -0,0 +1,118 @@ +"use client"; + +import * as React from "react"; +import { + RainbowKitProvider, + getDefaultWallets, + getDefaultConfig, +} from "@rainbow-me/rainbowkit"; +import { + phantomWallet, + trustWallet, + ledgerWallet, +} from "@rainbow-me/rainbowkit/wallets"; +import { sepolia, bscTestnet, optimismSepolia } from "wagmi/chains"; +import { defineChain, type Chain } from "viem"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider, http, createConfig } from "wagmi"; +import { Provider as JotaiProvider } from "jotai"; + +// Paseo Testnet chain definition (Polkadot testnet) +export const paseoTestnet = defineChain({ + id: 420420420, + name: "Paseo Testnet", + nativeCurrency: { + decimals: 18, + name: "Paseo", + symbol: "PAS", + }, + rpcUrls: { + default: { + http: ["https://testnet-passet-hub-eth-rpc.polkadot.io"], + webSocket: ["wss://passet-hub-paseo.ibp.network"], + }, + }, + blockExplorers: { + default: { + name: "Blockscout", + url: "https://blockscout-passet-hub.parity-testnet.parity.io/", + }, + }, + testnet: true, +}); + +// Bridge supported chains configuration +export const bridgeChains = { + sepolia, + bscTestnet, + optimismSepolia, + paseoTestnet, +} as const; + +// Network pairs for bridge (source -> destination) +export type BridgeNetworkPair = { + source: Chain; + destination: Chain; + name: string; +}; + +export const bridgeNetworkPairs: BridgeNetworkPair[] = [ + { source: paseoTestnet, destination: sepolia, name: "Paseo → ETH Sepolia" }, + { + source: bscTestnet, + destination: sepolia, + name: "BSC Testnet → ETH Sepolia", + }, + { + source: optimismSepolia, + destination: sepolia, + name: "Optimism Sepolia → ETH Sepolia", + }, +]; + +export const localConfig = createConfig({ + chains: [sepolia, bscTestnet, optimismSepolia, paseoTestnet], + transports: { + [sepolia.id]: http(), + [bscTestnet.id]: http(), + [optimismSepolia.id]: http(), + [paseoTestnet.id]: http(), + }, + ssr: true, +}); + +const { wallets } = getDefaultWallets(); + +const config = getDefaultConfig({ + appName: "Hyperbridge", + projectId: "ddf8cf3ee0013535c3760d4c79c9c8b9", + wallets: [ + ...wallets, + { + groupName: "Other", + wallets: [phantomWallet, trustWallet, ledgerWallet], + }, + ], + chains: [sepolia, bscTestnet, optimismSepolia, paseoTestnet], + transports: { + [sepolia.id]: http(), + [bscTestnet.id]: http(), + [optimismSepolia.id]: http(), + [paseoTestnet.id]: http(), + }, + ssr: true, +}); + +const queryClient = new QueryClient(); + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/1-xcm-hyperbridge/frontend/app/send-transaction/loading.tsx b/1-xcm-hyperbridge/frontend/app/send-transaction/loading.tsx new file mode 100644 index 00000000..64936374 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/send-transaction/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return ( +
+ +
+ + + + +
+ +
+ ) +} \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/app/send-transaction/page.tsx b/1-xcm-hyperbridge/frontend/app/send-transaction/page.tsx new file mode 100644 index 00000000..d70a87aa --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/send-transaction/page.tsx @@ -0,0 +1,14 @@ +"use client"; +import SendTransaction from "@/components/send-transaction"; +import SigpassKit from "@/components/sigpasskit"; +import Navbar from "@/components/navbar"; + +export default function SendTransactionPage() { + return ( +
+ +

Send Transaction

+ +
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/app/wallet/loading.tsx b/1-xcm-hyperbridge/frontend/app/wallet/loading.tsx new file mode 100644 index 00000000..64936374 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/wallet/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function Loading() { + // You can add any UI inside Loading, including a Skeleton. + return ( +
+ +
+ + + + +
+ +
+ ) +} \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/app/wallet/page.tsx b/1-xcm-hyperbridge/frontend/app/wallet/page.tsx new file mode 100644 index 00000000..f7c31ddf --- /dev/null +++ b/1-xcm-hyperbridge/frontend/app/wallet/page.tsx @@ -0,0 +1,12 @@ +"use client"; +import SigpassKit from "@/components/sigpasskit"; +import Link from "next/link"; + +export default function WalletPage() { + return ( +
+

Wallet

+ +
+ ); +} \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/components.json b/1-xcm-hyperbridge/frontend/components.json new file mode 100644 index 00000000..6bb595e0 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/1-xcm-hyperbridge/frontend/components/copy-button.tsx b/1-xcm-hyperbridge/frontend/components/copy-button.tsx new file mode 100644 index 00000000..89ec8426 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/copy-button.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/components/ui/button"; +import { Copy, Check } from "lucide-react"; +import { useState } from "react"; +import { Address } from "viem"; + +export default function CopyButton({ + copyText +}: { + copyText: Address | string | null; +}) { + const [isCopied, setIsCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(copyText ? copyText : ""); + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, 1000); + }; + + return ( + + ) +} diff --git a/1-xcm-hyperbridge/frontend/components/faucet-write-contract.tsx b/1-xcm-hyperbridge/frontend/components/faucet-write-contract.tsx new file mode 100644 index 00000000..82b7b6b1 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/faucet-write-contract.tsx @@ -0,0 +1,393 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + type BaseError, + useWaitForTransactionReceipt, + useConfig, + useWriteContract, + useReadContracts, + useAccount, + useChainId, + useSwitchChain, +} from "wagmi"; +import { formatUnits, Address } from "viem"; +import { + ExternalLink, + ChevronDown, + X, + Hash, + LoaderCircle, + CircleCheck, + DollarSign, + Droplets, + RefreshCw, + Info, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, +} from "@/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; + +import { truncateHash } from "@/lib/utils"; +import CopyButton from "@/components/copy-button"; +import { getSigpassWallet } from "@/lib/sigpass"; +import { useAtomValue } from "jotai"; +import { addressAtom } from "@/components/sigpasskit"; +import { Skeleton } from "./ui/skeleton"; +import { localConfig } from "@/app/providers"; +import { getBridgeConfig, isBridgeSupported } from "@/lib/bridge-config"; +import { erc20AbiExtend, dripFunctionAbi } from "@/lib/abi"; + +export default function FaucetWriteContract() { + const config = useConfig(); + const account = useAccount(); + const chainId = useChainId(); + const { switchChainAsync, isPending: isSwitchingChain } = useSwitchChain(); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = useState(false); + + const sigpassAddress = useAtomValue(addressAtom); + const activeAddress = sigpassAddress || account.address; + + const bridgeConfig = getBridgeConfig(chainId); + const tokenAddress = bridgeConfig?.defaultBridgeToken as Address; + const faucetAddress = bridgeConfig?.tokenFaucet as Address; + const isSupported = isBridgeSupported(chainId) && !!faucetAddress; + + const { + data: hash, + error, + isPending, + writeContractAsync, + reset, + } = useWriteContract({ + config: sigpassAddress ? localConfig : config, + }); + + const { data: tokenData, refetch } = useReadContracts({ + contracts: [ + { + address: tokenAddress, + abi: erc20AbiExtend, + functionName: "balanceOf", + args: [activeAddress as Address], + chainId: chainId as 420420420 | 11155111 | 97 | 11155420, + }, + { + address: tokenAddress, + abi: erc20AbiExtend, + functionName: "decimals", + chainId: chainId as 420420420 | 11155111 | 97 | 11155420, + }, + { + address: tokenAddress, + abi: erc20AbiExtend, + functionName: "symbol", + chainId: chainId as 420420420 | 11155111 | 97 | 11155420, + }, + ], + config: sigpassAddress ? localConfig : config, + query: { + enabled: !!activeAddress && !!tokenAddress && isSupported, + }, + }); + + const balance = tokenData?.[0]?.result as bigint | undefined; + const decimals = tokenData?.[1]?.result as number | undefined; + const tokenSymbol = tokenData?.[2]?.result as string | undefined; + + async function handleFaucet() { + if (!faucetAddress || !tokenAddress) return; + + try { + const writeConfig = sigpassAddress + ? { account: await getSigpassWallet() } + : {}; + await writeContractAsync({ + ...writeConfig, + address: faucetAddress, + abi: dripFunctionAbi, + functionName: "drip", + args: [tokenAddress], + chainId: chainId as 420420420 | 11155111 | 97 | 11155420, + }); + } catch (error) { + console.error("Faucet error:", error); + } + } + + useEffect(() => { + if (hash) setOpen(true); + }, [hash]); + + const { isLoading: isConfirming, isSuccess: isConfirmed } = + useWaitForTransactionReceipt({ + hash, + config: sigpassAddress ? localConfig : config, + }); + + useEffect(() => { + if (isConfirmed) refetch(); + }, [isConfirmed, refetch]); + + async function handleSwitchToOptimism() { + try { + await switchChainAsync({ chainId: 11155420 }); + } catch (error) { + console.error("Failed to switch chain:", error); + } + } + + // Unsupported chain + if (!isSupported) { + return ( +
+
+

USDC Faucet

+

+ Get test USDC tokens for bridging +

+
+ +
+ +

+ Faucet is available on Optimism Sepolia and Ethereum Sepolia +

+ +
+
+ ); + } + + const getExplorerUrl = (txHash: string) => { + const explorer = config.chains?.find((c) => c.id === chainId) + ?.blockExplorers?.default?.url; + return explorer + ? `${explorer}/tx/${txHash}` + : `https://etherscan.io/tx/${txHash}`; + }; + + return ( +
+ {/* Header */} +
+

USDC Faucet

+

+ Get test USDC tokens for bridging +

+
+ + {/* Balance Card */} +
+
+
+ +
+ + Your Balance + + {balance !== undefined && decimals ? ( + + {formatUnits(balance, decimals)} {tokenSymbol || "USDC"} + + ) : ( + + )} +
+
+ +
+ +
+ Token +
+ + {truncateHash(tokenAddress)} + + +
+
+ + + + {!activeAddress && ( +

+ Connect wallet to request tokens +

+ )} +
+ + {/* Info */} +
+

+ Request test USDC to use with the Hyperbridge token bridge. Tokens are + for testnet use only. +

+
+ + {/* Transaction Status */} + {isDesktop ? ( + + + + + + + Faucet Transaction + Transaction status + +
+ {hash && ( + + )} + {isConfirming && ( +
+ {" "} + Confirming... +
+ )} + {isConfirmed && ( +
+ USDC received! +
+ )} + {error && ( +
+ {" "} + {(error as BaseError).shortMessage || error.message} +
+ )} +
+ + + + + +
+
+ ) : ( + + + + + + + Faucet Transaction + Transaction status + +
+ {hash && ( + + )} + {isConfirming && ( +
+ {" "} + Confirming... +
+ )} + {isConfirmed && ( +
+ USDC received! +
+ )} + {error && ( +
+ {" "} + {(error as BaseError).shortMessage || error.message} +
+ )} +
+ + + + + +
+
+ )} +
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/components/navbar.tsx b/1-xcm-hyperbridge/frontend/components/navbar.tsx new file mode 100644 index 00000000..a016bba5 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/navbar.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; + +export default function Navbar() { + return ( + + ); +} diff --git a/1-xcm-hyperbridge/frontend/components/portfolio-card.tsx b/1-xcm-hyperbridge/frontend/components/portfolio-card.tsx new file mode 100644 index 00000000..588ebe7e --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/portfolio-card.tsx @@ -0,0 +1,17 @@ +import { useBalance } from 'wagmi'; +import { formatEther } from 'viem'; +import { Button } from '@/components/ui/button'; +import { RefreshCcw } from 'lucide-react'; + +export default function PortfolioCard() { + const { data: balance, refetch } = useBalance(); + return ( +
+

Balance

+

{balance?.value ? formatEther(balance.value) : '0'}

+ +
+ ) +} \ No newline at end of file diff --git a/1-xcm-hyperbridge/frontend/components/send-transaction.tsx b/1-xcm-hyperbridge/frontend/components/send-transaction.tsx new file mode 100644 index 00000000..12093fc5 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/send-transaction.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + type BaseError, + useSendTransaction, + useWaitForTransactionReceipt, + useConfig +} from "wagmi"; +import { parseEther, isAddress, Address } from "viem"; +import { + Ban, + ExternalLink, + ChevronDown, + X, + Hash, + LoaderCircle, + CircleCheck, +} from "lucide-react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, +} from "@/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { truncateHash } from "@/lib/utils"; +import CopyButton from "@/components/copy-button"; +import { getSigpassWallet } from "@/lib/sigpass"; +import { useAtomValue } from 'jotai'; +import { addressAtom } from '@/components/sigpasskit'; +import { localConfig, paseoTestnet } from '@/app/providers'; + +// form schema for sending transaction +const formSchema = z.object({ + // address is a required field + address: z + .string() + .min(2) + .max(50) + .refine((val) => val === "" || isAddress(val), { + message: "Invalid Ethereum address format", + }) as z.ZodType
, + // amount is a required field + amount: z + .string() + .refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { + message: "Amount must be a positive number", + }) + .refine((val) => /^\d*\.?\d{0,18}$/.test(val), { + message: "Amount cannot have more than 18 decimal places", + }), +}); + +export default function SendTransaction() { + + // useConfig hook to get config + const config = useConfig(); + + // useMediaQuery hook to check if the screen is desktop + const isDesktop = useMediaQuery("(min-width: 768px)"); + // useState hook to open/close dialog/drawer + const [open, setOpen] = useState(false); + + // get the address from session storage + const address = useAtomValue(addressAtom) + + // useSendTransaction hook to send transaction + const { + data: hash, + error, + isPending, + sendTransactionAsync, + } = useSendTransaction({ + config: address ? localConfig : config, + }); + + + // 1. Define your form. + const form = useForm>({ + // resolver is zodResolver + resolver: zodResolver(formSchema), + // default values for address and amount + defaultValues: { + address: "", + amount: "", + }, + }); + + + // 2. Define a submit handler. + async function onSubmit(values: z.infer) { + if (address) { + sendTransactionAsync({ + account: await getSigpassWallet(), + to: values.address as Address, + value: parseEther(values.amount), + chainId: paseoTestnet.id, + }); + } else { + // Fallback to connected wallet + sendTransactionAsync({ + to: values.address as Address, + value: parseEther(values.amount), + }); + } + } + + // Watch for transaction hash and open dialog/drawer when received + useEffect(() => { + if (hash) { + setOpen(true); + } + }, [hash]); + + + // useWaitForTransactionReceipt hook to wait for transaction receipt + const { isLoading: isConfirming, isSuccess: isConfirmed } = + useWaitForTransactionReceipt({ + hash, + config: address ? localConfig : config, + }); + + + return ( +
+
+ + ( + + Receiving Address + + + + The address to send PAS to. + + + )} + /> + ( + + Amount + + {isDesktop ? ( + + ) : ( + + )} + + The amount of PAS to send. + + + )} + /> + { + isPending ? ( + + ) : ( + + ) + } + + + { + // Desktop would be using dialog + isDesktop ? ( + + + + + + + Transaction status + + + Follow the transaction status below. + +
+ {hash ? ( +
+ + Transaction Hash + + {truncateHash(hash)} + + + +
+ ) : ( +
+ + No transaction hash +
+ )} + { + !isPending && !isConfirmed && !isConfirming && ( +
+ No transaction submitted +
+ ) + } + {isConfirming && ( +
+ Waiting + for confirmation... +
+ )} + {isConfirmed && ( +
+ Transaction confirmed! +
+ )} + {error && ( +
+ Error:{" "} + {(error as BaseError).shortMessage || error.message} +
+ )} +
+ + + + + +
+
+ ) : ( + // Mobile would be using drawer + + + + + + + Transaction status + + Follow the transaction status below. + + +
+ {hash ? ( +
+ + Transaction Hash + + {truncateHash(hash)} + + + +
+ ) : ( +
+ + No transaction hash +
+ )} + { + !isPending && !isConfirmed && !isConfirming && ( +
+ No transaction submitted +
+ ) + } + {isConfirming && ( +
+ Waiting + for confirmation... +
+ )} + {isConfirmed && ( +
+ Transaction confirmed! +
+ )} + {error && ( +
+ Error:{" "} + {(error as BaseError).shortMessage || error.message} +
+ )} +
+ + + + + +
+
+ ) + } +
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/components/sigpasskit.tsx b/1-xcm-hyperbridge/frontend/components/sigpasskit.tsx new file mode 100644 index 00000000..b638997e --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/sigpasskit.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { useState, useEffect } from "react"; +import '@rainbow-me/rainbowkit/styles.css'; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { Copy, Check, KeyRound, Ban, ExternalLink, LogOut, ChevronDown, X } from 'lucide-react'; +import { formatEther, Address } from 'viem'; +import { createSigpassWallet, getSigpassWallet, checkSigpassWallet, checkBrowserWebAuthnSupport } from "@/lib/sigpass"; +import { ConnectButton } from '@rainbow-me/rainbowkit'; +import { useAccount, useBalance, createConfig, http, useConfig } from 'wagmi'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import Image from 'next/image'; +import { useAtom } from 'jotai'; +import { atomWithStorage, RESET } from 'jotai/utils'; +import { paseoTestnet } from '@/app/providers'; + + +// Set the string key and the initial value +export const addressAtom = atomWithStorage
('SIGPASS_ADDRESS', undefined) + +// create a local config for the wallet +const localConfig = createConfig({ + chains: [paseoTestnet], + transports: { + [paseoTestnet.id]: http(), + }, + ssr: true, +}); + +export default function SigpassKit() { + const [wallet, setWallet] = useState(false); + const [open, setOpen] = useState(false); + const [webAuthnSupport, setWebAuthnSupport] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)") + const account = useAccount(); + const [address, setAddress] = useAtom(addressAtom); + const [isCopied, setIsCopied] = useState(false); + const config = useConfig(); + const { data: balance } = useBalance({ + address: address, + chainId: paseoTestnet.id, + config: address ? localConfig : config, + }); + + // check if the wallet is already created + useEffect(() => { + async function fetchWalletStatus() { + const status = await checkSigpassWallet(); + setWallet(status); + } + fetchWalletStatus(); + }, []); + + // check if the browser supports WebAuthn + useEffect(() => { + const support = checkBrowserWebAuthnSupport(); + setWebAuthnSupport(support); + }, []); + + // get the wallet + async function getWallet() { + const account = await getSigpassWallet(); + if (account) { + setAddress(account.address); + } else { + console.error('Issue getting wallet'); + } + } + + // create a wallet + async function createWallet() { + const account = await createSigpassWallet("dapp"); + if (account) { + setOpen(false); + setWallet(true); + } + } + + // truncate address to 6 characters and add ... at the end + function truncateAddress(address: Address, length: number = 4) { + return `${address.slice(0, length)}...${address.slice(-length)}`; + } + + // copy the address to the clipboard + function copyAddress() { + if (address) { + navigator.clipboard.writeText(address ? address : ""); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1000); + } + } + + // disconnect the wallet + function disconnect() { + setAddress(undefined); + setOpen(false); + setAddress(RESET); + } + + + if (isDesktop) { + return ( +
+ {!wallet && !account.isConnected && !address ? ( + + + + + + + Create Wallet + + Instantly get a wallet with Passkey + + +
+
+

What is a Wallet?

+
+ icon-1 +
+

A Home for your Digital Assets

+

Wallets are used to send, receive, store, and display digital assets like Polkadot and NFTs.

+
+
+
+ icon-2 +
+

A new way to Log In

+

Instead of creating new accounts and passwords on every website, just connect your wallet.

+
+
+
+
+ +
+ Learn more + { + webAuthnSupport ? ( + + ) : ( + + ) + } +
+
+
+ Powered by Sigpass +
+
+
+ ) : wallet && !account.isConnected && address === undefined ? ( + + ) : wallet && !account.isConnected && address ? + + + + + + + Wallet + + + {truncateAddress(address, 4)} + +
+ {balance ? `${formatEther(balance.value)} PAS` : } +
+
+ + +
+
+
+ : null} + { + !address ? : null + } +
+ ) + } + + return ( +
+ {(!wallet && !account.isConnected && !address) ? ( + + + + + + + Create Wallet + + Instantly get a wallet with Passkey + + +
+
+

What is a Wallet?

+
+ icon-1 +
+

A Home for your Digital Assets

+

Wallets are used to send, receive, store, and display digital assets like Polkadot and NFTs.

+
+
+
+ icon-2 +
+

A new way to Log In

+

Instead of creating new accounts and passwords on every website, just connect your wallet.

+
+
+ Learn more +
+
+ + {webAuthnSupport ? ( + + ) : ( + + )} + + + +
+ Powered by Sigpass +
+
+
+
+ ) : wallet && !account.isConnected && address === undefined ? ( + + ) : wallet && !account.isConnected && address ? ( + + + + + + +
+ Wallet + + + +
+ + {truncateAddress(address, 4)} + +
+
+
+ {balance ? `${formatEther(balance.value)} PAS` : } +
+
+ + +
+
+
+
+ ) : null} + {!address ? : null} +
+ ) +} + diff --git a/1-xcm-hyperbridge/frontend/components/string-copy-button.tsx b/1-xcm-hyperbridge/frontend/components/string-copy-button.tsx new file mode 100644 index 00000000..7909de41 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/string-copy-button.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; +import { Copy, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Address as EvmAddress } from "viem"; + + +export default function StringCopyButton({ + copyText, + buttonTitle, +}: { + copyText: EvmAddress | string | null; + buttonTitle: string; +}) { + const [isCopied, setIsCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(copyText ? copyText : ""); + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, 1000); + }; + + return ( + + ); +} diff --git a/1-xcm-hyperbridge/frontend/components/token-bridge.tsx b/1-xcm-hyperbridge/frontend/components/token-bridge.tsx new file mode 100644 index 00000000..b517e93b --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/token-bridge.tsx @@ -0,0 +1,778 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { + type BaseError, + useWaitForTransactionReceipt, + useConfig, + useWriteContract, + useReadContracts, + useAccount, + useSwitchChain, +} from "wagmi"; +import { parseUnits, formatUnits, isAddress, Address, toHex } from "viem"; +import { + ExternalLink, + ChevronDown, + X, + Hash, + LoaderCircle, + CircleCheck, + ArrowRight, + Wallet, + RefreshCw, + Info, + Clock, + DollarSign, +} from "lucide-react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogFooter, + DialogTitle, + DialogClose, +} from "@/components/ui/dialog"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import { truncateHash } from "@/lib/utils"; +import CopyButton from "@/components/copy-button"; +import { getSigpassWallet } from "@/lib/sigpass"; +import { useAtomValue } from "jotai"; +import { addressAtom } from "@/components/sigpasskit"; +import { Skeleton } from "./ui/skeleton"; +import { + localConfig, + bridgeNetworkPairs, + type BridgeNetworkPair, +} from "@/app/providers"; +import { + bridgeConfigs, + chainIdentifiers, + DEFAULT_RELAYER_FEE, + DEFAULT_TIMEOUT, + isBridgeSupported, +} from "@/lib/bridge-config"; +import { erc20AbiExtend, tokenBridgeAbi } from "@/lib/abi"; + +type SupportedChainId = 420420420 | 11155111 | 97 | 11155420; + +export default function TokenBridge() { + const config = useConfig(); + const account = useAccount(); + const { switchChainAsync, isPending: isSwitchingChain } = useSwitchChain(); + const isDesktop = useMediaQuery("(min-width: 768px)"); + + const [open, setOpen] = useState(false); + const [selectedPairIndex, setSelectedPairIndex] = useState(2); + const [needsApproval, setNeedsApproval] = useState(true); + + const sigpassAddress = useAtomValue(addressAtom); + const activeAddress = sigpassAddress || account.address; + const selectedPair: BridgeNetworkPair = bridgeNetworkPairs[selectedPairIndex]; + + const { + data: approveHash, + error: approveError, + isPending: isApprovePending, + writeContractAsync: writeApproveAsync, + reset: resetApprove, + } = useWriteContract({ + config: sigpassAddress ? localConfig : config, + }); + + const { + data: bridgeHash, + error: bridgeError, + isPending: isBridgePending, + writeContractAsync: writeBridgeAsync, + reset: resetBridge, + } = useWriteContract({ + config: sigpassAddress ? localConfig : config, + }); + + const sourceBridgeConfig = bridgeConfigs[selectedPair.source.id]; + const bridgeContract = sourceBridgeConfig?.tokenBridge; + const usdcToken = sourceBridgeConfig?.defaultBridgeToken; + + const { + data: tokenData, + refetch: refetchTokenData, + isLoading: isLoadingTokenData, + } = useReadContracts({ + contracts: [ + { + address: usdcToken as Address, + abi: erc20AbiExtend, + functionName: "balanceOf", + args: [activeAddress as Address], + chainId: selectedPair.source.id as SupportedChainId, + }, + { + address: usdcToken as Address, + abi: erc20AbiExtend, + functionName: "decimals", + chainId: selectedPair.source.id as SupportedChainId, + }, + { + address: usdcToken as Address, + abi: erc20AbiExtend, + functionName: "symbol", + chainId: selectedPair.source.id as SupportedChainId, + }, + { + address: usdcToken as Address, + abi: erc20AbiExtend, + functionName: "allowance", + args: [activeAddress as Address, bridgeContract as Address], + chainId: selectedPair.source.id as SupportedChainId, + }, + ], + config: sigpassAddress ? localConfig : config, + query: { + enabled: + !!activeAddress && + !!usdcToken && + !!bridgeContract && + isBridgeSupported(selectedPair.source.id), + }, + }); + + const tokenBalance = tokenData?.[0]?.result as bigint | undefined; + const tokenDecimals = tokenData?.[1]?.result as number | undefined; + const tokenSymbol = tokenData?.[2]?.result as string | undefined; + const tokenAllowance = tokenData?.[3]?.result as bigint | undefined; + + const formSchema = useMemo( + () => + z.object({ + recipient: z + .string() + .min(2) + .max(50) + .refine((val) => val === "" || isAddress(val), { + message: "Invalid address format", + }) as z.ZodType
, + amount: z + .string() + .refine((val) => !isNaN(parseFloat(val)) && parseFloat(val) > 0, { + message: "Amount must be a positive number", + }) + .refine((val) => /^\d*\.?\d{0,18}$/.test(val), { + message: "Amount cannot have more than 18 decimal places", + }) + .superRefine((val, ctx) => { + if (!tokenBalance || !tokenDecimals) return; + const inputAmount = parseUnits(val, tokenDecimals); + if (inputAmount > tokenBalance) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Amount exceeds available balance", + }); + } + }), + }), + [tokenBalance, tokenDecimals] + ); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + recipient: "", + amount: "", + }, + }); + + useEffect(() => { + if (bridgeHash) setOpen(true); + }, [bridgeHash]); + + const { isLoading: isApproveConfirming, isSuccess: isApproveConfirmed } = + useWaitForTransactionReceipt({ + hash: approveHash, + config: sigpassAddress ? localConfig : config, + }); + + const { isLoading: isBridgeConfirming, isSuccess: isBridgeConfirmed } = + useWaitForTransactionReceipt({ + hash: bridgeHash, + config: sigpassAddress ? localConfig : config, + }); + + useEffect(() => { + if (isBridgeConfirmed) refetchTokenData(); + }, [isBridgeConfirmed, refetchTokenData]); + + useEffect(() => { + const subscription = form.watch((value) => { + if (!value.amount || !tokenDecimals || !tokenAllowance) { + setNeedsApproval(true); + return; + } + try { + const amount = parseUnits(value.amount, tokenDecimals); + setNeedsApproval(tokenAllowance < amount); + } catch { + setNeedsApproval(true); + } + }); + return () => subscription.unsubscribe(); + }, [form, tokenDecimals, tokenAllowance]); + + useEffect(() => { + if (isApproveConfirmed) refetchTokenData(); + }, [isApproveConfirmed, refetchTokenData]); + + useEffect(() => { + if ( + isApproveConfirmed && + !bridgeHash && + !isBridgePending && + !needsApproval + ) { + setTimeout(() => executeBridge(), 100); + } + }, [isApproveConfirmed, needsApproval, bridgeHash, isBridgePending]); + + const isOnCorrectChain = account.chainId === selectedPair.source.id; + + async function handleSwitchChain() { + try { + await switchChainAsync({ chainId: selectedPair.source.id }); + } catch (error) { + console.error("Failed to switch chain:", error); + } + } + + async function onSubmit(values: z.infer) { + if (!activeAddress || !tokenDecimals || !bridgeContract || !usdcToken) + return; + const amount = parseUnits(values.amount, tokenDecimals); + + try { + const requiresApproval = !tokenAllowance || tokenAllowance < amount; + if (requiresApproval) { + const writeConfig = sigpassAddress + ? { account: await getSigpassWallet() } + : {}; + await writeApproveAsync({ + ...writeConfig, + address: usdcToken as Address, + abi: erc20AbiExtend, + functionName: "approve", + args: [bridgeContract, amount], + chainId: selectedPair.source.id as SupportedChainId, + }); + } else { + await executeBridge(); + } + } catch (error) { + console.error("Transaction failed:", error); + } + } + + async function executeBridge() { + const values = form.getValues(); + if ( + !activeAddress || + !tokenDecimals || + !bridgeContract || + !tokenSymbol || + !usdcToken + ) + return; + + const amount = parseUnits(values.amount, tokenDecimals); + const destChainId = chainIdentifiers[selectedPair.destination.id]; + const destChainBytes = toHex(destChainId || ""); + + try { + const writeConfig = sigpassAddress + ? { account: await getSigpassWallet() } + : {}; + await writeBridgeAsync({ + ...writeConfig, + address: bridgeContract, + abi: tokenBridgeAbi, + functionName: "bridgeTokensWithFee", + args: [ + usdcToken as Address, + tokenSymbol, + amount, + values.recipient as Address, + destChainBytes as `0x${string}`, + DEFAULT_RELAYER_FEE, + DEFAULT_TIMEOUT, + ], + chainId: selectedPair.source.id as SupportedChainId, + }); + } catch (error) { + console.error("Bridge failed:", error); + } + } + + function resetTransaction() { + form.reset(); + resetApprove(); + resetBridge(); + setOpen(false); + } + + function getExplorerUrl(hash: string, chainId: number) { + const chain = bridgeNetworkPairs.find( + (p) => p.source.id === chainId || p.destination.id === chainId + ); + const targetChain = + chain?.source.id === chainId ? chain.source : chain?.destination; + return targetChain?.blockExplorers?.default?.url + ? `${targetChain.blockExplorers.default.url}/tx/${hash}` + : `https://etherscan.io/tx/${hash}`; + } + + const TransactionStatusContent = () => ( +
+
+
+ + 1 + + Token Approval +
+ {approveHash && ( + + )} + {isApprovePending && ( +
+ Confirm in + wallet... +
+ )} + {isApproveConfirming && ( +
+ Confirming... +
+ )} + {isApproveConfirmed && ( +
+ Approved +
+ )} + {approveError && ( +
+ {" "} + {(approveError as BaseError).shortMessage || approveError.message} +
+ )} +
+ +
+
+ + 2 + + Bridge Transaction +
+ {bridgeHash && ( + + )} + {!approveHash && !bridgeHash && !isBridgePending && ( +
+ Waiting for approval... +
+ )} + {isBridgePending && ( +
+ Confirm in + wallet... +
+ )} + {isBridgeConfirming && ( +
+ Confirming... +
+ )} + {isBridgeConfirmed && ( +
+ Submitted +
+ )} + {bridgeError && ( +
+ {" "} + {(bridgeError as BaseError).shortMessage || bridgeError.message} +
+ )} +
+ + {isBridgeConfirmed && ( +
+
+ + Bridge Initiated +
+

+ Your USDC is being bridged to {selectedPair.destination.name}. This + typically takes 10-30 minutes. +

+ + Track on Hyperbridge Explorer + +
+ )} +
+ ); + + const isChainSupported = isBridgeSupported(selectedPair.source.id); + + return ( +
+
+

Bridge USDC

+

+ Transfer USDC across chains via Hyperbridge +

+
+ +
+ + +
+
+ From + + {selectedPair.source.name} + +
+ +
+ To + + {selectedPair.destination.name} + +
+
+
+ + {!isChainSupported ? ( +
+ +

+ This route is not yet available. Please select another route. +

+
+ ) : !activeAddress ? ( +
+ +

+ Connect your wallet to bridge tokens +

+
+ ) : !isOnCorrectChain && !sigpassAddress ? ( +
+ +

+ Switch to {selectedPair.source.name} to continue +

+ +
+ ) : ( + <> +
+
+ +
+ Available + {isLoadingTokenData ? ( + + ) : ( + + {tokenBalance && tokenDecimals + ? `${formatUnits(tokenBalance, tokenDecimals)} ${ + tokenSymbol || "USDC" + }` + : "0 USDC"} + + )} +
+
+ +
+ +
+ + ( + + Recipient + + + + + Address on {selectedPair.destination.name} + + + + )} + /> + + ( + + Amount + +
+ {isDesktop ? ( + + ) : ( + + )} + {tokenBalance && tokenDecimals && ( + + )} +
+
+ +
+ )} + /> + +
+
+ + Relayer Fee + + 1 USDC +
+
+ + Est. Time + + 10-30 min +
+
+ + + + + + )} + + {isDesktop ? ( + + + + + Transaction Status + + Track your bridge transaction progress + + + + + {isBridgeConfirmed && ( + + )} + + + + + + + ) : ( + + + + + Transaction Status + + Track your bridge transaction progress + + +
+ +
+ + {isBridgeConfirmed && ( + + )} + + + + +
+
+ )} +
+ ); +} diff --git a/1-xcm-hyperbridge/frontend/components/ui/button.tsx b/1-xcm-hyperbridge/frontend/components/ui/button.tsx new file mode 100644 index 00000000..65d4fcd9 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/1-xcm-hyperbridge/frontend/components/ui/dialog.tsx b/1-xcm-hyperbridge/frontend/components/ui/dialog.tsx new file mode 100644 index 00000000..01ff19c7 --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/1-xcm-hyperbridge/frontend/components/ui/drawer.tsx b/1-xcm-hyperbridge/frontend/components/ui/drawer.tsx new file mode 100644 index 00000000..6a0ef53d --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/1-xcm-hyperbridge/frontend/components/ui/field.tsx b/1-xcm-hyperbridge/frontend/components/ui/field.tsx new file mode 100644 index 00000000..0a276fcb --- /dev/null +++ b/1-xcm-hyperbridge/frontend/components/ui/field.tsx @@ -0,0 +1,244 @@ +"use client" + +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + "group/field data-[invalid=true]:text-destructive flex w-full gap-3", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +