diff --git a/onlyswaps-token-routes-api/.env.example b/onlyswaps-token-routes-api/.env.example new file mode 100644 index 00000000..fda91d7e --- /dev/null +++ b/onlyswaps-token-routes-api/.env.example @@ -0,0 +1,67 @@ +# Network RPC URLs + +ETHEREUM_RPC_URL= +ARBITRUM_RPC_URL= +AVALANCHE_RPC_URL= +BASE_RPC_URL= +BINANCE_RPC_URL= +FILECOIN_RPC_URL= +LINEA_RPC_URL= +OP_RPC_URL= +SCROLL_RPC_URL= +# Testnets and Calibration +ARC_TESTNET_RPC_URL= +AVALANCHE_FUJI_RPC_URL= +BASE_SEPOLIA_RPC_URL= +FILECOIN_CALIBRATION_RPC_URL= + +# Router Contract Addresses + +ETHEREUM_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +ARBITRUM_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +AVALANCHE_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +BASE_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +BINANCE_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +FILECOIN_ROUTER=0xA25921858Fa5Ee8177FaaFF1D6522730E4649648 +LINEA_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +OP_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +SCROLL_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +# Testnets and Calibration +ARC_TESTNET_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +AVALANCHE_FUJI_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +BASE_SEPOLIA_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +FILECOIN_CALIBRATION_ROUTER=0x1B37530129E84Cc0C4Db6C00c61beC06B9691b8c + +# Starting Block Numbers (contract deployment blocks) +# Set to a specific block number to sync from that block +# Set to 0 to start from latest block (no historical sync, only new events) + +ETHEREUM_START_BLOCK=0 +ARBITRUM_START_BLOCK=0 +AVALANCHE_START_BLOCK=0 +BASE_START_BLOCK=0 +BINANCE_START_BLOCK=0 +FILECOIN_START_BLOCK=0 +LINEA_START_BLOCK=0 +OP_START_BLOCK=0 +SCROLL_START_BLOCK=0 +# Testnets and Calibration +ARC_TESTNET_START_BLOCK=0 +AVALANCHE_FUJI_START_BLOCK=0 +BASE_SEPOLIA_START_BLOCK=0 +FILECOIN_CALIBRATION_START_BLOCK=0 + +# API Configuration +PORT=3000 +NODE_ENV=development + +# Sync Configuration +SYNC_INTERVAL=60000 +# BLOCK_CHUNK_SIZE: Number of blocks to query per request +# Alchemy Free tier: max 10 blocks, Growth tier: max 10000 blocks +# Increase this value if you have a paid RPC plan for faster syncing +BLOCK_CHUNK_SIZE=10 +# REQUEST_DELAY_MS: Delay between chunk requests to avoid rate limiting +# Alchemy Free tier: 330 req/sec, recommend 200-500ms delay +# Set to 0 for paid plans with higher limits +REQUEST_DELAY_MS=200 diff --git a/onlyswaps-token-routes-api/.gitignore b/onlyswaps-token-routes-api/.gitignore new file mode 100644 index 00000000..43946f2d --- /dev/null +++ b/onlyswaps-token-routes-api/.gitignore @@ -0,0 +1,82 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build outputs +dist/ +build/ +*.js +*.d.ts +*.js.map +!jest.config.js +!hardhat.config.js + +# Environment variables +.env +.env.local +.env.*.local + +# Database +data/db.json +data/*.json +*.db +*.sqlite + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +pnpm-debug.log* + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*~ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*.swn +.sublime-* + +# Testing +coverage/ +.nyc_output/ +*.lcov + +# TypeScript +*.tsbuildinfo + +# Temporary files +tmp/ +temp/ +*.tmp + +# Cache +.cache/ +.parcel-cache/ +.eslintcache + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity \ No newline at end of file diff --git a/onlyswaps-token-routes-api/README.md b/onlyswaps-token-routes-api/README.md new file mode 100644 index 00000000..8db859d7 --- /dev/null +++ b/onlyswaps-token-routes-api/README.md @@ -0,0 +1,502 @@ +# Only Swaps Token Mapping Indexer + +API service for tracking cross-chain token mappings from only swaps Router contracts deployed across blockchain networks. + +## Features + +- Indexes `TokenMappingAdded` and `TokenMappingRemoved` Router contract events across multiple chains +- Fetches and caches token metadata (symbol, name, decimals) +- REST API for querying token mappings +- Real-time event listening for new mappings +- JSON file-based database (LowDB) with automatic sync progress tracking +- Multi-chain support (Ethereum, Arbitrum, Base) + +## Key Features + +- **Multi-chain event indexing:** Tracks TokenMappingAdded/Removed events across all configured chains (Ethereum, Arbitrum, Avalanche, Base, Binance, Filecoin, Linea, Optimism, Scroll, etc.) +- **Configurable sync start:** Set start block per chain in `.env` (sync from any block, or set to 0 to start from latest) +- **Automatic progress tracking:** Sync progress is saved per chain in `data/db.json` and resumes on restart +- **Real-time event listening:** After historical sync, listens for new events and updates database instantly +- **Soft delete for mappings:** `TokenMappingRemoved` events mark mappings as inactive (`isActive: false`), preserving history +- **Token metadata caching:** Fetches and caches ERC20 metadata (symbol, name, decimals) for all mapped tokens +- **Graceful handling of unconfigured chains:** If a mapping references a chain not in `.env`, mapping is stored but token metadata fetch logs a warning +- **REST API endpoints:** Query mappings, tokens, and networks with rich filtering and metadata enrichment +- **Rate limiting and chunked sync:** Configurable block chunk size and request delay for RPC rate limits +- **No sensitive data in DB:** RPC URLs are only in `.env`, never stored in the database +- **Audit/history support:** All mapping changes (add/remove) are tracked with timestamps and block numbers +- **Easy chain addition:** Add new chains by updating `.env` and `src/config/networks.ts` +- **TypeScript codebase:** Strongly typed for reliability and maintainability + + +## Quick Start + +### Prerequisites + +- Node.js 18+ +- RPC endpoints for Ethereum, Arbitrum, and Base (e.g., Alchemy, Infura) +- only swaps Router contract addresses for each network + +### Installation + +```bash +# 1. Install dependencies +npm install + +# 2. Configure environment +cp .env.example .env +# Edit .env with your RPC URLs, router addresses, and start blocks + +# 3. Run in development mode (syncs + starts server) +npm run dev +``` + +The server will: +- Initialize the database +- Sync historical events from configured start blocks +- Start the API on `http://localhost:3000` +- Listen for new events in real-time + +### Configuration + +Edit `.env` with your settings: + +```env +# Network RPC URLs (required) +ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY +ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_API_KEY +BASE_RPC_URL=https://base-mainnet.g.alchemy.com/v2/YOUR_API_KEY + +# Router Contract Addresses (required) +ETHEREUM_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +ARBITRUM_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB +BASE_ROUTER=0x16323707e61d20A39AaE5ab64808e480B91658aB + +# Starting Block Numbers (contract deployment blocks) +# Set to a specific block number to sync from that block +# Set to 0 to start from latest block (no historical sync, only new events) +ETHEREUM_START_BLOCK=23822063 +ARBITRUM_START_BLOCK=401344812 +BASE_START_BLOCK=38314492 + +# API Configuration +PORT=3000 +NODE_ENV=development + +# Sync Configuration +SYNC_INTERVAL=60000 +# BLOCK_CHUNK_SIZE: Number of blocks to query per request +# Alchemy Free tier: max 10 blocks, Growth tier: max 10000 blocks +# Increase this value if you have a paid RPC plan for faster syncing +BLOCK_CHUNK_SIZE=10 +# REQUEST_DELAY_MS: Delay between chunk requests to avoid rate limiting +# Alchemy Free tier: 330 req/sec, recommend 200-500ms delay +# Set to 0 for paid plans with higher limits +REQUEST_DELAY_MS=200 +``` + +**Important Notes:** +- **Start Block Behavior:** + - Set to a specific block number (e.g., `23822063`) to sync all events from that block forward + - Set to `0` to skip historical sync and only capture new events from the current latest block + - Once syncing starts, progress is automatically saved to `data/db.json` and resumes on restart +- **Multi-Chain Support:** + - The indexer only syncs chains configured in `.env` + - Token mappings may reference destination chains not configured (e.g., BSC, Avalanche, etc.) + - When fetching metadata for tokens on unconfigured chains, you will see warnings like `"No provider for chain 56"` + - Mappings are still stored correctly; only the destination token metadata will be missing + - To add more chains: add RPC URL, router address, and start block to `.env`, then update `src/config/networks.ts` +- **Rate Limiting:** Free tier RPC providers have strict limits. Use `BLOCK_CHUNK_SIZE=10` and `REQUEST_DELAY_MS=200` for Alchemy free tier. + +### Production Deployment + +```bash +# Build TypeScript +npm run build + +# Start production server +npm start + +# Or run one-time sync only (no server) +npm run sync +``` + +## API Endpoints + +### Health Check + +**GET** `/health` + +Check if the API is running. + +```bash +curl http://localhost:3000/health +``` + +Example output: +```json +{ + "status": "ok", + "timestamp": 1732636800000 +} +``` + +### Networks + +**GET** `/api/networks` + +List all supported networks. + +```bash +curl http://localhost:3000/api/networks +``` + +Example output: +```json +{ + "networks": [ + { + "id": "ethereum", + "name": "Ethereum", + "chainId": 1, + "routerAddress": "0x16323707e61d20A39AaE5ab64808e480B91658aB" + }, + { + "id": "arbitrum", + "name": "Arbitrum", + "chainId": 42161, + "routerAddress": "0x16323707e61d20A39AaE5ab64808e480B91658aB" + } + ] +} +``` + +**GET** `/api/networks/:chainId` + +Get specific network by chain ID. + +```bash +curl http://localhost:3000/api/networks/1 +``` + +Example output: +```json +{ + "id": "ethereum", + "name": "Ethereum", + "chainId": 1, + "routerAddress": "0x16323707e61d20A39AaE5ab64808e480B91658aB" +} +``` + +### Token Mappings + +**GET** `/api/mappings` + +Get all token mappings (with enriched token metadata). + +```bash +curl http://localhost:3000/api/mappings +``` + +Example output: +```json +{ + "mappings": [ + { + "id": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48-1-0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174-137", + "srcTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "srcChainId": 1, + "dstTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "dstChainId": 137, + "isActive": true, + "blockNumber": 12345678, + "txHash": "0xabc123...", + "timestamp": 1732636800000, + "srcToken": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6 + }, + "dstToken": { + "address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "chainId": 137, + "symbol": "USDC", + "name": "USD Coin (PoS)", + "decimals": 6 + }, + "srcNetwork": "Ethereum", + "dstNetwork": "Polygon" + } + ], + "count": 1 +} +``` + +**GET** `/api/mappings?srcChainId={id}&dstChainId={id}` + +Filter mappings by source and/or destination chain. + +```bash +# Get mappings from Ethereum +curl 'http://localhost:3000/api/mappings?srcChainId=1' + +# Get mappings to Arbitrum +curl 'http://localhost:3000/api/mappings?dstChainId=42161' + +# Get mappings from Ethereum to Base +curl 'http://localhost:3000/api/mappings?srcChainId=1&dstChainId=8453' +``` + +Example output: +```json +{ + "mappings": [ + { + "id": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48-1-0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174-137", + "srcTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "srcChainId": 1, + "dstTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "dstChainId": 137, + "isActive": true, + "blockNumber": 12345678, + "txHash": "0xabc123...", + "timestamp": 1732636800000, + "srcToken": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6 + }, + "dstToken": { + "address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "chainId": 137, + "symbol": "USDC", + "name": "USD Coin (PoS)", + "decimals": 6 + }, + "srcNetwork": "Ethereum", + "dstNetwork": "Polygon" + } + ], + "count": 1 +} +``` + +**GET** `/api/mappings/token/:address/:chainId` + +Get all destination mappings for a specific source token. + +```bash +curl http://localhost:3000/api/mappings/token/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/1 +``` + +Example output: +```json +{ + "mappings": [ + { + "srcTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "srcChainId": 1, + "dstTokenAddress": "0x...", + "dstChainId": 42161, + "isActive": true, + "dstToken": { + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6 + }, + "dstNetwork": "Arbitrum", + "dstRpcUrl": "https://arb-mainnet.g.alchemy.com/v2/..." + } + ] +} +``` + +### Tokens + +**GET** `/api/tokens/:address/:chainId` + +Get token metadata for a specific token on a specific chain. + +```bash +curl http://localhost:3000/api/tokens/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/1 +``` + +Example output: +```json +{ + "token": { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6 + }, + "networkName": "Ethereum" +} +``` + +## How It Works + +### Event Indexing + +The indexer monitors `TokenMappingAdded` and `TokenMappingRemoved` events from only swaps Router contracts: + +```solidity +event TokenMappingAdded(uint256 indexed dstChainId, address indexed dstToken, address indexed srcToken); +event TokenMappingRemoved(uint256 indexed dstChainId, address indexed dstToken, address indexed srcToken); +``` + +### Sync Strategy + +The indexer automatically tracks sync progress per chain: + +1. **First Run**: Starts from `{NETWORK}_START_BLOCK` configured in `.env` +2. **Subsequent Runs**: Resumes from last synced block stored in database +3. **Progress Tracking**: Each chain's `blockNumber` updates after successful sync +4. **Incremental Syncing**: Only processes new blocks on restart + +To re-sync from a specific block, update the `blockNumber` for a network in `data/db.json`. + +### Real-time Updates + +After historical sync completes, the indexer: +- Listens for new events on each chain +- Automatically processes new mappings as they occur +- Updates the database in real-time + +## Database + +Data is stored in `data/db.json` with the following structure: + +```json +{ + "networks": [ + { + "chainId": 1, + "name": "Ethereum", + "routerAddress": "0x...", + "blockNumber": 21234567 + } + ], + "tokens": [ + { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "chainId": 1, + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6 + } + ], + "mappings": [ + { + "id": "0xA0b8...3606eB48-1-0x2791...9Aa84174-137", + "srcTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "srcChainId": 1, + "dstTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "dstChainId": 137, + "isActive": true, + "blockNumber": 12345678, + "txHash": "0xabc123...", + "timestamp": 1732636800000 + } + ] +} +``` + +### Database Schema + +- **`networks[]`**: Network sync state (chainId, name, routerAddress, last synced blockNumber) + - Note: RPC URLs are NOT stored in the database - they come from environment variables only +- **`tokens[]`**: Token metadata cache (symbol, name, decimals) +- **`mappings[]`**: Token mapping records with `isActive` flag for removals + +## Monitoring + +### Check Sync Progress + +View current sync state: + +```bash +cat data/db.json | jq '.networks[] | {name, chainId, blockNumber}' +``` + +### Application Logs + +The indexer logs all operations: + +``` +[INFO] [2025-11-26T12:00:00.000Z] Starting OnlySwaps Indexer... +[INFO] [2025-11-26T12:00:01.000Z] ✓ Database initialized +[INFO] [2025-11-26T12:00:02.000Z] [Chain 1] Syncing from block 21234567 to 21234600 +[INFO] [2025-11-26T12:00:05.000Z] [Chain 1] Sync complete. Last block: 21234600 +[INFO] [2025-11-26T12:00:05.500Z] [Chain 1] Listening for events... +[INFO] [2025-11-26T12:00:06.000Z] ✓ API server running on http://localhost:3000 +``` + +## Troubleshooting + +### RPC Rate Limiting + +If syncing is slow or timing out: +- Reduce `BLOCK_CHUNK_SIZE` in `.env` (e.g., from 10000 to 5000) +- Use premium RPC endpoints with higher rate limits +- Set start blocks closer to deployment to reduce sync time + +### Events Not Appearing + +1. Verify router addresses are correct in `.env` +2. Check start blocks are before first mapping event +3. Ensure RPC URLs are working and have correct API keys +4. Review logs for sync errors + +### Port Already in Use + +Change the port in `.env`: + +```env +PORT=3001 +``` + +## Development + +### Project Structure + +``` +onlyswaps-token-routes-api/ +├── src/ +│ ├── config/ +│ │ └── networks.ts # Network configurations +│ ├── services/ +│ │ ├── database.ts # LowDB service +│ │ ├── event-indexer.ts # Blockchain event indexing +│ │ └── token-metadata.ts # ERC20 metadata fetching +│ ├── api/ +│ │ ├── server.ts # Express server +│ │ └── routes/ +│ │ ├── networks.ts # Network endpoints +│ │ ├── mappings.ts # Mapping endpoints +│ │ └── tokens.ts # Token endpoints +│ ├── types/ +│ │ └── index.ts # TypeScript types +│ ├── utils/ +│ │ └── logger.ts # Logging utility +│ ├── index.ts # Main entry point +│ └── sync.ts # Sync-only script +├── data/ +│ └── db.json # Database file +└── package.json +``` + +### Type Checking + +```bash +npx tsc --noEmit +``` + +## License + +See parent repository LICENSE file. \ No newline at end of file diff --git a/onlyswaps-token-routes-api/package.json b/onlyswaps-token-routes-api/package.json new file mode 100644 index 00000000..4a48f6dc --- /dev/null +++ b/onlyswaps-token-routes-api/package.json @@ -0,0 +1,26 @@ +{ + "name": "onlyswaps-token-routes-api", + "version": "0.0.1", + "description": "Token mapping indexer for Only Swaps protocol", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "sync": "ts-node src/sync.ts" + }, + "dependencies": { + "ethers": "^6.9.0", + "express": "^4.18.2", + "lowdb": "^6.1.1", + "dotenv": "^16.3.1", + "cors": "^2.8.5" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "typescript": "^5.3.2", + "ts-node": "^10.9.1" + } +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/api/routes/mappings.ts b/onlyswaps-token-routes-api/src/api/routes/mappings.ts new file mode 100644 index 00000000..93084c6a --- /dev/null +++ b/onlyswaps-token-routes-api/src/api/routes/mappings.ts @@ -0,0 +1,72 @@ +import { Router } from "express"; +import { DatabaseService } from "../../services/database"; +import { logger } from "../../utils/logger"; + +export function createMappingsRouter(db: DatabaseService) { + const router = Router(); + + router.get("/", async (req, res) => { + try { + const { srcChainId, dstChainId } = req.query; + + const mappings = await db.getTokenMappings( + srcChainId ? Number(srcChainId) : undefined, + dstChainId ? Number(dstChainId) : undefined + ); + + // Enrich with token metadata + const enrichedMappings = await Promise.all( + mappings.map(async (mapping) => { + const srcToken = await db.getToken(mapping.srcTokenAddress, mapping.srcChainId); + const dstToken = await db.getToken(mapping.dstTokenAddress, mapping.dstChainId); + const srcNetwork = await db.getNetwork(mapping.srcChainId); + const dstNetwork = await db.getNetwork(mapping.dstChainId); + + return { + ...mapping, + srcToken, + dstToken, + srcNetwork: srcNetwork?.name, + dstNetwork: dstNetwork?.name + }; + }) + ); + + res.json(enrichedMappings); + } catch (error) { + logger.error("Error fetching mappings:", error); + res.status(500).json({ error: "Failed to fetch mappings" }); + } + }); + + router.get("/token/:address/:chainId", async (req, res) => { + try { + const { address, chainId } = req.params; + const mappings = await db.getTokenMappings(Number(chainId)); + + const filtered = mappings.filter( + m => m.srcTokenAddress.toLowerCase() === address.toLowerCase() + ); + + const enriched = await Promise.all( + filtered.map(async (mapping) => { + const dstToken = await db.getToken(mapping.dstTokenAddress, mapping.dstChainId); + const dstNetwork = await db.getNetwork(mapping.dstChainId); + + return { + ...mapping, + dstToken, + dstNetwork: dstNetwork?.name + }; + }) + ); + + res.json(enriched); + } catch (error) { + logger.error("Error fetching token mappings:", error); + res.status(500).json({ error: "Failed to fetch token mappings" }); + } + }); + + return router; +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/api/routes/networks.ts b/onlyswaps-token-routes-api/src/api/routes/networks.ts new file mode 100644 index 00000000..ba99ccf1 --- /dev/null +++ b/onlyswaps-token-routes-api/src/api/routes/networks.ts @@ -0,0 +1,35 @@ +import { Router } from "express"; +import { DatabaseService } from "../../services/database"; +import { logger } from "../../utils/logger"; + +export function createNetworksRouter(db: DatabaseService) { + const router = Router(); + + router.get("/", async (req, res) => { + try { + const networks = await db.getNetworks(); + res.json(networks); + } catch (error) { + logger.error("Error fetching networks:", error); + res.status(500).json({ error: "Failed to fetch networks" }); + } + }); + + router.get("/:chainId", async (req, res) => { + try { + const chainId = Number(req.params.chainId); + const network = await db.getNetwork(chainId); + + if (!network) { + return res.status(404).json({ error: "Network not found" }); + } + + res.json(network); + } catch (error) { + logger.error("Error fetching network:", error); + res.status(500).json({ error: "Failed to fetch network" }); + } + }); + + return router; +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/api/routes/tokens.ts b/onlyswaps-token-routes-api/src/api/routes/tokens.ts new file mode 100644 index 00000000..a6887cc1 --- /dev/null +++ b/onlyswaps-token-routes-api/src/api/routes/tokens.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { DatabaseService } from "../../services/database"; +import { logger } from "../../utils/logger"; + +export function createTokensRouter(db: DatabaseService) { + const router = Router(); + + router.get("/:address/:chainId", async (req, res) => { + try { + const { address, chainId } = req.params; + const token = await db.getToken(address, Number(chainId)); + + if (!token) { + return res.status(404).json({ error: "Token not found" }); + } + + const network = await db.getNetwork(token.chainId); + + res.json({ + ...token, + networkName: network?.name + }); + } catch (error) { + logger.error("Error fetching token:", error); + res.status(500).json({ error: "Failed to fetch token" }); + } + }); + + return router; +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/api/server.ts b/onlyswaps-token-routes-api/src/api/server.ts new file mode 100644 index 00000000..8477557e --- /dev/null +++ b/onlyswaps-token-routes-api/src/api/server.ts @@ -0,0 +1,25 @@ +import express from "express"; +import cors from "cors"; +import { DatabaseService } from "../services/database"; +import { createNetworksRouter } from "./routes/networks"; +import { createMappingsRouter } from "./routes/mappings"; +import { createTokensRouter } from "./routes/tokens"; + +export function createServer(db: DatabaseService) { + const app = express(); + + app.use(cors()); + app.use(express.json()); + + // Health check + app.get("/health", (req, res) => { + res.json({ status: "ok", timestamp: new Date().toISOString() }); + }); + + // Routes + app.use("/api/networks", createNetworksRouter(db)); + app.use("/api/mappings", createMappingsRouter(db)); + app.use("/api/tokens", createTokensRouter(db)); + + return app; +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/config/networks.ts b/onlyswaps-token-routes-api/src/config/networks.ts new file mode 100644 index 00000000..1a587249 --- /dev/null +++ b/onlyswaps-token-routes-api/src/config/networks.ts @@ -0,0 +1,95 @@ +import { NetworkConfig } from "../types"; + +export const networks: NetworkConfig[] = [ + { + chainId: 42161, + name: "Arbitrum", + rpcUrl: process.env.ARBITRUM_RPC_URL || "", + routerAddress: process.env.ARBITRUM_ROUTER || "", + blockNumber: Number(process.env.ARBITRUM_START_BLOCK || 0) + }, + { + chainId: 5042002, + name: "Arc Testnet", + rpcUrl: process.env.ARC_TESTNET_RPC_URL || "", + routerAddress: process.env.ARC_TESTNET_ROUTER || "", + blockNumber: Number(process.env.ARC_TESTNET_START_BLOCK || 0) + }, + { + chainId: 43113, + name: "Avalanche Fuji", + rpcUrl: process.env.AVALANCHE_FUJI_RPC_URL || "", + routerAddress: process.env.AVALANCHE_FUJI_ROUTER || "", + blockNumber: Number(process.env.AVALANCHE_FUJI_START_BLOCK || 0) + }, + { + chainId: 43114, + name: "Avalanche", + rpcUrl: process.env.AVALANCHE_RPC_URL || "", + routerAddress: process.env.AVALANCHE_ROUTER || "", + blockNumber: Number(process.env.AVALANCHE_START_BLOCK || 0) + }, + { + chainId: 8453, + name: "Base", + rpcUrl: process.env.BASE_RPC_URL || "", + routerAddress: process.env.BASE_ROUTER || "", + blockNumber: Number(process.env.BASE_START_BLOCK || 0) + }, + { + chainId: 84532, + name: "Base Sepolia", + rpcUrl: process.env.BASE_SEPOLIA_RPC_URL || "", + routerAddress: process.env.BASE_SEPOLIA_ROUTER || "", + blockNumber: Number(process.env.BASE_SEPOLIA_START_BLOCK || 0) + }, + { + chainId: 56, + name: "Binance", + rpcUrl: process.env.BINANCE_RPC_URL || "", + routerAddress: process.env.BINANCE_ROUTER || "", + blockNumber: Number(process.env.BINANCE_START_BLOCK || 0) + }, + { + chainId: 1, + name: "Ethereum", + rpcUrl: process.env.ETHEREUM_RPC_URL || "", + routerAddress: process.env.ETHEREUM_ROUTER || "", + blockNumber: Number(process.env.ETHEREUM_START_BLOCK || 0) + }, + { + chainId: 314, + name: "Filecoin", + rpcUrl: process.env.FILECOIN_RPC_URL || "", + routerAddress: process.env.FILECOIN_ROUTER || "", + blockNumber: Number(process.env.FILECOIN_START_BLOCK || 0) + }, + { + chainId: 314159, + name: "Filecoin Calibration", + rpcUrl: process.env.FILECOIN_CALIBRATION_RPC_URL || "", + routerAddress: process.env.FILECOIN_CALIBRATION_ROUTER || "", + blockNumber: Number(process.env.FILECOIN_CALIBRATION_START_BLOCK || 0) + }, + { + chainId: 59144, + name: "Linea", + rpcUrl: process.env.LINEA_RPC_URL || "", + routerAddress: process.env.LINEA_ROUTER || "", + blockNumber: Number(process.env.LINEA_START_BLOCK || 0) + }, + { + chainId: 10, + name: "Optimism", + rpcUrl: process.env.OP_RPC_URL || "", + routerAddress: process.env.OP_ROUTER || "", + blockNumber: Number(process.env.OP_START_BLOCK || 0) + }, + { + chainId: 534352, + name: "Scroll", + rpcUrl: process.env.SCROLL_RPC_URL || "", + routerAddress: process.env.SCROLL_ROUTER || "", + blockNumber: Number(process.env.SCROLL_START_BLOCK || 0) + } +]; \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/index.ts b/onlyswaps-token-routes-api/src/index.ts new file mode 100644 index 00000000..e7efa6b2 --- /dev/null +++ b/onlyswaps-token-routes-api/src/index.ts @@ -0,0 +1,102 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import { DatabaseService } from "./services/database"; +import { TokenMetadataService } from "./services/token-metadata"; +import { EventIndexer } from "./services/event-indexer"; +import { createServer } from "./api/server"; +import { networks } from "./config/networks"; +import { logger } from "./utils/logger"; + +async function main() { + logger.info("🚀 Starting Only Swaps Indexer..."); + + // Initialize database + const db = new DatabaseService(); + await db.initialize(); + logger.info("✓ Database initialized"); + + // Insert/update networks in DB + for (const network of networks) { + await db.upsertNetwork({ + chainId: network.chainId, + name: network.name, + routerAddress: network.routerAddress, + blockNumber: network.blockNumber + }); + } + logger.info("✓ Networks configured"); + + const tokenMetadata = new TokenMetadataService(networks); + const indexer = new EventIndexer(networks, db, tokenMetadata); + + // + // --- Start API server IMMEDIATELY --- + // + const app = createServer(db); + const PORT = process.env.PORT || 3000; + + app.listen(PORT, () => { + logger.info(`🌐 API server running on http://localhost:${PORT}`); + logger.info("Endpoints:"); + logger.info(" GET /health"); + logger.info(" GET /api/networks"); + logger.info(" GET /api/networks/:chainId"); + logger.info(" GET /api/mappings"); + logger.info(" GET /api/mappings?srcChainId=1&dstChainId=137"); + logger.info(" GET /api/mappings/token/:address/:chainId"); + logger.info(" GET /api/tokens/:address/:chainId"); + }); + + // + // --- Sync each chain sequentially --- + for (const network of networks) { + + const chainPrefix = `[${network.name}][${network.chainId}]`; + logger.info(`${chainPrefix} --- Starting sync ---`); + + // If config start block is zero, skip historical sync and ignore DB block + if (network.blockNumber === 0) { + logger.info(`${chainPrefix} Config start block is zero → skipping historical sync.`); + } else { + const savedNetwork = await db.getNetwork(network.chainId); + let fromBlock: number; + + // Determine starting block + if (savedNetwork?.blockNumber && savedNetwork.blockNumber > 0) { + fromBlock = savedNetwork.blockNumber + 1; + } else if (network.blockNumber > 0) { + fromBlock = network.blockNumber; + } else { + const provider = indexer.getProvider(network.chainId); + const latestBlock = await provider.getBlockNumber(); + fromBlock = latestBlock; + logger.info(`${chainPrefix} No start block configured → using latest block ${latestBlock}`); + } + + logger.info(`${chainPrefix} Syncing from block ${fromBlock}...`); + + try { + await indexer.syncHistoricalEvents(network.chainId, fromBlock); + logger.info(`${chainPrefix} ✓ Historical sync finished`); + } catch (err) { + logger.error(`${chainPrefix} ❌ Historical sync failed:`, err); + } + } + + // Start live event listener + try { + await indexer.startListening(network.chainId); + logger.info(`${chainPrefix} 👂 Listening for new events...`); + } catch (err) { + logger.error(`${chainPrefix} ❌ Failed to start listener:`, err); + } + } +} + +main().catch((error) => { + logger.error("Application crashed:", error); + process.exit(1); +}); + + diff --git a/onlyswaps-token-routes-api/src/services/database.ts b/onlyswaps-token-routes-api/src/services/database.ts new file mode 100644 index 00000000..cce082d5 --- /dev/null +++ b/onlyswaps-token-routes-api/src/services/database.ts @@ -0,0 +1,154 @@ +import { Low } from "lowdb"; +import { JSONFile } from "lowdb/node"; +import path from "path"; +import { Database, TokenMapping, TokenInfo, NetworkState } from "../types"; + +export class DatabaseService { + private db!: Low; + + async initialize() { + const file = path.join(__dirname, "../../data/db.json"); + const adapter = new JSONFile(file); + this.db = new Low(adapter, { + networks: [], + tokens: [], + mappings: [] + }); + + await this.db.read(); + + // Initialize with default data if empty + if (!this.db.data.networks.length) { + this.db.data.networks = []; + this.db.data.tokens = []; + this.db.data.mappings = []; + await this.db.write(); + } + } + + async addTokenMapping(mapping: Omit) { + await this.db.read(); + + const id = `${mapping.srcTokenAddress}-${mapping.srcChainId}-${mapping.dstTokenAddress}-${mapping.dstChainId}`; + const existingIndex = this.db.data.mappings.findIndex(m => m.id === id); + + const newMapping: TokenMapping = { + ...mapping, + id, + timestamp: Date.now() + }; + + if (existingIndex >= 0) { + this.db.data.mappings[existingIndex] = { + ...this.db.data.mappings[existingIndex], + isActive: true, + blockNumber: mapping.blockNumber, + txHash: mapping.txHash, + timestamp: Date.now() + }; + } else { + this.db.data.mappings.push(newMapping); + } + + await this.db.write(); + } + + async removeTokenMapping(params: { + srcTokenAddress: string; + srcChainId: number; + dstTokenAddress: string; + dstChainId: number; + }) { + await this.db.read(); + + const id = `${params.srcTokenAddress}-${params.srcChainId}-${params.dstTokenAddress}-${params.dstChainId}`; + const mapping = this.db.data.mappings.find(m => m.id === id); + + if (mapping) { + mapping.isActive = false; + mapping.timestamp = Date.now(); + await this.db.write(); + } + } + + async upsertToken(token: TokenInfo) { + await this.db.read(); + + const existingIndex = this.db.data.tokens.findIndex( + t => t.address.toLowerCase() === token.address.toLowerCase() && t.chainId === token.chainId + ); + + if (existingIndex >= 0) { + this.db.data.tokens[existingIndex] = { + ...this.db.data.tokens[existingIndex], + ...token + }; + } else { + this.db.data.tokens.push(token); + } + + await this.db.write(); + } + + async updateNetworkBlockNumber(chainId: number, blockNumber: number) { + await this.db.read(); + + const network = this.db.data.networks.find(n => n.chainId === chainId); + if (network) { + network.blockNumber = blockNumber; + await this.db.write(); + } + } + + async upsertNetwork(network: NetworkState) { + await this.db.read(); + + const existingIndex = this.db.data.networks.findIndex(n => n.chainId === network.chainId); + + if (existingIndex >= 0) { + // Update existing, preserving blockNumber if higher + const existing = this.db.data.networks[existingIndex]; + this.db.data.networks[existingIndex] = { + ...network, + blockNumber: Math.max(existing.blockNumber || 0, network.blockNumber || 0) + }; + } else { + this.db.data.networks.push(network); + } + + await this.db.write(); + } + + async getTokenMappings(srcChainId?: number, dstChainId?: number): Promise { + await this.db.read(); + + let mappings = this.db.data.mappings.filter(m => m.isActive); + + if (srcChainId !== undefined) { + mappings = mappings.filter(m => m.srcChainId === srcChainId); + } + + if (dstChainId !== undefined) { + mappings = mappings.filter(m => m.dstChainId === dstChainId); + } + + return mappings; + } + + async getToken(address: string, chainId: number): Promise { + await this.db.read(); + return this.db.data.tokens.find( + t => t.address.toLowerCase() === address.toLowerCase() && t.chainId === chainId + ); + } + + async getNetworks(): Promise { + await this.db.read(); + return this.db.data.networks; + } + + async getNetwork(chainId: number): Promise { + await this.db.read(); + return this.db.data.networks.find(n => n.chainId === chainId); + } +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/services/event-indexer.ts b/onlyswaps-token-routes-api/src/services/event-indexer.ts new file mode 100644 index 00000000..a0c95aed --- /dev/null +++ b/onlyswaps-token-routes-api/src/services/event-indexer.ts @@ -0,0 +1,275 @@ +import { ethers } from "ethers"; +import { DatabaseService } from "./database"; +import { TokenMetadataService } from "./token-metadata"; +import { NetworkConfig } from "../types"; +import { logger } from "../utils/logger"; + +const ROUTER_ABI = [ + "event TokenMappingAdded(uint256 indexed dstChainId, address indexed dstToken, address indexed srcToken)", + "event TokenMappingRemoved(uint256 indexed dstChainId, address indexed dstToken, address indexed srcToken)" +]; + +export class EventIndexer { + private providers: Map = new Map(); + private contracts: Map = new Map(); + private networkNames: Map = new Map(); + + constructor( + private networks: NetworkConfig[], + private db: DatabaseService, + private tokenMetadata: TokenMetadataService + ) { + this.initializeNetworks(); + } + + private getNetworkName(chainId: number): string { + return this.networkNames.get(chainId) || `Chain ${chainId}`; + } + + public getProvider(chainId: number): ethers.Provider { + const provider = this.providers.get(chainId); + if (!provider) { + throw new Error(`Provider not found for chain ${chainId}`); + } + return provider; + } + + private initializeNetworks() { + for (const network of this.networks) { + const provider = new ethers.JsonRpcProvider(network.rpcUrl); + const contract = new ethers.Contract(network.routerAddress, ROUTER_ABI, provider); + + this.providers.set(network.chainId, provider); + this.contracts.set(network.chainId, contract); + this.networkNames.set(network.chainId, network.name); + } + } + + async syncHistoricalEvents(chainId: number, fromBlock: number = 0) { + const contract = this.contracts.get(chainId); + const provider = this.providers.get(chainId); + + if (!contract || !provider) { + throw new Error(`Contract or provider not found for chain ${chainId}`); + } + + const currentBlock = await provider.getBlockNumber(); + const networkName = this.getNetworkName(chainId); + const totalBlocks = currentBlock - fromBlock; + logger.info(`[${networkName}] Syncing from block ${fromBlock} to ${currentBlock} (${totalBlocks} blocks)`); + + const chunkSize = Number(process.env.BLOCK_CHUNK_SIZE) || 10000; + + for (let start = fromBlock; start < currentBlock; start += chunkSize) { + const end = Math.min(start + chunkSize - 1, currentBlock); + + logger.info(`[${networkName}] Processing blocks ${start} to ${end}`); + + // Get TokenMappingAdded events - query with event signature topic + // The signature hash should match: 0x00f6b276aebfc163c3646a63a0286f845fdd2df56be91b61b62044067ce849a4 + const addedEventSignature = "0x00f6b276aebfc163c3646a63a0286f845fdd2df56be91b61b62044067ce849a4"; + const addedEvents = await provider.getLogs({ + address: contract.target, + topics: [addedEventSignature], + fromBlock: start, + toBlock: end + }); + + if (addedEvents.length > 0) { + logger.info(`[${networkName}] Found ${addedEvents.length} TokenMappingAdded event(s)`); + // Log first event to debug + const first = addedEvents[0]; + logger.info(`[${networkName}] First event found: ${JSON.stringify({ + topics: first.topics, + data: first.data, + topicsLength: first.topics.length + })}`); + } + + for (const event of addedEvents) { + try { + // Check if data field has the parameters instead of topics + if (event.data && event.data !== '0x' && event.data.length > 2) { + // Parameters are in data field, not indexed + logger.info(`[${networkName}] Decoding from data field: ${event.data}`); + // Data contains: dstChainId (32 bytes), dstToken (32 bytes), srcToken (32 bytes) + const dstChainId = BigInt('0x' + event.data.slice(2, 66)); + const dstToken = ethers.getAddress('0x' + event.data.slice(90, 130)); + const srcToken = ethers.getAddress('0x' + event.data.slice(154, 194)); + + const eventWithArgs = { + args: { dstChainId, dstToken, srcToken }, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash + }; + + await this.handleTokenMappingAdded(chainId, eventWithArgs); + continue; + } + + // All parameters are indexed, so they're in topics[1], topics[2], topics[3] + // topics[0] is the event signature + if (event.topics.length < 4) { + logger.warn(`[${networkName}] Event missing topics: ${event.topics.length}, topics: ${JSON.stringify(event.topics)}`); + continue; + } + + // Manually decode indexed parameters from topics + const dstChainId = BigInt(event.topics[1]); + const dstToken = ethers.getAddress('0x' + event.topics[2].slice(26)); // Remove padding + const srcToken = ethers.getAddress('0x' + event.topics[3].slice(26)); // Remove padding + + const eventWithArgs = { + args: { + dstChainId, + dstToken, + srcToken + }, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash + }; + + await this.handleTokenMappingAdded(chainId, eventWithArgs); + } catch (error) { + logger.error(`[${networkName}] Failed to handle TokenMappingAdded event: ${error}`); + // Continue processing other events + } + } + + // Get TokenMappingRemoved events + const removedEvents = await provider.getLogs({ + address: contract.target, + topics: [ethers.id("TokenMappingRemoved(uint256,address,address)")], + fromBlock: start, + toBlock: end + }); + + if (removedEvents.length > 0) { + logger.info(`[${networkName}] Found ${removedEvents.length} TokenMappingRemoved event(s)`); + } + + for (const event of removedEvents) { + try { + // All parameters are indexed, so they're in topics[1], topics[2], topics[3] + if (event.topics.length < 4) { + logger.warn(`[${networkName}] Event missing topics: ${event.topics.length}`); + continue; + } + + // Manually decode indexed parameters from topics + const dstChainId = BigInt(event.topics[1]); + const dstToken = ethers.getAddress('0x' + event.topics[2].slice(26)); // Remove padding + const srcToken = ethers.getAddress('0x' + event.topics[3].slice(26)); // Remove padding + + const eventWithArgs = { + args: { + dstChainId, + dstToken, + srcToken + }, + blockNumber: event.blockNumber, + transactionHash: event.transactionHash + }; + + await this.handleTokenMappingRemoved(chainId, eventWithArgs); + } catch (error) { + logger.error(`[${networkName}] Failed to handle TokenMappingRemoved event: ${error}`); + // Continue processing other events + } + } + + // Update progress after each chunk + await this.db.updateNetworkBlockNumber(chainId, end); + + // Add delay between chunks to avoid rate limiting + const delayMs = Number(process.env.REQUEST_DELAY_MS) || 200; + if (delayMs > 0) { + await this.sleep(delayMs); + } + } + + logger.info(`[${networkName}] Sync complete. Last block: ${currentBlock}`); + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + async startListening(chainId: number) { + const contract = this.contracts.get(chainId); + if (!contract) { + throw new Error(`Contract not found for chain ${chainId}`); + } + + const networkName = this.getNetworkName(chainId); + + contract.on("TokenMappingAdded", async (dstChainId, dstToken, srcToken, event) => { + logger.info(`[${networkName}] TokenMappingAdded: ${srcToken} -> ${dstToken} (chain ${dstChainId})`); + await this.handleTokenMappingAdded(chainId, event); + }); + + contract.on("TokenMappingRemoved", async (dstChainId, dstToken, srcToken, event) => { + logger.info(`[${networkName}] TokenMappingRemoved: ${srcToken} -> ${dstToken} (chain ${dstChainId})`); + await this.handleTokenMappingRemoved(chainId, event); + }); + + logger.info(`[${networkName}] Listening for events...`); + } + + private async handleTokenMappingAdded(srcChainId: number, event: any) { + try { + const { dstChainId, dstToken, srcToken } = event.args; + const blockNumber = event.blockNumber; + const txHash = event.transactionHash; + + logger.info(`Processing mapping: ${srcToken} -> ${dstToken} (chain ${dstChainId}) at block ${blockNumber}`); + + // Fetch and store token metadata + logger.info(`Fetching metadata for src token ${srcToken} on chain ${srcChainId}`); + await this.fetchAndStoreTokenMetadata(srcToken, srcChainId); + + logger.info(`Fetching metadata for dst token ${dstToken} on chain ${dstChainId}`); + await this.fetchAndStoreTokenMetadata(dstToken, Number(dstChainId)); + + // Store mapping + logger.info(`Storing mapping in database...`); + await this.db.addTokenMapping({ + srcTokenAddress: srcToken, + srcChainId: srcChainId, + dstTokenAddress: dstToken, + dstChainId: Number(dstChainId), + blockNumber: blockNumber, + txHash: txHash, + isActive: true + }); + + logger.info(`✓ Successfully stored mapping: ${srcToken} -> ${dstToken}`); + } catch (error) { + logger.error(`Error handling TokenMappingAdded: ${error}`); + throw error; + } + } + + private async handleTokenMappingRemoved(srcChainId: number, event: any) { + const { dstChainId, dstToken, srcToken } = event.args; + + await this.db.removeTokenMapping({ + srcTokenAddress: srcToken, + srcChainId: srcChainId, + dstTokenAddress: dstToken, + dstChainId: Number(dstChainId) + }); + } + + private async fetchAndStoreTokenMetadata(tokenAddress: string, chainId: number) { + try { + const metadata = await this.tokenMetadata.fetchTokenMetadata(tokenAddress, chainId); + if (metadata) { + await this.db.upsertToken(metadata); + } + } catch (error) { + logger.warn(`Failed to fetch metadata for ${tokenAddress} on chain ${chainId}, continuing anyway: ${error}`); + // Don't throw - we still want to store the mapping even if metadata fetch fails + } + } +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/services/token-metadata.ts b/onlyswaps-token-routes-api/src/services/token-metadata.ts new file mode 100644 index 00000000..5c2392b0 --- /dev/null +++ b/onlyswaps-token-routes-api/src/services/token-metadata.ts @@ -0,0 +1,49 @@ +import { ethers } from "ethers"; +import { TokenInfo } from "../types"; +import { logger } from "../utils/logger"; + +const ERC20_ABI = [ + "function symbol() view returns (string)", + "function name() view returns (string)", + "function decimals() view returns (uint8)" +]; + +export class TokenMetadataService { + private providers: Map = new Map(); + + constructor(networks: { chainId: number; rpcUrl: string }[]) { + for (const network of networks) { + const provider = new ethers.JsonRpcProvider(network.rpcUrl); + this.providers.set(network.chainId, provider); + } + } + + async fetchTokenMetadata(tokenAddress: string, chainId: number): Promise { + const provider = this.providers.get(chainId); + if (!provider) { + logger.error(`No provider for chain ${chainId}`); + return null; + } + + try { + const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider); + + const [symbol, name, decimals] = await Promise.all([ + contract.symbol().catch(() => "UNKNOWN"), + contract.name().catch(() => "Unknown Token"), + contract.decimals().catch(() => 18) + ]); + + return { + address: tokenAddress, + chainId, + symbol, + name, + decimals: Number(decimals) + }; + } catch (error) { + logger.error(`Failed to fetch metadata for ${tokenAddress} on chain ${chainId}:`, error); + return null; + } + } +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/sync.ts b/onlyswaps-token-routes-api/src/sync.ts new file mode 100644 index 00000000..857f36cf --- /dev/null +++ b/onlyswaps-token-routes-api/src/sync.ts @@ -0,0 +1,37 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import { DatabaseService } from "./services/database"; +import { TokenMetadataService } from "./services/token-metadata"; +import { EventIndexer } from "./services/event-indexer"; +import { networks } from "./config/networks"; +import { logger } from "./utils/logger"; + +async function sync() { + logger.info("Starting sync..."); + + const db = new DatabaseService(); + await db.initialize(); + + const tokenMetadata = new TokenMetadataService(networks); + const indexer = new EventIndexer(networks, db, tokenMetadata); + + for (const network of networks) { + const savedNetwork = await db.getNetwork(network.chainId); + // Use last synced block from DB, fall back to env config start block, then 0 + const fromBlock = (savedNetwork?.blockNumber && savedNetwork.blockNumber > 0) + ? savedNetwork.blockNumber + 1 // Start from next block after last synced + : network.blockNumber; // Use configured start block from env + + logger.info(`\nSyncing ${network.name} from block ${fromBlock}...`); + await indexer.syncHistoricalEvents(network.chainId, fromBlock); + } + + logger.info("\n✓ Sync complete"); + process.exit(0); +} + +sync().catch((error) => { + logger.error("Sync failed:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/types/index.ts b/onlyswaps-token-routes-api/src/types/index.ts new file mode 100644 index 00000000..3e2bd778 --- /dev/null +++ b/onlyswaps-token-routes-api/src/types/index.ts @@ -0,0 +1,40 @@ +export interface NetworkConfig { + chainId: number; + name: string; + rpcUrl: string; + routerAddress: string; + blockNumber: number; +} + +export interface NetworkState { + chainId: number; + name: string; + routerAddress: string; + blockNumber: number; +} + +export interface TokenInfo { + address: string; + chainId: number; + symbol?: string; + name?: string; + decimals?: number; +} + +export interface TokenMapping { + id: string; + srcTokenAddress: string; + srcChainId: number; + dstTokenAddress: string; + dstChainId: number; + isActive: boolean; + blockNumber: number; + txHash: string; + timestamp: number; +} + +export interface Database { + networks: NetworkState[]; + tokens: TokenInfo[]; + mappings: TokenMapping[]; +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/src/utils/logger.ts b/onlyswaps-token-routes-api/src/utils/logger.ts new file mode 100644 index 00000000..dd9926a9 --- /dev/null +++ b/onlyswaps-token-routes-api/src/utils/logger.ts @@ -0,0 +1,106 @@ +import { ethers } from "ethers"; + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +class Logger { + private level: LogLevel; + private prefix: string; + + constructor(prefix: string = "only swaps", level: LogLevel = LogLevel.INFO) { + this.prefix = prefix; + this.level = level; + } + + setLevel(level: LogLevel) { + this.level = level; + } + + private shouldLog(level: LogLevel): boolean { + return level >= this.level; + } + + private formatMessage(level: string, message: string, context?: any): string { + const timestamp = new Date().toISOString(); + const contextStr = context ? ` ${JSON.stringify(context)}` : ""; + return `[${timestamp}] [${this.prefix}] [${level}] ${message}${contextStr}`; + } + + debug(message: string, context?: any) { + if (this.shouldLog(LogLevel.DEBUG)) { + console.debug(this.formatMessage("DEBUG", message, context)); + } + } + + info(message: string, context?: any) { + if (this.shouldLog(LogLevel.INFO)) { + console.log(this.formatMessage("INFO", message, context)); + } + } + + warn(message: string, context?: any) { + if (this.shouldLog(LogLevel.WARN)) { + console.warn(this.formatMessage("WARN", message, context)); + } + } + + error(message: string, error?: Error | any) { + if (this.shouldLog(LogLevel.ERROR)) { + const context = error instanceof Error ? { message: error.message, stack: error.stack } : error; + console.error(this.formatMessage("ERROR", message, context)); + } + } + + // Specialized logging for blockchain events + event(eventName: string, chainId: number, data?: any) { + this.info(`Event: ${eventName} on chain ${chainId}`, data); + } + + // Log transaction details + transaction(txHash: string, chainId: number, description: string) { + this.info(`Transaction: ${description}`, { txHash, chainId }); + } + + // Log block processing + blockRange(chainId: number, startBlock: number, endBlock: number) { + this.debug(`Processing blocks ${startBlock} to ${endBlock} on chain ${chainId}`); + } + + // Log token mapping events + tokenMapping(action: "added" | "removed", srcToken: string, srcChainId: number, dstToken: string, dstChainId: number) { + this.info(`Token mapping ${action}`, { + srcToken, + srcChainId, + dstToken, + dstChainId + }); + } + + // Log API requests + apiRequest(method: string, path: string, statusCode?: number) { + this.info(`API ${method} ${path}`, { statusCode }); + } + + // Log sync progress + syncProgress(chainId: number, currentBlock: number, targetBlock: number) { + const progress = ((currentBlock / targetBlock) * 100).toFixed(2); + this.info(`Sync progress for chain ${chainId}: ${progress}%`, { + currentBlock, + targetBlock + }); + } +} + +// Create and export a default logger instance +export const logger = new Logger("only swaps", + process.env.LOG_LEVEL === "debug" ? LogLevel.DEBUG : LogLevel.INFO +); + +// Export a function to create custom loggers for specific components +export function createLogger(prefix: string, level?: LogLevel): Logger { + return new Logger(prefix, level); +} \ No newline at end of file diff --git a/onlyswaps-token-routes-api/tsconfig.json b/onlyswaps-token-routes-api/tsconfig.json new file mode 100644 index 00000000..3e3712f3 --- /dev/null +++ b/onlyswaps-token-routes-api/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file