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:
Copilot
2025-10-29 16:33:06 -05:00
committed by GitHub
parent 1d8fafffed
commit e8f815662d
13 changed files with 1305 additions and 52 deletions

View File

@@ -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)
# -----------------------------------------------------------------------------

View File

@@ -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
View 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}`;
}

View 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)

View File

@@ -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,
},
},
};

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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
View 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;
});
});
});

View File

@@ -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;
});
});
});

View File

@@ -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) {

View File

@@ -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
View 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}`;
}