Add multi-chain support for EVM networks (#90)
* Initial plan * Add multi-chain configuration and update hardhat config Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Add comprehensive tests and documentation for multi-chain support Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Add cross-chain verification support to API and registry service Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Add comprehensive multi-chain deployment documentation Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Fix chain ID example in documentation to avoid confusion Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> * Address code review feedback: add parameter validation and error logging Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: onnwee <211922112+onnwee@users.noreply.github.com>
This commit was merged in pull request #90.
This commit is contained in:
24
.env.example
24
.env.example
@@ -2,6 +2,30 @@
|
||||
PRIVATE_KEY=
|
||||
RPC_URL=https://sepolia.base.org
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Multi-Chain RPC Configuration (Optional - defaults provided in config/chains.ts)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Ethereum networks
|
||||
# ETHEREUM_RPC_URL=https://eth.llamarpc.com
|
||||
# SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
|
||||
|
||||
# Polygon networks
|
||||
# POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
# POLYGON_AMOY_RPC_URL=https://rpc-amoy.polygon.technology
|
||||
|
||||
# Base networks (Coinbase L2)
|
||||
# BASE_RPC_URL=https://mainnet.base.org
|
||||
# BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
|
||||
|
||||
# Arbitrum networks
|
||||
# ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc
|
||||
# ARBITRUM_SEPOLIA_RPC_URL=https://sepolia-rollup.arbitrum.io/rpc
|
||||
|
||||
# Optimism networks
|
||||
# OPTIMISM_RPC_URL=https://mainnet.optimism.io
|
||||
# OPTIMISM_SEPOLIA_RPC_URL=https://sepolia.optimism.io
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SSL/TLS Configuration (Production)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
91
README.md
91
README.md
@@ -130,12 +130,18 @@ View the [CI workflow configuration](.github/workflows/ci.yml) and [workflow run
|
||||
1. Install deps
|
||||
2. Configure `.env` (see `.env.example`):
|
||||
- `PRIVATE_KEY` of the deployer/creator account
|
||||
- `RPC_URL` for Base Sepolia (or your preferred network)
|
||||
- `RPC_URL` for your preferred network (e.g., Base Sepolia, Ethereum Mainnet, Polygon, etc.)
|
||||
- Optional: Chain-specific RPC URLs (e.g., `ETHEREUM_RPC_URL`, `POLYGON_RPC_URL`) to override defaults
|
||||
- `IPFS_API_URL` and optional `IPFS_PROJECT_ID`/`IPFS_PROJECT_SECRET` for IPFS uploads
|
||||
|
||||
- Optional: `API_KEY` to require `x-api-key` on sensitive endpoints
|
||||
- Database: by default uses SQLite via `DATABASE_URL=file:./dev.db`. For Postgres, see below.
|
||||
|
||||
**Note on Multi-Chain Deployments:**
|
||||
- Each network requires a separate deployment of the ContentRegistry contract
|
||||
- Deployed addresses are saved in `deployed/<network>.json` files
|
||||
- The registry service automatically resolves the correct contract address based on the chain ID
|
||||
|
||||
### Web app env
|
||||
|
||||
If you plan to use the included web UI (`web/`), set:
|
||||
@@ -149,12 +155,62 @@ If you plan to use the included web UI (`web/`), set:
|
||||
- `NEXTAUTH_URL` (e.g., `http://localhost:3000`)
|
||||
- `NEXTAUTH_SECRET` (generate a random string)
|
||||
|
||||
## Multi-Chain Support
|
||||
|
||||
Internet-ID supports deployment and verification across multiple EVM-compatible chains:
|
||||
|
||||
### Supported Networks
|
||||
|
||||
**Mainnets (Production):**
|
||||
- **Ethereum Mainnet** (chain ID: 1) – High security, higher gas costs
|
||||
- **Polygon** (chain ID: 137) – Low cost, good UX, MATIC gas token
|
||||
- **Base** (chain ID: 8453) – Coinbase L2, low cost, good UX
|
||||
- **Arbitrum One** (chain ID: 42161) – Low cost L2
|
||||
- **Optimism** (chain ID: 10) – Low cost L2
|
||||
|
||||
**Testnets (Development):**
|
||||
- **Ethereum Sepolia** (chain ID: 11155111)
|
||||
- **Polygon Amoy** (chain ID: 80002)
|
||||
- **Base Sepolia** (chain ID: 84532)
|
||||
- **Arbitrum Sepolia** (chain ID: 421614)
|
||||
- **Optimism Sepolia** (chain ID: 11155420)
|
||||
|
||||
### Chain Configuration
|
||||
|
||||
Chain configurations are defined in `config/chains.ts` with:
|
||||
- RPC URLs (with environment variable overrides)
|
||||
- Block explorer URLs
|
||||
- Native currency details
|
||||
- Gas settings
|
||||
|
||||
You can override default RPC URLs via environment variables:
|
||||
```bash
|
||||
ETHEREUM_RPC_URL=https://your-eth-rpc.com
|
||||
POLYGON_RPC_URL=https://your-polygon-rpc.com
|
||||
BASE_RPC_URL=https://your-base-rpc.com
|
||||
# See .env.example for all options
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
- `build` – compile contracts
|
||||
- `deploy:base-sepolia` – deploy `ContentRegistry` to Base Sepolia
|
||||
|
||||
**Deployment Scripts (Multi-Chain):**
|
||||
- `deploy:ethereum` – deploy to Ethereum Mainnet
|
||||
- `deploy:sepolia` – deploy to Ethereum Sepolia testnet
|
||||
- `deploy:polygon` – deploy to Polygon
|
||||
- `deploy:polygon-amoy` – deploy to Polygon Amoy testnet
|
||||
- `deploy:base` – deploy to Base
|
||||
- `deploy:base-sepolia` – deploy to Base Sepolia testnet
|
||||
- `deploy:arbitrum` – deploy to Arbitrum One
|
||||
- `deploy:arbitrum-sepolia` – deploy to Arbitrum Sepolia testnet
|
||||
- `deploy:optimism` – deploy to Optimism
|
||||
- `deploy:optimism-sepolia` – deploy to Optimism Sepolia testnet
|
||||
- `deploy:local` – deploy to local Hardhat node
|
||||
|
||||
**Other Scripts:**
|
||||
- `register` – hash a file and register its hash + manifest URI on-chain
|
||||
- `RPC_URL` for Base Sepolia (or your preferred network). For local, you can use `LOCAL_RPC_URL=http://127.0.0.1:8545`.
|
||||
- `RPC_URL` for your preferred network. For local, you can use `LOCAL_RPC_URL=http://127.0.0.1:8545`.
|
||||
- For IPFS uploads: `IPFS_API_URL` and optional `IPFS_PROJECT_ID`/`IPFS_PROJECT_SECRET`
|
||||
- `verify` – verify a file against its manifest and on-chain registry
|
||||
- `bind:youtube` – bind a YouTube videoId to a previously registered master file
|
||||
@@ -176,15 +232,23 @@ If you plan to use the included web UI (`web/`), set:
|
||||
|
||||
1. Compile and deploy
|
||||
|
||||
```
|
||||
```bash
|
||||
npm i
|
||||
npx hardhat compile
|
||||
npx hardhat run --network baseSepolia scripts/deploy.ts
|
||||
|
||||
# Deploy to Base Sepolia (testnet)
|
||||
npm run deploy:base-sepolia
|
||||
|
||||
# Or deploy to other networks
|
||||
npm run deploy:polygon-amoy # Polygon testnet
|
||||
npm run deploy:sepolia # Ethereum testnet
|
||||
npm run deploy:optimism-sepolia # Optimism testnet
|
||||
npm run deploy:arbitrum-sepolia # Arbitrum testnet
|
||||
```
|
||||
|
||||
Local node option (no faucets needed)
|
||||
|
||||
```
|
||||
```bash
|
||||
# Terminal A: start local node (prefunded accounts)
|
||||
npm run node
|
||||
|
||||
@@ -192,6 +256,21 @@ npm run node
|
||||
npm run deploy:local
|
||||
```
|
||||
|
||||
**Production Deployments:**
|
||||
|
||||
For mainnet deployments, ensure you have:
|
||||
- Sufficient native tokens for gas (ETH, MATIC, etc.)
|
||||
- `PRIVATE_KEY` set in `.env`
|
||||
- Appropriate RPC URL configured
|
||||
|
||||
```bash
|
||||
npm run deploy:polygon # Polygon mainnet (low cost)
|
||||
npm run deploy:base # Base mainnet (low cost L2)
|
||||
npm run deploy:arbitrum # Arbitrum One (low cost L2)
|
||||
npm run deploy:optimism # Optimism (low cost L2)
|
||||
npm run deploy:ethereum # Ethereum mainnet (high cost, high security)
|
||||
```
|
||||
|
||||
2. Upload your content and manifest
|
||||
|
||||
```
|
||||
|
||||
183
config/chains.ts
Normal file
183
config/chains.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Multi-chain configuration for Internet-ID
|
||||
* Contains RPC URLs, block explorers, chain IDs, and other chain-specific settings
|
||||
*/
|
||||
|
||||
export interface ChainConfig {
|
||||
chainId: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
rpcUrl: string;
|
||||
blockExplorer: string;
|
||||
nativeCurrency: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
testnet: boolean;
|
||||
gasSettings?: {
|
||||
maxFeePerGas?: string;
|
||||
maxPriorityFeePerGas?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const SUPPORTED_CHAINS: Record<string, ChainConfig> = {
|
||||
// Ethereum
|
||||
ethereum: {
|
||||
chainId: 1,
|
||||
name: "ethereum",
|
||||
displayName: "Ethereum Mainnet",
|
||||
rpcUrl: process.env.ETHEREUM_RPC_URL || "https://eth.llamarpc.com",
|
||||
blockExplorer: "https://etherscan.io",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
sepolia: {
|
||||
chainId: 11155111,
|
||||
name: "sepolia",
|
||||
displayName: "Ethereum Sepolia",
|
||||
rpcUrl: process.env.SEPOLIA_RPC_URL || "https://ethereum-sepolia-rpc.publicnode.com",
|
||||
blockExplorer: "https://sepolia.etherscan.io",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Polygon
|
||||
polygon: {
|
||||
chainId: 137,
|
||||
name: "polygon",
|
||||
displayName: "Polygon",
|
||||
rpcUrl: process.env.POLYGON_RPC_URL || "https://polygon-rpc.com",
|
||||
blockExplorer: "https://polygonscan.com",
|
||||
nativeCurrency: { name: "MATIC", symbol: "MATIC", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
polygonAmoy: {
|
||||
chainId: 80002,
|
||||
name: "polygonAmoy",
|
||||
displayName: "Polygon Amoy",
|
||||
rpcUrl: process.env.POLYGON_AMOY_RPC_URL || "https://rpc-amoy.polygon.technology",
|
||||
blockExplorer: "https://amoy.polygonscan.com",
|
||||
nativeCurrency: { name: "MATIC", symbol: "MATIC", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Base
|
||||
base: {
|
||||
chainId: 8453,
|
||||
name: "base",
|
||||
displayName: "Base",
|
||||
rpcUrl: process.env.BASE_RPC_URL || "https://mainnet.base.org",
|
||||
blockExplorer: "https://basescan.org",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
baseSepolia: {
|
||||
chainId: 84532,
|
||||
name: "baseSepolia",
|
||||
displayName: "Base Sepolia",
|
||||
rpcUrl: process.env.BASE_SEPOLIA_RPC_URL || "https://sepolia.base.org",
|
||||
blockExplorer: "https://sepolia.basescan.org",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Arbitrum
|
||||
arbitrum: {
|
||||
chainId: 42161,
|
||||
name: "arbitrum",
|
||||
displayName: "Arbitrum One",
|
||||
rpcUrl: process.env.ARBITRUM_RPC_URL || "https://arb1.arbitrum.io/rpc",
|
||||
blockExplorer: "https://arbiscan.io",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
arbitrumSepolia: {
|
||||
chainId: 421614,
|
||||
name: "arbitrumSepolia",
|
||||
displayName: "Arbitrum Sepolia",
|
||||
rpcUrl: process.env.ARBITRUM_SEPOLIA_RPC_URL || "https://sepolia-rollup.arbitrum.io/rpc",
|
||||
blockExplorer: "https://sepolia.arbiscan.io",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Optimism
|
||||
optimism: {
|
||||
chainId: 10,
|
||||
name: "optimism",
|
||||
displayName: "Optimism",
|
||||
rpcUrl: process.env.OPTIMISM_RPC_URL || "https://mainnet.optimism.io",
|
||||
blockExplorer: "https://optimistic.etherscan.io",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
optimismSepolia: {
|
||||
chainId: 11155420,
|
||||
name: "optimismSepolia",
|
||||
displayName: "Optimism Sepolia",
|
||||
rpcUrl: process.env.OPTIMISM_SEPOLIA_RPC_URL || "https://sepolia.optimism.io",
|
||||
blockExplorer: "https://sepolia-optimism.etherscan.io",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Legacy localhost for testing
|
||||
localhost: {
|
||||
chainId: 31337,
|
||||
name: "localhost",
|
||||
displayName: "Localhost",
|
||||
rpcUrl: process.env.LOCAL_RPC_URL || "http://127.0.0.1:8545",
|
||||
blockExplorer: "",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get chain configuration by chain ID
|
||||
*/
|
||||
export function getChainById(chainId: number): ChainConfig | undefined {
|
||||
return Object.values(SUPPORTED_CHAINS).find((chain) => chain.chainId === chainId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chain configuration by name
|
||||
*/
|
||||
export function getChainByName(name: string): ChainConfig | undefined {
|
||||
return SUPPORTED_CHAINS[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mainnet chains
|
||||
*/
|
||||
export function getMainnetChains(): ChainConfig[] {
|
||||
return Object.values(SUPPORTED_CHAINS).filter((chain) => !chain.testnet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all testnet chains
|
||||
*/
|
||||
export function getTestnetChains(): ChainConfig[] {
|
||||
return Object.values(SUPPORTED_CHAINS).filter((chain) => chain.testnet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block explorer URL for a transaction
|
||||
*/
|
||||
export function getExplorerTxUrl(chainId?: number, txHash?: string): string | undefined {
|
||||
if (!chainId || !txHash) return undefined;
|
||||
const chain = getChainById(chainId);
|
||||
if (!chain || !chain.blockExplorer) return undefined;
|
||||
return `${chain.blockExplorer}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block explorer URL for an address
|
||||
*/
|
||||
export function getExplorerAddressUrl(chainId?: number, address?: string): string | undefined {
|
||||
if (!chainId || !address) return undefined;
|
||||
const chain = getChainById(chainId);
|
||||
if (!chain || !chain.blockExplorer) return undefined;
|
||||
return `${chain.blockExplorer}/address/${address}`;
|
||||
}
|
||||
329
docs/MULTI_CHAIN_DEPLOYMENT.md
Normal file
329
docs/MULTI_CHAIN_DEPLOYMENT.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Multi-Chain Deployment Guide
|
||||
|
||||
Internet-ID supports deployment across multiple EVM-compatible blockchain networks. This guide covers how to deploy, configure, and use the ContentRegistry contract on different chains.
|
||||
|
||||
## Supported Networks
|
||||
|
||||
### Production Networks (Mainnets)
|
||||
|
||||
| Network | Chain ID | Gas Token | Use Case | Cost |
|
||||
|---------|----------|-----------|----------|------|
|
||||
| Ethereum Mainnet | 1 | ETH | Maximum security | High |
|
||||
| Polygon | 137 | MATIC | Low cost, high throughput | Low |
|
||||
| Base | 8453 | ETH | Coinbase ecosystem, low cost | Low |
|
||||
| Arbitrum One | 42161 | ETH | Low cost L2 | Low |
|
||||
| Optimism | 10 | ETH | Low cost L2 | Low |
|
||||
|
||||
### Test Networks (Testnets)
|
||||
|
||||
| Network | Chain ID | Faucet | Explorer |
|
||||
|---------|----------|--------|----------|
|
||||
| Ethereum Sepolia | 11155111 | [Sepolia Faucet](https://sepoliafaucet.com/) | [Sepolia Etherscan](https://sepolia.etherscan.io) |
|
||||
| Polygon Amoy | 80002 | [Amoy Faucet](https://faucet.polygon.technology/) | [Amoy PolygonScan](https://amoy.polygonscan.com) |
|
||||
| Base Sepolia | 84532 | [Base Faucet](https://www.coinbase.com/faucets/base-ethereum-sepolia-faucet) | [Base Sepolia Scan](https://sepolia.basescan.org) |
|
||||
| Arbitrum Sepolia | 421614 | [Arbitrum Faucet](https://faucet.quicknode.com/arbitrum/sepolia) | [Arbiscan Sepolia](https://sepolia.arbiscan.io) |
|
||||
| Optimism Sepolia | 11155420 | [Optimism Faucet](https://app.optimism.io/faucet) | [Optimism Sepolia Scan](https://sepolia-optimism.etherscan.io) |
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Install Dependencies**
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
2. **Configure Environment Variables**
|
||||
|
||||
Create a `.env` file (copy from `.env.example`):
|
||||
```bash
|
||||
# Your deployer private key
|
||||
PRIVATE_KEY=your_private_key_here
|
||||
|
||||
# Default RPC URL (used by scripts)
|
||||
RPC_URL=https://sepolia.base.org
|
||||
|
||||
# Optional: Override RPC URLs for specific chains
|
||||
ETHEREUM_RPC_URL=https://your-eth-rpc.com
|
||||
POLYGON_RPC_URL=https://your-polygon-rpc.com
|
||||
BASE_RPC_URL=https://your-base-rpc.com
|
||||
ARBITRUM_RPC_URL=https://your-arbitrum-rpc.com
|
||||
OPTIMISM_RPC_URL=https://your-optimism-rpc.com
|
||||
```
|
||||
|
||||
3. **Compile Contracts**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Deploy to Testnet
|
||||
|
||||
Start with a testnet to ensure everything works correctly:
|
||||
|
||||
```bash
|
||||
# Base Sepolia (recommended for testing)
|
||||
npm run deploy:base-sepolia
|
||||
|
||||
# Or other testnets
|
||||
npm run deploy:sepolia # Ethereum Sepolia
|
||||
npm run deploy:polygon-amoy # Polygon Amoy
|
||||
npm run deploy:arbitrum-sepolia # Arbitrum Sepolia
|
||||
npm run deploy:optimism-sepolia # Optimism Sepolia
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
ContentRegistry deployed to: 0x1234567890123456789012345678901234567890
|
||||
Saved address to: /path/to/deployed/baseSepolia.json
|
||||
```
|
||||
|
||||
### Deploy to Mainnet
|
||||
|
||||
⚠️ **Warning**: Mainnet deployments cost real money. Ensure you have:
|
||||
- Sufficient gas tokens (ETH, MATIC, etc.)
|
||||
- Verified your deployment works on testnet
|
||||
- Reviewed gas costs for your target chain
|
||||
|
||||
```bash
|
||||
# Low-cost L2 options (recommended for most users)
|
||||
npm run deploy:polygon # Polygon mainnet (very low cost)
|
||||
npm run deploy:base # Base mainnet (low cost L2)
|
||||
npm run deploy:arbitrum # Arbitrum One (low cost L2)
|
||||
npm run deploy:optimism # Optimism mainnet (low cost L2)
|
||||
|
||||
# High-security option (expensive)
|
||||
npm run deploy:ethereum # Ethereum mainnet
|
||||
```
|
||||
|
||||
### Deployment File Structure
|
||||
|
||||
After deployment, the contract address is saved in `deployed/<network>.json`:
|
||||
|
||||
```
|
||||
deployed/
|
||||
├── ethereum.json # Ethereum Mainnet
|
||||
├── sepolia.json # Ethereum Sepolia
|
||||
├── polygon.json # Polygon
|
||||
├── polygonAmoy.json # Polygon Amoy
|
||||
├── base.json # Base
|
||||
├── baseSepolia.json # Base Sepolia
|
||||
├── arbitrum.json # Arbitrum One
|
||||
├── arbitrumSepolia.json # Arbitrum Sepolia
|
||||
├── optimism.json # Optimism
|
||||
└── optimismSepolia.json # Optimism Sepolia
|
||||
```
|
||||
|
||||
Each file contains:
|
||||
```json
|
||||
{
|
||||
"address": "0x1234567890123456789012345678901234567890"
|
||||
}
|
||||
```
|
||||
|
||||
## Using Multi-Chain Deployments
|
||||
|
||||
### Registry Service
|
||||
|
||||
The registry service automatically resolves contract addresses based on chain ID:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
resolveDefaultRegistry,
|
||||
getRegistryAddress,
|
||||
getAllRegistryAddresses,
|
||||
getProviderForChain
|
||||
} from "./scripts/services/registry.service";
|
||||
|
||||
// Get registry for current network
|
||||
const { registryAddress, chainId } = await resolveDefaultRegistry();
|
||||
|
||||
// Get registry for specific chain
|
||||
const polygonAddress = await getRegistryAddress(137);
|
||||
|
||||
// Get all deployed registries
|
||||
const allAddresses = await getAllRegistryAddresses();
|
||||
// Returns: { 1: "0x...", 137: "0x...", 8453: "0x...", ... }
|
||||
|
||||
// Get provider for specific chain
|
||||
const provider = getProviderForChain(137);
|
||||
```
|
||||
|
||||
### Cross-Chain Verification
|
||||
|
||||
The API supports cross-chain platform binding resolution:
|
||||
|
||||
```bash
|
||||
# Check all chains for a platform binding
|
||||
curl "http://localhost:3001/api/resolve/cross-chain?platform=youtube&platformId=dQw4w9WgXcQ"
|
||||
```
|
||||
|
||||
Response includes chain information:
|
||||
```json
|
||||
{
|
||||
"platform": "youtube",
|
||||
"platformId": "dQw4w9WgXcQ",
|
||||
"creator": "0x1234...",
|
||||
"contentHash": "0xabcd...",
|
||||
"manifestURI": "ipfs://...",
|
||||
"timestamp": 1234567890,
|
||||
"registryAddress": "0x5678...",
|
||||
"chainId": 137,
|
||||
"chainName": "Polygon"
|
||||
}
|
||||
```
|
||||
|
||||
### Web App Integration
|
||||
|
||||
The web app automatically supports all chains:
|
||||
|
||||
```typescript
|
||||
import { getChainById, getExplorerTxUrl } from "../lib/chains";
|
||||
|
||||
// Get chain details
|
||||
const chain = getChainById(137);
|
||||
console.log(chain?.displayName); // "Polygon"
|
||||
|
||||
// Get explorer URLs
|
||||
const txUrl = getExplorerTxUrl(137, "0x1234...");
|
||||
// Returns: "https://polygonscan.com/tx/0x1234..."
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Choosing a Chain
|
||||
|
||||
**For Development:**
|
||||
- Start with **Base Sepolia** or **Polygon Amoy** (free testnet tokens)
|
||||
- Test cross-chain features on multiple testnets
|
||||
|
||||
**For Production:**
|
||||
|
||||
- **Low Cost, High Volume**: Use **Polygon** or **Base**
|
||||
- Great for frequent verifications
|
||||
- Transactions cost pennies
|
||||
- Good user experience
|
||||
|
||||
- **L2 Ecosystems**: Use **Arbitrum** or **Optimism**
|
||||
- Lower costs than Ethereum mainnet
|
||||
- Strong ecosystem support
|
||||
- Good for DeFi integration
|
||||
|
||||
- **Maximum Security**: Use **Ethereum Mainnet**
|
||||
- Highest security and decentralization
|
||||
- Higher gas costs
|
||||
- Best for high-value content
|
||||
|
||||
### Multi-Chain Strategy
|
||||
|
||||
1. **Deploy to one chain initially** (e.g., Polygon for low cost)
|
||||
2. **Test thoroughly** with real verifications
|
||||
3. **Deploy to additional chains** as needed for:
|
||||
- Geographic distribution
|
||||
- Ecosystem alignment
|
||||
- Risk diversification
|
||||
|
||||
### Gas Optimization
|
||||
|
||||
- Deploy during low-traffic periods for lower gas costs
|
||||
- Consider batching operations on higher-cost chains
|
||||
- Use L2s (Base, Arbitrum, Optimism) for frequent operations
|
||||
- Keep Polygon for highest-volume use cases
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Deployment Fails
|
||||
|
||||
**Error: Insufficient funds**
|
||||
```
|
||||
Solution: Ensure wallet has gas tokens for the target chain
|
||||
- Check balance at block explorer
|
||||
- Use faucets for testnets
|
||||
- Fund wallet for mainnet deployments
|
||||
```
|
||||
|
||||
**Error: Network not configured**
|
||||
```
|
||||
Solution: Check hardhat.config.ts includes the network
|
||||
- Verify chain is in SUPPORTED_CHAINS (config/chains.ts)
|
||||
- Check RPC URL is accessible
|
||||
- Consider using custom RPC via environment variable
|
||||
```
|
||||
|
||||
### Resolution Issues
|
||||
|
||||
**Error: Registry address not configured**
|
||||
```
|
||||
Solution: Deploy contract to the target chain first
|
||||
- Run appropriate deploy:* script
|
||||
- Verify deployed/<network>.json exists
|
||||
- Check file contains valid address
|
||||
```
|
||||
|
||||
**Cross-chain resolution returns 404**
|
||||
```
|
||||
Solution: Platform binding doesn't exist on any chain
|
||||
- Verify content was registered on-chain
|
||||
- Check platform binding was created
|
||||
- Ensure you're checking the right platform/platformId
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom RPC Providers
|
||||
|
||||
Override default RPC URLs via environment variables:
|
||||
|
||||
```bash
|
||||
# Use Alchemy
|
||||
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
|
||||
POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY
|
||||
|
||||
# Use Infura
|
||||
BASE_RPC_URL=https://base-mainnet.infura.io/v3/YOUR_KEY
|
||||
|
||||
# Use Ankr
|
||||
ARBITRUM_RPC_URL=https://rpc.ankr.com/arbitrum
|
||||
```
|
||||
|
||||
### Adding New Chains
|
||||
|
||||
To add support for a new EVM chain:
|
||||
|
||||
1. Add chain configuration to `config/chains.ts`:
|
||||
```typescript
|
||||
mychain: {
|
||||
chainId: 99999, // Use the actual chain ID from chainlist.org
|
||||
name: "mychain",
|
||||
displayName: "My Chain",
|
||||
rpcUrl: "https://rpc.mychain.io",
|
||||
blockExplorer: "https://explorer.mychain.io",
|
||||
nativeCurrency: { name: "My Token", symbol: "MYT", decimals: 18 },
|
||||
testnet: false,
|
||||
}
|
||||
```
|
||||
|
||||
2. Add network to `hardhat.config.ts`:
|
||||
```typescript
|
||||
mychain: {
|
||||
url: SUPPORTED_CHAINS.mychain.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.mychain.chainId,
|
||||
}
|
||||
```
|
||||
|
||||
3. Add deployment script to `package.json`:
|
||||
```json
|
||||
"deploy:mychain": "hardhat run --network mychain scripts/deploy.ts"
|
||||
```
|
||||
|
||||
4. Add deployment file mapping in `scripts/services/registry.service.ts`:
|
||||
```typescript
|
||||
99999: "mychain.json"
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Open an issue on [GitHub](https://github.com/subculture-collective/internet-id/issues)
|
||||
- Check existing [documentation](../README.md)
|
||||
- Review [security policy](../SECURITY_POLICY.md)
|
||||
@@ -1,6 +1,8 @@
|
||||
import { HardhatUserConfig } from "hardhat/config";
|
||||
import "@nomicfoundation/hardhat-toolbox";
|
||||
import * as dotenv from "dotenv";
|
||||
import { SUPPORTED_CHAINS } from "./config/chains";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const config: HardhatUserConfig = {
|
||||
@@ -13,11 +15,62 @@ const config: HardhatUserConfig = {
|
||||
networks: {
|
||||
hardhat: {},
|
||||
localhost: {
|
||||
url: process.env.LOCAL_RPC_URL || "http://127.0.0.1:8545",
|
||||
url: SUPPORTED_CHAINS.localhost.rpcUrl,
|
||||
},
|
||||
// Ethereum networks
|
||||
ethereum: {
|
||||
url: SUPPORTED_CHAINS.ethereum.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.ethereum.chainId,
|
||||
},
|
||||
sepolia: {
|
||||
url: SUPPORTED_CHAINS.sepolia.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.sepolia.chainId,
|
||||
},
|
||||
// Polygon networks
|
||||
polygon: {
|
||||
url: SUPPORTED_CHAINS.polygon.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.polygon.chainId,
|
||||
},
|
||||
polygonAmoy: {
|
||||
url: SUPPORTED_CHAINS.polygonAmoy.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.polygonAmoy.chainId,
|
||||
},
|
||||
// Base networks
|
||||
base: {
|
||||
url: SUPPORTED_CHAINS.base.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.base.chainId,
|
||||
},
|
||||
baseSepolia: {
|
||||
url: process.env.RPC_URL || "https://sepolia.base.org",
|
||||
url: process.env.RPC_URL || SUPPORTED_CHAINS.baseSepolia.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.baseSepolia.chainId,
|
||||
},
|
||||
// Arbitrum networks
|
||||
arbitrum: {
|
||||
url: SUPPORTED_CHAINS.arbitrum.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.arbitrum.chainId,
|
||||
},
|
||||
arbitrumSepolia: {
|
||||
url: SUPPORTED_CHAINS.arbitrumSepolia.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.arbitrumSepolia.chainId,
|
||||
},
|
||||
// Optimism networks
|
||||
optimism: {
|
||||
url: SUPPORTED_CHAINS.optimism.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.optimism.chainId,
|
||||
},
|
||||
optimismSepolia: {
|
||||
url: SUPPORTED_CHAINS.optimismSepolia.rpcUrl,
|
||||
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
||||
chainId: SUPPORTED_CHAINS.optimismSepolia.chainId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,7 +15,16 @@
|
||||
"test:backup": "bash test-backup-system.sh",
|
||||
"node": "hardhat node",
|
||||
"deploy:local": "hardhat run --network localhost scripts/deploy.ts",
|
||||
"deploy:ethereum": "hardhat run --network ethereum scripts/deploy.ts",
|
||||
"deploy:sepolia": "hardhat run --network sepolia scripts/deploy.ts",
|
||||
"deploy:polygon": "hardhat run --network polygon scripts/deploy.ts",
|
||||
"deploy:polygon-amoy": "hardhat run --network polygonAmoy scripts/deploy.ts",
|
||||
"deploy:base": "hardhat run --network base scripts/deploy.ts",
|
||||
"deploy:base-sepolia": "hardhat run --network baseSepolia scripts/deploy.ts",
|
||||
"deploy:arbitrum": "hardhat run --network arbitrum scripts/deploy.ts",
|
||||
"deploy:arbitrum-sepolia": "hardhat run --network arbitrumSepolia scripts/deploy.ts",
|
||||
"deploy:optimism": "hardhat run --network optimism scripts/deploy.ts",
|
||||
"deploy:optimism-sepolia": "hardhat run --network optimismSepolia scripts/deploy.ts",
|
||||
"register": "ts-node scripts/register.ts",
|
||||
"upload:ipfs": "ts-node scripts/upload-ipfs.ts",
|
||||
"manifest": "ts-node scripts/make-manifest.ts",
|
||||
|
||||
@@ -230,6 +230,46 @@ async function startServer() {
|
||||
}
|
||||
});
|
||||
|
||||
// Cross-chain resolve: check all supported chains for a platform binding
|
||||
app.get("/api/resolve/cross-chain", moderate, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const url = (req.query as any).url as string | undefined;
|
||||
const platform = (req.query as any).platform as string | undefined;
|
||||
const platformId = (req.query as any).platformId as string | undefined;
|
||||
const parsed = parsePlatformInput(url, platform, platformId);
|
||||
if (!parsed?.platform || !parsed.platformId) {
|
||||
return res.status(400).json({ error: "Provide url or platform + platformId" });
|
||||
}
|
||||
|
||||
// Import registry service functions
|
||||
const { resolveByPlatformCrossChain } = await import("./services/registry.service");
|
||||
const entry = await resolveByPlatformCrossChain(parsed.platform, parsed.platformId);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({
|
||||
error: "No binding found on any supported chain",
|
||||
...parsed,
|
||||
});
|
||||
}
|
||||
|
||||
const { getChainById } = await import("../config/chains");
|
||||
const chain = getChainById(entry.chainId);
|
||||
|
||||
return res.json({
|
||||
...parsed,
|
||||
creator: entry.creator,
|
||||
contentHash: entry.contentHash,
|
||||
manifestURI: entry.manifestURI,
|
||||
timestamp: entry.timestamp,
|
||||
registryAddress: entry.registryAddress,
|
||||
chainId: entry.chainId,
|
||||
chainName: chain?.displayName || `Chain ${entry.chainId}`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return res.status(500).json({ error: e?.message || String(e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Public verify: resolve + include manifest JSON if on IPFS/HTTP - moderate rate limiting
|
||||
app.get("/api/public-verify", moderate, async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ethers } from "ethers";
|
||||
import { readFile } from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { getChainById, SUPPORTED_CHAINS } from "../../config/chains";
|
||||
|
||||
export interface RegistryInfo {
|
||||
registryAddress: string;
|
||||
@@ -14,6 +15,21 @@ export interface RegistryEntry {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Mapping of chain IDs to deployment file names
|
||||
const CHAIN_DEPLOYMENT_FILES: Record<number, string> = {
|
||||
1: "ethereum.json",
|
||||
11155111: "sepolia.json",
|
||||
137: "polygon.json",
|
||||
80002: "polygonAmoy.json",
|
||||
8453: "base.json",
|
||||
84532: "baseSepolia.json",
|
||||
42161: "arbitrum.json",
|
||||
421614: "arbitrumSepolia.json",
|
||||
10: "optimism.json",
|
||||
11155420: "optimismSepolia.json",
|
||||
31337: "localhost.json",
|
||||
};
|
||||
|
||||
// Helper to resolve default registry address for current network
|
||||
export async function resolveDefaultRegistry(): Promise<RegistryInfo> {
|
||||
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL || "https://sepolia.base.org");
|
||||
@@ -21,19 +37,67 @@ export async function resolveDefaultRegistry(): Promise<RegistryInfo> {
|
||||
const chainId = Number(net.chainId);
|
||||
const override = process.env.REGISTRY_ADDRESS;
|
||||
if (override) return { registryAddress: override, chainId };
|
||||
let deployedFile: string | undefined;
|
||||
if (chainId === 84532) deployedFile = path.join(process.cwd(), "deployed", "baseSepolia.json");
|
||||
if (deployedFile) {
|
||||
|
||||
const deployedFileName = CHAIN_DEPLOYMENT_FILES[chainId];
|
||||
if (deployedFileName) {
|
||||
const deployedFile = path.join(process.cwd(), "deployed", deployedFileName);
|
||||
try {
|
||||
const data = JSON.parse((await readFile(deployedFile)).toString("utf8"));
|
||||
if (data?.address) return { registryAddress: data.address, chainId };
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
console.error(`Failed to read or parse registry deployment file "${deployedFile}":`, err);
|
||||
}
|
||||
}
|
||||
throw new Error("Registry address not configured");
|
||||
throw new Error(`Registry address not configured for chain ID ${chainId}`);
|
||||
}
|
||||
|
||||
// Helper to get registry address for a specific chain
|
||||
export async function getRegistryAddress(chainId: number): Promise<string | undefined> {
|
||||
const deployedFileName = CHAIN_DEPLOYMENT_FILES[chainId];
|
||||
if (!deployedFileName) return undefined;
|
||||
|
||||
const deployedFile = path.join(process.cwd(), "deployed", deployedFileName);
|
||||
try {
|
||||
const data = JSON.parse((await readFile(deployedFile)).toString("utf8"));
|
||||
return data?.address;
|
||||
} catch (err) {
|
||||
console.error(`Failed to read registry address from ${deployedFile}:`, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get all deployed registry addresses
|
||||
export async function getAllRegistryAddresses(): Promise<Record<number, string>> {
|
||||
const addresses: Record<number, string> = {};
|
||||
|
||||
for (const [chainIdStr, fileName] of Object.entries(CHAIN_DEPLOYMENT_FILES)) {
|
||||
const chainId = parseInt(chainIdStr);
|
||||
const deployedFile = path.join(process.cwd(), "deployed", fileName);
|
||||
try {
|
||||
const data = JSON.parse((await readFile(deployedFile)).toString("utf8"));
|
||||
if (data?.address) {
|
||||
addresses[chainId] = data.address;
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip if file doesn't exist - only log non-ENOENT errors
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.error(`Failed to read registry file ${deployedFile}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export function getProvider(rpcUrl?: string): ethers.JsonRpcProvider {
|
||||
return new ethers.JsonRpcProvider(rpcUrl || process.env.RPC_URL || "https://sepolia.base.org");
|
||||
return new ethers.JsonRpcProvider(rpcUrl || process.env.RPC_URL || SUPPORTED_CHAINS.baseSepolia.rpcUrl);
|
||||
}
|
||||
|
||||
// Helper to get provider for a specific chain
|
||||
export function getProviderForChain(chainId: number): ethers.JsonRpcProvider | undefined {
|
||||
const chain = getChainById(chainId);
|
||||
if (!chain) return undefined;
|
||||
return new ethers.JsonRpcProvider(chain.rpcUrl);
|
||||
}
|
||||
|
||||
export function getRegistryContract(
|
||||
@@ -80,3 +144,82 @@ export async function getEntry(
|
||||
timestamp: Number(entry.timestamp || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CrossChainRegistryEntry extends RegistryEntry {
|
||||
chainId: number;
|
||||
registryAddress: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a platform binding across all supported chains
|
||||
* Returns the first match found, checking chains in priority order
|
||||
*/
|
||||
export async function resolveByPlatformCrossChain(
|
||||
platform: string,
|
||||
platformId: string
|
||||
): Promise<CrossChainRegistryEntry | null> {
|
||||
const addresses = await getAllRegistryAddresses();
|
||||
const chainIds = Object.keys(addresses).map((id) => parseInt(id));
|
||||
|
||||
// Check each chain in order
|
||||
for (const chainId of chainIds) {
|
||||
const registryAddress = addresses[chainId];
|
||||
const provider = getProviderForChain(chainId);
|
||||
if (!provider) continue;
|
||||
|
||||
try {
|
||||
const entry = await resolveByPlatform(registryAddress, platform, platformId, provider);
|
||||
// Check if entry exists (creator is not zero address)
|
||||
if (entry.creator !== ethers.ZeroAddress) {
|
||||
return {
|
||||
...entry,
|
||||
chainId,
|
||||
registryAddress,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to resolve platform binding on chainId ${chainId} (registry: ${registryAddress}):`,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a content entry across all supported chains
|
||||
* Returns the first match found
|
||||
*/
|
||||
export async function getEntryCrossChain(contentHash: string): Promise<CrossChainRegistryEntry | null> {
|
||||
const addresses = await getAllRegistryAddresses();
|
||||
const chainIds = Object.keys(addresses).map((id) => parseInt(id));
|
||||
|
||||
for (const chainId of chainIds) {
|
||||
const registryAddress = addresses[chainId];
|
||||
const provider = getProviderForChain(chainId);
|
||||
if (!provider) continue;
|
||||
|
||||
try {
|
||||
const entry = await getEntry(registryAddress, contentHash, provider);
|
||||
// Check if entry exists (creator is not zero address)
|
||||
if (entry.creator !== ethers.ZeroAddress) {
|
||||
return {
|
||||
...entry,
|
||||
chainId,
|
||||
registryAddress,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to get entry on chainId ${chainId} (registry: ${registryAddress}):`,
|
||||
err
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
236
test/config/chains.test.ts
Normal file
236
test/config/chains.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { expect } from "chai";
|
||||
import {
|
||||
SUPPORTED_CHAINS,
|
||||
getChainById,
|
||||
getChainByName,
|
||||
getMainnetChains,
|
||||
getTestnetChains,
|
||||
getExplorerTxUrl,
|
||||
getExplorerAddressUrl,
|
||||
} from "../../config/chains";
|
||||
|
||||
describe("Chain Configuration", function () {
|
||||
describe("SUPPORTED_CHAINS", function () {
|
||||
it("should contain all expected chains", function () {
|
||||
const chainNames = Object.keys(SUPPORTED_CHAINS);
|
||||
expect(chainNames).to.include.members([
|
||||
"ethereum",
|
||||
"sepolia",
|
||||
"polygon",
|
||||
"polygonAmoy",
|
||||
"base",
|
||||
"baseSepolia",
|
||||
"arbitrum",
|
||||
"arbitrumSepolia",
|
||||
"optimism",
|
||||
"optimismSepolia",
|
||||
"localhost",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should have valid chain IDs", function () {
|
||||
for (const chain of Object.values(SUPPORTED_CHAINS)) {
|
||||
expect(chain.chainId).to.be.a("number");
|
||||
expect(chain.chainId).to.be.greaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have valid block explorers for non-localhost chains", function () {
|
||||
for (const chain of Object.values(SUPPORTED_CHAINS)) {
|
||||
if (chain.name !== "localhost") {
|
||||
expect(chain.blockExplorer).to.be.a("string");
|
||||
expect(chain.blockExplorer.length).to.be.greaterThan(0);
|
||||
expect(chain.blockExplorer).to.match(/^https?:\/\//);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should have valid RPC URLs", function () {
|
||||
for (const chain of Object.values(SUPPORTED_CHAINS)) {
|
||||
expect(chain.rpcUrl).to.be.a("string");
|
||||
expect(chain.rpcUrl.length).to.be.greaterThan(0);
|
||||
expect(chain.rpcUrl).to.match(/^https?:\/\//);
|
||||
}
|
||||
});
|
||||
|
||||
it("should correctly mark testnets and mainnets", function () {
|
||||
expect(SUPPORTED_CHAINS.ethereum.testnet).to.be.false;
|
||||
expect(SUPPORTED_CHAINS.sepolia.testnet).to.be.true;
|
||||
expect(SUPPORTED_CHAINS.polygon.testnet).to.be.false;
|
||||
expect(SUPPORTED_CHAINS.polygonAmoy.testnet).to.be.true;
|
||||
expect(SUPPORTED_CHAINS.base.testnet).to.be.false;
|
||||
expect(SUPPORTED_CHAINS.baseSepolia.testnet).to.be.true;
|
||||
expect(SUPPORTED_CHAINS.arbitrum.testnet).to.be.false;
|
||||
expect(SUPPORTED_CHAINS.arbitrumSepolia.testnet).to.be.true;
|
||||
expect(SUPPORTED_CHAINS.optimism.testnet).to.be.false;
|
||||
expect(SUPPORTED_CHAINS.optimismSepolia.testnet).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChainById", function () {
|
||||
it("should return correct chain for valid chain ID", function () {
|
||||
const chain = getChainById(1);
|
||||
expect(chain).to.exist;
|
||||
expect(chain?.name).to.equal("ethereum");
|
||||
expect(chain?.chainId).to.equal(1);
|
||||
});
|
||||
|
||||
it("should return correct chain for Base Sepolia", function () {
|
||||
const chain = getChainById(84532);
|
||||
expect(chain).to.exist;
|
||||
expect(chain?.name).to.equal("baseSepolia");
|
||||
expect(chain?.chainId).to.equal(84532);
|
||||
});
|
||||
|
||||
it("should return undefined for invalid chain ID", function () {
|
||||
const chain = getChainById(999999);
|
||||
expect(chain).to.be.undefined;
|
||||
});
|
||||
|
||||
it("should work for all supported chain IDs", function () {
|
||||
const expectedChainIds = [1, 11155111, 137, 80002, 8453, 84532, 42161, 421614, 10, 11155420, 31337];
|
||||
for (const chainId of expectedChainIds) {
|
||||
const chain = getChainById(chainId);
|
||||
expect(chain).to.exist;
|
||||
expect(chain?.chainId).to.equal(chainId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChainByName", function () {
|
||||
it("should return correct chain for valid name", function () {
|
||||
const chain = getChainByName("ethereum");
|
||||
expect(chain).to.exist;
|
||||
expect(chain?.name).to.equal("ethereum");
|
||||
expect(chain?.chainId).to.equal(1);
|
||||
});
|
||||
|
||||
it("should return correct chain for Base Sepolia", function () {
|
||||
const chain = getChainByName("baseSepolia");
|
||||
expect(chain).to.exist;
|
||||
expect(chain?.name).to.equal("baseSepolia");
|
||||
expect(chain?.chainId).to.equal(84532);
|
||||
});
|
||||
|
||||
it("should return undefined for invalid name", function () {
|
||||
const chain = getChainByName("invalid_chain");
|
||||
expect(chain).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMainnetChains", function () {
|
||||
it("should return only mainnet chains", function () {
|
||||
const mainnets = getMainnetChains();
|
||||
expect(mainnets).to.be.an("array");
|
||||
expect(mainnets.length).to.be.greaterThan(0);
|
||||
for (const chain of mainnets) {
|
||||
expect(chain.testnet).to.be.false;
|
||||
}
|
||||
});
|
||||
|
||||
it("should include expected mainnet chains", function () {
|
||||
const mainnets = getMainnetChains();
|
||||
const names = mainnets.map((c) => c.name);
|
||||
expect(names).to.include.members(["ethereum", "polygon", "base", "arbitrum", "optimism"]);
|
||||
});
|
||||
|
||||
it("should not include testnet chains", function () {
|
||||
const mainnets = getMainnetChains();
|
||||
const names = mainnets.map((c) => c.name);
|
||||
expect(names).to.not.include.members([
|
||||
"sepolia",
|
||||
"polygonAmoy",
|
||||
"baseSepolia",
|
||||
"arbitrumSepolia",
|
||||
"optimismSepolia",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTestnetChains", function () {
|
||||
it("should return only testnet chains", function () {
|
||||
const testnets = getTestnetChains();
|
||||
expect(testnets).to.be.an("array");
|
||||
expect(testnets.length).to.be.greaterThan(0);
|
||||
for (const chain of testnets) {
|
||||
expect(chain.testnet).to.be.true;
|
||||
}
|
||||
});
|
||||
|
||||
it("should include expected testnet chains", function () {
|
||||
const testnets = getTestnetChains();
|
||||
const names = testnets.map((c) => c.name);
|
||||
expect(names).to.include.members([
|
||||
"sepolia",
|
||||
"polygonAmoy",
|
||||
"baseSepolia",
|
||||
"arbitrumSepolia",
|
||||
"optimismSepolia",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExplorerTxUrl", function () {
|
||||
it("should return correct URL for Ethereum mainnet", function () {
|
||||
const url = getExplorerTxUrl(1, "0x1234567890abcdef");
|
||||
expect(url).to.equal("https://etherscan.io/tx/0x1234567890abcdef");
|
||||
});
|
||||
|
||||
it("should return correct URL for Base Sepolia", function () {
|
||||
const url = getExplorerTxUrl(84532, "0xabcdef1234567890");
|
||||
expect(url).to.equal("https://sepolia.basescan.org/tx/0xabcdef1234567890");
|
||||
});
|
||||
|
||||
it("should return correct URL for Polygon", function () {
|
||||
const url = getExplorerTxUrl(137, "0xdeadbeef");
|
||||
expect(url).to.equal("https://polygonscan.com/tx/0xdeadbeef");
|
||||
});
|
||||
|
||||
it("should return correct URL for Arbitrum", function () {
|
||||
const url = getExplorerTxUrl(42161, "0xcafebabe");
|
||||
expect(url).to.equal("https://arbiscan.io/tx/0xcafebabe");
|
||||
});
|
||||
|
||||
it("should return correct URL for Optimism", function () {
|
||||
const url = getExplorerTxUrl(10, "0x1337");
|
||||
expect(url).to.equal("https://optimistic.etherscan.io/tx/0x1337");
|
||||
});
|
||||
|
||||
it("should return undefined for invalid chain ID", function () {
|
||||
const url = getExplorerTxUrl(999999, "0x1234");
|
||||
expect(url).to.be.undefined;
|
||||
});
|
||||
|
||||
it("should return undefined for localhost", function () {
|
||||
const url = getExplorerTxUrl(31337, "0x1234");
|
||||
expect(url).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExplorerAddressUrl", function () {
|
||||
it("should return correct URL for Ethereum mainnet", function () {
|
||||
const url = getExplorerAddressUrl(1, "0x1234567890123456789012345678901234567890");
|
||||
expect(url).to.equal("https://etherscan.io/address/0x1234567890123456789012345678901234567890");
|
||||
});
|
||||
|
||||
it("should return correct URL for Base Sepolia", function () {
|
||||
const url = getExplorerAddressUrl(84532, "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd");
|
||||
expect(url).to.equal("https://sepolia.basescan.org/address/0xabcdefabcdefabcdefabcdefabcdefabcdefabcd");
|
||||
});
|
||||
|
||||
it("should return correct URL for Polygon", function () {
|
||||
const url = getExplorerAddressUrl(137, "0x1111111111111111111111111111111111111111");
|
||||
expect(url).to.equal("https://polygonscan.com/address/0x1111111111111111111111111111111111111111");
|
||||
});
|
||||
|
||||
it("should return undefined for invalid chain ID", function () {
|
||||
const url = getExplorerAddressUrl(999999, "0x1234567890123456789012345678901234567890");
|
||||
expect(url).to.be.undefined;
|
||||
});
|
||||
|
||||
it("should return undefined for localhost", function () {
|
||||
const url = getExplorerAddressUrl(31337, "0x1234567890123456789012345678901234567890");
|
||||
expect(url).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -184,4 +184,36 @@ describe("Registry Service", function () {
|
||||
expect(normalized).to.equal("youtube");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProviderForChain", function () {
|
||||
it("should return provider for valid chain ID", function () {
|
||||
const provider = registryService.getProviderForChain(1);
|
||||
expect(provider).to.be.instanceOf(ethers.JsonRpcProvider);
|
||||
});
|
||||
|
||||
it("should return provider for Base Sepolia", function () {
|
||||
const provider = registryService.getProviderForChain(84532);
|
||||
expect(provider).to.be.instanceOf(ethers.JsonRpcProvider);
|
||||
});
|
||||
|
||||
it("should return provider for Polygon", function () {
|
||||
const provider = registryService.getProviderForChain(137);
|
||||
expect(provider).to.be.instanceOf(ethers.JsonRpcProvider);
|
||||
});
|
||||
|
||||
it("should return provider for Arbitrum", function () {
|
||||
const provider = registryService.getProviderForChain(42161);
|
||||
expect(provider).to.be.instanceOf(ethers.JsonRpcProvider);
|
||||
});
|
||||
|
||||
it("should return provider for Optimism", function () {
|
||||
const provider = registryService.getProviderForChain(10);
|
||||
expect(provider).to.be.instanceOf(ethers.JsonRpcProvider);
|
||||
});
|
||||
|
||||
it("should return undefined for invalid chain ID", function () {
|
||||
const provider = registryService.getProviderForChain(999999);
|
||||
expect(provider).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import ErrorMessage from "../components/ErrorMessage";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { ToastContainer } from "../components/Toast";
|
||||
import { getExplorerTxUrl } from "../../lib/chains";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3001";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_API_KEY;
|
||||
@@ -54,18 +55,7 @@ interface DashboardStats {
|
||||
function explorerTxUrl(txHash: string | undefined, chainId?: number) {
|
||||
if (!txHash) return undefined;
|
||||
const id = chainId || 84532; // Default to Base Sepolia
|
||||
switch (id) {
|
||||
case 1:
|
||||
return `https://etherscan.io/tx/${txHash}`;
|
||||
case 11155111:
|
||||
return `https://sepolia.etherscan.io/tx/${txHash}`;
|
||||
case 8453:
|
||||
return `https://basescan.org/tx/${txHash}`;
|
||||
case 84532:
|
||||
return `https://sepolia.basescan.org/tx/${txHash}`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
return getExplorerTxUrl(id, txHash);
|
||||
}
|
||||
|
||||
function ipfsToGateway(uri: string | undefined) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToastContainer } from "./components/Toast";
|
||||
import LoadingSpinner from "./components/LoadingSpinner";
|
||||
import ErrorMessage from "./components/ErrorMessage";
|
||||
import SkeletonLoader from "./components/SkeletonLoader";
|
||||
import { getExplorerTxUrl, getExplorerAddressUrl } from "../lib/chains";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3001";
|
||||
const API_KEY = process.env.NEXT_PUBLIC_API_KEY;
|
||||
@@ -34,41 +35,19 @@ const PLATFORM_OPTIONS = [
|
||||
"linkedin",
|
||||
];
|
||||
|
||||
// Re-export chain helper functions for backward compatibility
|
||||
function explorerTxUrl(
|
||||
chainId: number | undefined,
|
||||
txHash: string | undefined
|
||||
) {
|
||||
if (!txHash) return undefined;
|
||||
switch (chainId) {
|
||||
case 1:
|
||||
return `https://etherscan.io/tx/${txHash}`;
|
||||
case 11155111:
|
||||
return `https://sepolia.etherscan.io/tx/${txHash}`;
|
||||
case 8453:
|
||||
return `https://basescan.org/tx/${txHash}`;
|
||||
case 84532:
|
||||
return `https://sepolia.basescan.org/tx/${txHash}`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
return getExplorerTxUrl(chainId, txHash);
|
||||
}
|
||||
|
||||
function explorerAddressUrl(
|
||||
chainId: number | undefined,
|
||||
address: string | undefined
|
||||
) {
|
||||
if (!address) return undefined;
|
||||
switch (chainId) {
|
||||
case 1:
|
||||
return `https://etherscan.io/address/${address}`;
|
||||
case 11155111:
|
||||
return `https://sepolia.etherscan.io/address/${address}`;
|
||||
case 8453:
|
||||
return `https://basescan.org/address/${address}`;
|
||||
case 84532:
|
||||
return `https://sepolia.basescan.org/address/${address}`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
return getExplorerAddressUrl(chainId, address);
|
||||
}
|
||||
|
||||
function ipfsToGateway(uri: string | undefined) {
|
||||
|
||||
156
web/lib/chains.ts
Normal file
156
web/lib/chains.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Multi-chain configuration for web app
|
||||
* Contains chain IDs, block explorers, and other chain-specific settings
|
||||
*/
|
||||
|
||||
export interface ChainConfig {
|
||||
chainId: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
blockExplorer: string;
|
||||
nativeCurrency: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
testnet: boolean;
|
||||
}
|
||||
|
||||
export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
|
||||
// Ethereum
|
||||
1: {
|
||||
chainId: 1,
|
||||
name: "ethereum",
|
||||
displayName: "Ethereum Mainnet",
|
||||
blockExplorer: "https://etherscan.io",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
11155111: {
|
||||
chainId: 11155111,
|
||||
name: "sepolia",
|
||||
displayName: "Ethereum Sepolia",
|
||||
blockExplorer: "https://sepolia.etherscan.io",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Polygon
|
||||
137: {
|
||||
chainId: 137,
|
||||
name: "polygon",
|
||||
displayName: "Polygon",
|
||||
blockExplorer: "https://polygonscan.com",
|
||||
nativeCurrency: { name: "MATIC", symbol: "MATIC", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
80002: {
|
||||
chainId: 80002,
|
||||
name: "polygonAmoy",
|
||||
displayName: "Polygon Amoy",
|
||||
blockExplorer: "https://amoy.polygonscan.com",
|
||||
nativeCurrency: { name: "MATIC", symbol: "MATIC", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Base
|
||||
8453: {
|
||||
chainId: 8453,
|
||||
name: "base",
|
||||
displayName: "Base",
|
||||
blockExplorer: "https://basescan.org",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
84532: {
|
||||
chainId: 84532,
|
||||
name: "baseSepolia",
|
||||
displayName: "Base Sepolia",
|
||||
blockExplorer: "https://sepolia.basescan.org",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Arbitrum
|
||||
42161: {
|
||||
chainId: 42161,
|
||||
name: "arbitrum",
|
||||
displayName: "Arbitrum One",
|
||||
blockExplorer: "https://arbiscan.io",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
421614: {
|
||||
chainId: 421614,
|
||||
name: "arbitrumSepolia",
|
||||
displayName: "Arbitrum Sepolia",
|
||||
blockExplorer: "https://sepolia.arbiscan.io",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
|
||||
// Optimism
|
||||
10: {
|
||||
chainId: 10,
|
||||
name: "optimism",
|
||||
displayName: "Optimism",
|
||||
blockExplorer: "https://optimistic.etherscan.io",
|
||||
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: false,
|
||||
},
|
||||
11155420: {
|
||||
chainId: 11155420,
|
||||
name: "optimismSepolia",
|
||||
displayName: "Optimism Sepolia",
|
||||
blockExplorer: "https://sepolia-optimism.etherscan.io",
|
||||
nativeCurrency: { name: "Sepolia Ether", symbol: "ETH", decimals: 18 },
|
||||
testnet: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get chain configuration by chain ID
|
||||
*/
|
||||
export function getChainById(chainId: number): ChainConfig | undefined {
|
||||
return SUPPORTED_CHAINS[chainId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mainnet chains
|
||||
*/
|
||||
export function getMainnetChains(): ChainConfig[] {
|
||||
return Object.values(SUPPORTED_CHAINS).filter((chain) => !chain.testnet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all testnet chains
|
||||
*/
|
||||
export function getTestnetChains(): ChainConfig[] {
|
||||
return Object.values(SUPPORTED_CHAINS).filter((chain) => chain.testnet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block explorer URL for a transaction
|
||||
*/
|
||||
export function getExplorerTxUrl(
|
||||
chainId: number | undefined,
|
||||
txHash: string | undefined
|
||||
): string | undefined {
|
||||
if (!chainId || !txHash) return undefined;
|
||||
const chain = getChainById(chainId);
|
||||
if (!chain || !chain.blockExplorer) return undefined;
|
||||
return `${chain.blockExplorer}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block explorer URL for an address
|
||||
*/
|
||||
export function getExplorerAddressUrl(
|
||||
chainId: number | undefined,
|
||||
address: string | undefined
|
||||
): string | undefined {
|
||||
if (!chainId || !address) return undefined;
|
||||
const chain = getChainById(chainId);
|
||||
if (!chain || !chain.blockExplorer) return undefined;
|
||||
return `${chain.blockExplorer}/address/${address}`;
|
||||
}
|
||||
Reference in New Issue
Block a user