diff --git a/CLAUDE.md b/CLAUDE.md index d80e3ed0c1..8f2a74ea2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,63 @@ This file provides guidance to AI coding assistants when working with code in th - RPC provider configs: `src/templates/rpc/{provider}.yml` - All configs validated against JSON schemas in `src/templates/namespace/` +### Pool Storage Format +Gateway stores pool configurations for each connector in `src/templates/pools/{connector}.json`. The pool storage format includes complete pool information fetched from on-chain data to ensure token ordering and fees match the actual pool state. + +#### Pool Object Structure +Each pool entry contains: +```typescript +{ + type: 'amm' | 'clmm', // Pool type: AMM (V2) or CLMM (V3) + network: string, // Network name (e.g., 'mainnet-beta', 'mainnet') + baseSymbol: string, // Base token symbol (e.g., 'SOL') + quoteSymbol: string, // Quote token symbol (e.g., 'USDC') + baseTokenAddress: string, // Base token contract address (authoritative) + quoteTokenAddress: string, // Quote token contract address (authoritative) + feePct: number, // Pool fee percentage (e.g., 0.25 for 0.25%) + address: string // Pool contract address +} +``` + +#### Adding Pools via API +Use `POST /pools` to add a new pool. The route automatically: +1. Fetches pool-info from the connector (authoritative source) +2. Extracts baseTokenAddress, quoteTokenAddress, and feePct +3. Resolves token symbols from addresses (if not provided) +4. Validates all required fields +5. Stores the enhanced pool object + +Example request: +```bash +curl -X POST http://localhost:15888/pools \ + -H "Content-Type: application/json" \ + -d '{ + "connector": "raydium", + "type": "amm", + "network": "mainnet-beta", + "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" + }' +``` + +The API will fetch pool-info and store complete pool data including token addresses and fees. + +#### Pool Template Migration +To migrate existing pool templates from the old format (symbol-only) to the new format (with token addresses and fees): + +```bash +# Ensure RPC endpoints are configured in conf/rpc/*.yml +npx ts-node scripts/migrate-pool-templates.ts +``` + +The migration script: +- Processes raydium.json, meteora.json, and uniswap.json +- Fetches pool-info for each pool address +- Extracts baseTokenAddress, quoteTokenAddress, and feePct from on-chain data +- Writes updated template files with the new format +- Reports success/failure counts for each connector + +After migration, review the updated template files before committing to ensure all pools were migrated successfully. + ### RPC Provider Configuration Gateway supports optimized RPC providers for enhanced performance: - **Infura** (Ethereum): `conf/rpc/infura.yml` - Set `rpcProvider: infura` in network configs diff --git a/CLMM_POOL_ADDRESS_STANDARDIZATION.md b/CLMM_POOL_ADDRESS_STANDARDIZATION.md new file mode 100644 index 0000000000..1c33cc0d96 --- /dev/null +++ b/CLMM_POOL_ADDRESS_STANDARDIZATION.md @@ -0,0 +1,515 @@ +# CLMM Open Position Cleanup Plan + +## Executive Summary + +Clean up CLMM `open-position` implementations to remove legacy pool lookup code and align with the existing schema requirement that `poolAddress` is mandatory. This is a **non-breaking change** since the schema already requires `poolAddress`. + +## Current State + +### Schema Status (Already Correct) ✅ + +**Global CLMM Schema** (`src/schemas/clmm-schema.ts`): +```typescript +export const OpenPositionRequest = Type.Object({ + network: Type.Optional(Type.String()), + walletAddress: Type.Optional(Type.String()), + lowerPrice: Type.Number(), + upperPrice: Type.Number(), + poolAddress: Type.String(), // ✅ REQUIRED + baseTokenAmount: Type.Optional(Type.Number()), + quoteTokenAmount: Type.Optional(Type.Number()), + slippagePct: Type.Optional(Type.Number()), +}); +``` + +**Connector Schemas** (All Correct): +- ✅ Raydium: `poolAddress: Type.String()` - Required +- ✅ Uniswap: `poolAddress: Type.String()` - Required +- ✅ Meteora: Uses global schema - Required +- ✅ PancakeSwap: Uses global schema - Required + +### Implementation Issues (Need Cleanup) ❌ + +All connectors correctly require `poolAddress`, but some have legacy code: + +#### 1. Raydium CLMM - Unused Parameters and Dead Code +**File**: `src/connectors/raydium/clmm-routes/openPosition.ts` + +**Problems**: +- Lines 25-26: Unused `baseTokenSymbol?` and `quoteTokenSymbol?` parameters +- Lines 35-46: Commented-out pool lookup logic (dead code) +- Lines 202-203: Passes `undefined` for removed parameters + +#### 2. Uniswap CLMM - Misleading Schema Examples +**File**: `src/connectors/uniswap/clmm-routes/openPosition.ts` + +**Problem**: +- Lines 55-56: Schema examples show `baseToken` and `quoteToken` that don't exist in actual schema + +#### 3. Meteora CLMM - Clean ✅ +**File**: `src/connectors/meteora/clmm-routes/openPosition.ts` +- No issues, implementation is clean + +#### 4. PancakeSwap CLMM - Clean ✅ +**File**: `src/connectors/pancakeswap/clmm-routes/openPosition.ts` +- No issues, implementation is clean + +### Swap Operations - OUT OF SCOPE + +**Decision**: Leave swap operations unchanged. They can continue to support pool lookup from token pairs. + +**Rationale**: +- Swaps are different from liquidity operations +- Pool lookup for swaps is a convenience feature +- No consistency issue - swaps don't require explicit pool selection +- Router-style swaps often don't specify pools + +## Problems to Fix + +### Problem 1: Raydium Has Legacy Pool Lookup Code + +**File**: `src/connectors/raydium/clmm-routes/openPosition.ts` + +**Issue**: Function signature has unused parameters from old design where pool could be looked up from token symbols. + +**Current code** (lines 16-28): +```typescript +async function openPosition( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + lowerPrice: number, + upperPrice: number, + poolAddress: string, + baseTokenAmount?: number, + quoteTokenAmount?: number, + baseTokenSymbol?: string, // ❌ UNUSED - should be removed + quoteTokenSymbol?: string, // ❌ UNUSED - should be removed + slippagePct?: number, +): Promise { +``` + +**Dead code** (lines 35-46): +```typescript + // If no pool address provided, find default pool using base and quote tokens + let poolAddressToUse = poolAddress; + if (!poolAddressToUse) { + if (!baseTokenSymbol || !quoteTokenSymbol) { + throw new Error('Either poolAddress or both baseToken and quoteToken must be provided'); + } + + poolAddressToUse = await raydium.findDefaultPool(baseTokenSymbol, quoteTokenSymbol, 'clmm'); + if (!poolAddressToUse) { + throw new Error(`No CLMM pool found for pair ${baseTokenSymbol}-${quoteTokenSymbol}`); + } + } +``` + +**Route handler** (lines 193-205): +```typescript +return await openPosition( + fastify, + networkToUse, + walletAddress, + lowerPrice, + upperPrice, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + undefined, // baseToken not needed anymore // ❌ Passes undefined + undefined, // quoteToken not needed anymore // ❌ Passes undefined + slippagePct, +); +``` + +### Problem 2: Uniswap Has Misleading Documentation + +**File**: `src/connectors/uniswap/clmm-routes/openPosition.ts` + +**Issue**: Schema documentation shows fields that don't exist in the actual schema. + +**Current code** (lines 46-60): +```typescript +schema: { + description: 'Open a new liquidity position in a Uniswap V3 pool', + tags: ['/connector/uniswap'], + body: { + ...OpenPositionRequest, + properties: { + ...OpenPositionRequest.properties, + network: { type: 'string', default: 'base' }, + walletAddress: { type: 'string', examples: [walletAddressExample] }, + lowerPrice: { type: 'number', examples: [1000] }, + upperPrice: { type: 'number', examples: [4000] }, + poolAddress: { type: 'string', examples: [''] }, + baseToken: { type: 'string', examples: ['WETH'] }, // ❌ NOT in schema + quoteToken: { type: 'string', examples: ['USDC'] }, // ❌ NOT in schema + baseTokenAmount: { type: 'number', examples: [0.001] }, + quoteTokenAmount: { type: 'number', examples: [3] }, + slippagePct: { type: 'number', examples: [1] }, + }, + }, +``` + +**Note**: `baseToken` and `quoteToken` are NOT fields in `OpenPositionRequest` - these are just misleading examples that could confuse API users. + +## Proposed Changes + +### Change 1: Clean Up Raydium Open Position + +**File**: `src/connectors/raydium/clmm-routes/openPosition.ts` + +#### 1.1 Update Function Signature (lines 16-28) + +**Before**: +```typescript +async function openPosition( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + lowerPrice: number, + upperPrice: number, + poolAddress: string, + baseTokenAmount?: number, + quoteTokenAmount?: number, + baseTokenSymbol?: string, + quoteTokenSymbol?: string, + slippagePct?: number, +): Promise { +``` + +**After**: +```typescript +async function openPosition( + _fastify: FastifyInstance, + network: string, + walletAddress: string, + lowerPrice: number, + upperPrice: number, + poolAddress: string, + baseTokenAmount?: number, + quoteTokenAmount?: number, + slippagePct?: number, +): Promise { +``` + +#### 1.2 Remove Dead Code (lines 35-46) + +**Delete entire block**: +```typescript + // If no pool address provided, find default pool using base and quote tokens + let poolAddressToUse = poolAddress; + if (!poolAddressToUse) { + if (!baseTokenSymbol || !quoteTokenSymbol) { + throw new Error('Either poolAddress or both baseToken and quoteToken must be provided'); + } + + poolAddressToUse = await raydium.findDefaultPool(baseTokenSymbol, quoteTokenSymbol, 'clmm'); + if (!poolAddressToUse) { + throw new Error(`No CLMM pool found for pair ${baseTokenSymbol}-${quoteTokenSymbol}`); + } + } +``` + +**Replace with**: +```typescript + // Use poolAddress directly - required by schema + const poolAddressToUse = poolAddress; +``` + +Or simply remove the variable and use `poolAddress` directly throughout (line 48 onwards). + +#### 1.3 Update Route Handler Call (lines 193-205) + +**Before**: +```typescript +return await openPosition( + fastify, + networkToUse, + walletAddress, + lowerPrice, + upperPrice, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + undefined, // baseToken not needed anymore + undefined, // quoteToken not needed anymore + slippagePct, +); +``` + +**After**: +```typescript +return await openPosition( + fastify, + networkToUse, + walletAddress, + lowerPrice, + upperPrice, + poolAddress, + baseTokenAmount, + quoteTokenAmount, + slippagePct, +); +``` + +### Change 2: Fix Uniswap Schema Examples + +**File**: `src/connectors/uniswap/clmm-routes/openPosition.ts` + +#### 2.1 Remove Misleading Examples (lines 55-56) + +**Before**: +```typescript +body: { + ...OpenPositionRequest, + properties: { + ...OpenPositionRequest.properties, + network: { type: 'string', default: 'base' }, + walletAddress: { type: 'string', examples: [walletAddressExample] }, + lowerPrice: { type: 'number', examples: [1000] }, + upperPrice: { type: 'number', examples: [4000] }, + poolAddress: { type: 'string', examples: [''] }, + baseToken: { type: 'string', examples: ['WETH'] }, // ❌ REMOVE + quoteToken: { type: 'string', examples: ['USDC'] }, // ❌ REMOVE + baseTokenAmount: { type: 'number', examples: [0.001] }, + quoteTokenAmount: { type: 'number', examples: [3] }, + slippagePct: { type: 'number', examples: [1] }, + }, +}, +``` + +**After**: +```typescript +body: { + ...OpenPositionRequest, + properties: { + ...OpenPositionRequest.properties, + network: { type: 'string', default: 'base' }, + walletAddress: { type: 'string', examples: [walletAddressExample] }, + lowerPrice: { type: 'number', examples: [1000] }, + upperPrice: { type: 'number', examples: [4000] }, + poolAddress: { type: 'string', examples: ['0xd0b53d9277642d899df5c87a3966a349a798f224'] }, + baseTokenAmount: { type: 'number', examples: [0.001] }, + quoteTokenAmount: { type: 'number', examples: [3] }, + slippagePct: { type: 'number', examples: [1] }, + }, +}, +``` + +**Note**: Also update poolAddress example to show actual pool address instead of empty string. + +## Impact Analysis + +### Breaking Changes + +✅ **NO BREAKING CHANGES** + +The schema already requires `poolAddress`, so this is purely an internal cleanup: +- API contracts unchanged +- All existing valid requests continue to work +- Invalid requests (missing poolAddress) already fail at schema validation + +### Files Changed + +**Total**: 2 files +1. `src/connectors/raydium/clmm-routes/openPosition.ts` - Remove unused code +2. `src/connectors/uniswap/clmm-routes/openPosition.ts` - Fix examples + +### Tests to Update + +**Raydium CLMM Tests**: +- No test changes needed (tests already provide poolAddress) +- Verify tests still pass after cleanup + +**Uniswap CLMM Tests**: +- No test changes needed +- Verify tests still pass after cleanup + +## Benefits + +1. **Cleaner codebase**: Removes ~15 lines of dead code +2. **Less confusion**: No misleading examples in Swagger UI +3. **Consistent**: Implementation matches schema exactly +4. **Maintainable**: Fewer code paths to maintain +5. **Clear intent**: poolAddress is always required, no ambiguity + +## Risks & Mitigation + +### Risk 1: Breaking hidden dependencies +**Likelihood**: Very low +**Mitigation**: +- Schema already enforces poolAddress requirement +- Any code relying on token lookup would already be failing +- Run full test suite to verify + +### Risk 2: Future pool lookup feature request +**Likelihood**: Low +**Mitigation**: +- Users can use `fetch-pools` endpoint +- Swap operations still support token lookup +- Document the recommended flow in examples + +## Testing Strategy + +### Unit Tests + +**Raydium CLMM**: +```bash +GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/raydium/clmm-routes/openPosition.test.ts +``` + +**Uniswap CLMM**: +```bash +GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/uniswap/clmm-routes/openPosition.test.ts +``` + +### Manual Testing + +1. **Raydium open-position** - Verify normal operation: +```bash +curl -X POST http://localhost:15888/connectors/raydium/clmm/open-position \ + -H "Content-Type: application/json" \ + -d '{ + "network": "mainnet-beta", + "walletAddress": "...", + "poolAddress": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", + "lowerPrice": 100, + "upperPrice": 200, + "baseTokenAmount": 0.01, + "slippagePct": 1 + }' +``` + +2. **Uniswap open-position** - Verify Swagger examples: +- Open http://localhost:15888/docs +- Navigate to `/connectors/uniswap/clmm/open-position` +- Verify examples don't show baseToken/quoteToken +- Verify poolAddress example shows real address + +### Regression Testing + +Run full connector test suites: +```bash +GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/raydium/ +GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/uniswap/ +GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/meteora/ +GATEWAY_TEST_MODE=dev jest --runInBand test/connectors/pancakeswap/ +``` + +## Implementation Checklist + +### Pre-Implementation +- [ ] Review plan with team +- [ ] Confirm no objections to removing dead code +- [ ] Verify test coverage exists + +### Implementation +- [ ] Update Raydium openPosition.ts function signature +- [ ] Remove Raydium dead code (lines 35-46) +- [ ] Update Raydium route handler call +- [ ] Remove Uniswap schema examples for baseToken/quoteToken +- [ ] Update Uniswap poolAddress example to real address +- [ ] Run Raydium CLMM tests +- [ ] Run Uniswap CLMM tests +- [ ] Manual testing via Swagger UI +- [ ] Manual testing via curl + +### Documentation +- [ ] Update code comments if needed +- [ ] Verify Swagger UI shows correct schema +- [ ] Check that poolAddress is marked as required in docs + +### Commit +- [ ] Create descriptive commit message +- [ ] Push changes +- [ ] Verify CI/CD passes + +## User-Facing Changes + +### Swagger UI + +**Before** (Uniswap): +```json +{ + "poolAddress": "", + "baseToken": "WETH", // ❌ Shown but not in schema + "quoteToken": "USDC", // ❌ Shown but not in schema + "lowerPrice": 1000, + ... +} +``` + +**After** (Uniswap): +```json +{ + "poolAddress": "0xd0b53d9277642d899df5c87a3966a349a798f224", + "lowerPrice": 1000, + ... +} +``` + +### API Behavior + +**No change** - API behavior is identical before and after: +- poolAddress always required +- Requests without poolAddress fail at schema validation +- Requests with poolAddress work as before + +## Timeline Estimate + +- **Implementation**: 30 minutes + - Raydium changes: 15 minutes + - Uniswap changes: 15 minutes +- **Testing**: 30 minutes + - Unit tests: 15 minutes + - Manual testing: 15 minutes +- **Documentation**: 15 minutes + - Verify Swagger + - Update comments + +**Total**: ~1.5 hours + +## Success Criteria + +✅ Implementation is successful when: +1. Raydium openPosition has no unused parameters +2. Raydium openPosition has no commented-out code +3. Uniswap schema examples don't show non-existent fields +4. All existing tests pass +5. Swagger UI shows correct schema +6. Manual testing confirms functionality unchanged + +## Related Work + +### Future Enhancements (Out of Scope) + +These are NOT part of this cleanup but could be considered separately: + +1. **Add pool validation**: Verify poolAddress exists before attempting operation +2. **Enhanced error messages**: Better errors when poolAddress is invalid +3. **Pool metadata in response**: Return pool info in successful responses +4. **Standardize other operations**: Review remove-liquidity, collect-fees, etc. + +## Questions & Answers + +**Q: Why not also clean up swap operations?** +A: Swap operations intentionally support pool lookup from token pairs as a convenience feature. This is different from liquidity operations which require explicit pool selection. + +**Q: Should we remove `Raydium.findDefaultPool()` method?** +A: Out of scope for this cleanup. It may still be used by swap operations. Can be addressed in future cleanup if truly unused. + +**Q: Will this affect Hummingbot integration?** +A: No - Hummingbot already provides poolAddress when calling open-position endpoints. + +**Q: Do we need a migration guide?** +A: No - this is not a breaking change. The schema already requires poolAddress. + +--- + +**Document Version**: 2.0 +**Created**: 2025-10-15 +**Author**: Claude Code +**Status**: Ready for Implementation +**Breaking Changes**: None +**Estimated Effort**: 1.5 hours diff --git a/README.md b/README.md index 1a25d27d81..0ae8a8a242 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,34 @@ Gateway uses [Swagger](https://swagger.io/) for API documentation. When running - `DELETE /wallet/remove` - Remove wallet - `POST /wallet/setDefault` - Set default wallet per chain +#### Pool Management Routes (`/pools/*`) +- `GET /pools` - List all configured pools for a connector +- `POST /pools` - Add a new pool (automatically fetches token addresses and fees from pool-info) +- `PUT /pools` - Update an existing pool +- `DELETE /pools` - Remove a pool + +**Pool Storage Format:** +Pools are stored with complete on-chain information to ensure accurate token ordering and fees: +```json +{ + "type": "amm", + "network": "mainnet-beta", + "baseSymbol": "SOL", + "quoteSymbol": "USDC", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.25, + "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" +} +``` + +When adding a pool via `POST /pools`, Gateway automatically: +1. Fetches pool-info from the DEX connector +2. Extracts authoritative baseTokenAddress, quoteTokenAddress, and feePct +3. Resolves token symbols from addresses +4. Validates and stores the enhanced pool object + +This ensures stored pool data always matches the actual on-chain pool state. ## Installation from Source @@ -390,6 +418,36 @@ Here are some ways that you can contribute to Gateway: - For each supported chain, token lists that translate address to symbols for each chain are stored in `/conf/tokens`. Use the `/tokens` API endpoints to manage tokens - changes require a Gateway restart to take effect. +### Pool Configuration and Migration + +Gateway stores pool configurations for AMM and CLMM connectors in `src/templates/pools/{connector}.json`. These pool templates include complete on-chain information (token addresses and fees) to ensure accurate trading operations. + +**Pool Template Files:** +- `src/templates/pools/raydium.json` - Raydium AMM and CLMM pools +- `src/templates/pools/meteora.json` - Meteora DLMM pools +- `src/templates/pools/uniswap.json` - Uniswap V2 and V3 pools + +**Migrating Pool Templates:** + +If you need to migrate pool templates from an older format (without token addresses and fees) to the new format, use the migration script: + +```bash +# Ensure RPC endpoints are configured in conf/rpc/*.yml +# Raydium/Meteora require Helius or standard Solana RPC +# Uniswap requires Infura or standard Ethereum RPC + +npx ts-node scripts/migrate-pool-templates.ts +``` + +The migration script will: +1. Read existing pool templates from `src/templates/pools/` +2. Fetch pool-info for each pool address from the respective connector +3. Extract authoritative baseTokenAddress, quoteTokenAddress, and feePct +4. Write updated template files with enhanced pool data +5. Report success/failure counts for each connector + +After migration, review the updated template files to ensure all pools were successfully migrated before committing changes. + ### RPC Provider Configuration Gateway supports optimized RPC providers for enhanced performance: diff --git a/scripts/add-bsc-tokens.ts b/scripts/add-bsc-tokens.ts new file mode 100644 index 0000000000..f7a55f436f --- /dev/null +++ b/scripts/add-bsc-tokens.ts @@ -0,0 +1,118 @@ +import { Contract } from '@ethersproject/contracts'; +import { logger } from '../src/services/logger'; +import { Ethereum } from '../src/chains/ethereum/ethereum'; +import * as fs from 'fs'; +import * as path from 'path'; + +const TOKEN_ADDRESSES = [ + '0x000Ae314E2A2172a039B26378814C252734f556A', // ASTER + '0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82', // CAKE + '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', // BUSD +]; + +const NETWORK = 'bsc'; +const BSC_CHAIN_ID = 56; +const TOKEN_FILE = path.join(__dirname, '../src/templates/tokens/ethereum/bsc.json'); + +const ERC20_ABI = [ + { + constant: true, + inputs: [], + name: 'name', + outputs: [{ name: '', type: 'string' }], + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'symbol', + outputs: [{ name: '', type: 'string' }], + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'decimals', + outputs: [{ name: '', type: 'uint8' }], + type: 'function', + }, +]; + +async function fetchTokenMetadata(address: string) { + try { + const ethereum = await Ethereum.getInstance(NETWORK); + const contract = new Contract(address, ERC20_ABI, ethereum.provider); + + const [name, symbol, decimals] = await Promise.all([ + contract.name(), + contract.symbol(), + contract.decimals(), + ]); + + return { + chainId: BSC_CHAIN_ID, + name, + symbol, + address, + decimals, + }; + } catch (error: any) { + logger.error(`Error fetching token metadata for ${address}: ${error.message}`); + return null; + } +} + +async function addTokens() { + logger.info('Fetching token metadata from BSC...\n'); + + // Load existing tokens + const existingTokens = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8')); + const existingAddresses = new Set(existingTokens.map((t: any) => t.address.toLowerCase())); + + const newTokens: any[] = []; + + for (const address of TOKEN_ADDRESSES) { + logger.info(`Processing: ${address}`); + + // Skip if token already exists + if (existingAddresses.has(address.toLowerCase())) { + logger.info(` ⊘ Already exists, skipping\n`); + continue; + } + + const metadata = await fetchTokenMetadata(address); + + if (metadata) { + logger.info(` ✓ Success`); + logger.info(` Name: ${metadata.name}`); + logger.info(` Symbol: ${metadata.symbol}`); + logger.info(` Decimals: ${metadata.decimals}\n`); + newTokens.push(metadata); + } else { + logger.error(` ✗ Failed\n`); + } + } + + if (newTokens.length === 0) { + logger.info('No new tokens to add.'); + return; + } + + // Merge and sort by symbol + const allTokens = [...existingTokens, ...newTokens].sort((a, b) => + a.symbol.localeCompare(b.symbol) + ); + + // Write updated token list + fs.writeFileSync(TOKEN_FILE, JSON.stringify(allTokens, null, 2)); + + logger.info('========================================'); + logger.info(`Added ${newTokens.length} new tokens to bsc.json`); + logger.info(`Total tokens: ${allTokens.length}`); + logger.info('========================================'); +} + +addTokens().catch((error) => { + logger.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/add-pancakeswap-pools.ts b/scripts/add-pancakeswap-pools.ts new file mode 100644 index 0000000000..094946c3af --- /dev/null +++ b/scripts/add-pancakeswap-pools.ts @@ -0,0 +1,166 @@ +import { Contract } from '@ethersproject/contracts'; +import { logger } from '../src/services/logger'; +import { getV2PoolInfo, getV3PoolInfo } from '../src/connectors/pancakeswap/pancakeswap.utils'; +import { ConfigManagerV2 } from '../src/services/config-manager-v2'; +import { Ethereum } from '../src/chains/ethereum/ethereum'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface PoolConfig { + address: string; + baseSymbol: string; + quoteSymbol: string; + type?: 'amm' | 'clmm'; +} + +const POOLS_TO_ADD: PoolConfig[] = [ + { + address: '0xaead6bd31dd66eb3a6216aaf271d0e661585b0b1', + baseSymbol: 'ASTER', + quoteSymbol: 'USDT', + }, + { + address: '0x172fcd41e0913e95784454622d1c3724f546f849', + baseSymbol: 'USDT', + quoteSymbol: 'WBNB', + }, + { + address: '0x7f51c8aaa6b0599abd16674e2b17fec7a9f674a1', + baseSymbol: 'CAKE', + quoteSymbol: 'USDT', + }, + { + address: '0x58f876857a02d6762e0101bb5c46a8c1ed44dc16', + baseSymbol: 'WBNB', + quoteSymbol: 'BUSD', + }, +]; + +const NETWORK = 'bsc'; +const TEMPLATE_PATH = path.join(__dirname, '../src/templates/pools/pancakeswap.json'); + +async function fetchPancakeswapPoolInfo( + poolAddress: string, + type: 'amm' | 'clmm' +): Promise<{ + baseTokenAddress: string; + quoteTokenAddress: string; + feePct: number; +} | null> { + try { + if (type === 'amm') { + const poolInfo = await getV2PoolInfo(poolAddress, NETWORK); + if (!poolInfo) { + return null; + } + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: 0.25, // Pancakeswap V2 default fee + }; + } else { + const poolInfo = await getV3PoolInfo(poolAddress, NETWORK); + if (!poolInfo) { + return null; + } + + // Get fee from V3 pool contract + const ethereum = await Ethereum.getInstance(NETWORK); + const v3PoolABI = [ + { + inputs: [], + name: 'fee', + outputs: [{ internalType: 'uint24', name: '', type: 'uint24' }], + stateMutability: 'view', + type: 'function', + }, + ]; + const poolContract = new Contract(poolAddress, v3PoolABI, ethereum.provider); + const feeRaw = await poolContract.fee(); + const feePct = feeRaw / 10000; // Convert from basis points to percentage + + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct, + }; + } + } catch (error: any) { + logger.error(`Error fetching Pancakeswap pool info for ${poolAddress}: ${error.message}`); + return null; + } +} + +async function addPools() { + logger.info('Starting Pancakeswap pool addition...\n'); + + // Initialize config manager + ConfigManagerV2.getInstance(); + + const results: any[] = []; + let successCount = 0; + let failedCount = 0; + + for (let i = 0; i < POOLS_TO_ADD.length; i++) { + const pool = POOLS_TO_ADD[i]; + logger.info(`[${i + 1}/${POOLS_TO_ADD.length}] Processing: ${pool.baseSymbol}-${pool.quoteSymbol}`); + logger.info(` Address: ${pool.address}`); + logger.info(` Network: ${NETWORK}`); + + // Try CLMM first, then AMM + let poolInfo = null; + let poolType: 'amm' | 'clmm' = 'clmm'; + + logger.info(' Trying CLMM (V3)...'); + poolInfo = await fetchPancakeswapPoolInfo(pool.address, 'clmm'); + + if (!poolInfo) { + logger.info(' CLMM failed, trying AMM (V2)...'); + poolType = 'amm'; + poolInfo = await fetchPancakeswapPoolInfo(pool.address, 'amm'); + } + + if (poolInfo) { + logger.info(` ✓ Success (${poolType.toUpperCase()})`); + logger.info(` Base Token: ${poolInfo.baseTokenAddress}`); + logger.info(` Quote Token: ${poolInfo.quoteTokenAddress}`); + logger.info(` Fee: ${poolInfo.feePct}%\n`); + + results.push({ + type: poolType, + network: NETWORK, + baseSymbol: pool.baseSymbol, + quoteSymbol: pool.quoteSymbol, + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + address: pool.address, + }); + + successCount++; + } else { + logger.error(` ✗ Failed to fetch pool info\n`); + failedCount++; + } + } + + // Write results to template file + logger.info('========================================'); + logger.info('Writing updated pancakeswap.json'); + logger.info('========================================'); + + fs.writeFileSync(TEMPLATE_PATH, JSON.stringify(results, null, 2)); + + logger.info(`Completed: ${successCount} success, ${failedCount} failed`); + logger.info(`Total pools in file: ${results.length}\n`); + + logger.info('========================================'); + logger.info('Pool Addition Complete!'); + logger.info('========================================'); + logger.info('Please review the updated pancakeswap.json file.'); +} + +addPools().catch((error) => { + logger.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/migrate-pool-templates.ts b/scripts/migrate-pool-templates.ts new file mode 100644 index 0000000000..91f1d0bb9b --- /dev/null +++ b/scripts/migrate-pool-templates.ts @@ -0,0 +1,243 @@ +import fs from 'fs/promises'; +import path from 'path'; + +import { Ethereum } from '../src/chains/ethereum/ethereum'; +import { Solana } from '../src/chains/solana/solana'; +import { Meteora } from '../src/connectors/meteora/meteora'; +import { Raydium } from '../src/connectors/raydium/raydium'; +import { Uniswap } from '../src/connectors/uniswap/uniswap'; +import { logger } from '../src/services/logger'; + +interface OldPool { + type: 'amm' | 'clmm'; + network: string; + baseSymbol: string; + quoteSymbol: string; + address: string; +} + +interface NewPool { + type: 'amm' | 'clmm'; + network: string; + baseSymbol: string; + quoteSymbol: string; + baseTokenAddress: string; + quoteTokenAddress: string; + feePct: number; + address: string; +} + +interface PoolInfo { + baseTokenAddress: string; + quoteTokenAddress: string; + feePct: number; +} + +async function fetchRaydiumPoolInfo(type: 'amm' | 'clmm', network: string, poolAddress: string): Promise { + try { + const raydium = await Raydium.getInstance(network); + + if (type === 'clmm') { + const poolInfo = await raydium.getClmmPoolInfo(poolAddress); + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + }; + } else { + const poolInfo = await raydium.getAmmPoolInfo(poolAddress); + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + }; + } + } catch (error) { + logger.error(`Error fetching Raydium pool info for ${poolAddress}: ${error.message}`); + return null; + } +} + +async function fetchMeteoraPoolInfo(network: string, poolAddress: string): Promise { + try { + const meteora = await Meteora.getInstance(network); + const poolInfo = await meteora.getPoolInfo(poolAddress); + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + }; + } catch (error) { + logger.error(`Error fetching Meteora pool info for ${poolAddress}: ${error.message}`); + return null; + } +} + +async function fetchUniswapPoolInfo(type: 'amm' | 'clmm', network: string, poolAddress: string): Promise { + try { + const uniswap = await Uniswap.getInstance(network); + const ethereum = await Ethereum.getInstance(network); + + // Import pool info utilities + const uniswapUtils = await import('../src/connectors/uniswap/uniswap.utils'); + + if (type === 'clmm') { + // For CLMM (V3) + const poolInfo = await uniswapUtils.getV3PoolInfo(poolAddress, network); + if (!poolInfo) { + return null; + } + + // Get fee from pool contract + const Contract = (await import('@ethersproject/contracts')).Contract; + const v3PoolABI = [ + { + inputs: [], + name: 'fee', + outputs: [{ internalType: 'uint24', name: '', type: 'uint24' }], + stateMutability: 'view', + type: 'function', + }, + ]; + + const poolContract = new Contract(poolAddress, v3PoolABI, ethereum.provider); + const fee = await poolContract.fee(); + const feePct = fee / 10000; // Convert from basis points to percentage + + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: feePct, + }; + } else { + // For AMM (V2) + const poolInfo = await uniswapUtils.getV2PoolInfo(poolAddress, network); + if (!poolInfo) { + return null; + } + + // Uniswap V2 has fixed 0.3% fee + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: 0.3, + }; + } + } catch (error) { + logger.error(`Error fetching Uniswap pool info for ${poolAddress}: ${error.message}`); + return null; + } +} + +async function fetchPoolInfo( + connector: string, + type: 'amm' | 'clmm', + network: string, + poolAddress: string, +): Promise { + if (connector === 'raydium') { + return fetchRaydiumPoolInfo(type, network, poolAddress); + } else if (connector === 'meteora') { + return fetchMeteoraPoolInfo(network, poolAddress); + } else if (connector === 'uniswap') { + return fetchUniswapPoolInfo(type, network, poolAddress); + } + return null; +} + +async function migrateTemplateFile(connector: string, templatePath: string): Promise { + logger.info(`\n========================================`); + logger.info(`Migrating template file: ${templatePath}`); + logger.info(`========================================\n`); + + // Read existing template + const fileContent = await fs.readFile(templatePath, 'utf-8'); + const oldPools: OldPool[] = JSON.parse(fileContent); + + logger.info(`Found ${oldPools.length} pools to migrate\n`); + + const newPools: NewPool[] = []; + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < oldPools.length; i++) { + const oldPool = oldPools[i]; + logger.info(`[${i + 1}/${oldPools.length}] Processing: ${oldPool.baseSymbol}-${oldPool.quoteSymbol}`); + logger.info(` Address: ${oldPool.address}`); + logger.info(` Type: ${oldPool.type}`); + logger.info(` Network: ${oldPool.network}`); + + const poolInfo = await fetchPoolInfo(connector, oldPool.type, oldPool.network, oldPool.address); + + if (poolInfo) { + newPools.push({ + ...oldPool, + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + }); + logger.info(` ✓ Success`); + logger.info(` Base Token: ${poolInfo.baseTokenAddress}`); + logger.info(` Quote Token: ${poolInfo.quoteTokenAddress}`); + logger.info(` Fee: ${poolInfo.feePct}%\n`); + successCount++; + } else { + logger.error(` ✗ Failed to fetch pool info\n`); + failCount++; + // Skip failed pools - don't add to newPools + } + } + + // Write updated template + await fs.writeFile(templatePath, JSON.stringify(newPools, null, 2) + '\n', 'utf-8'); + + logger.info(`========================================`); + logger.info(`Completed ${templatePath}`); + logger.info(` Success: ${successCount}`); + logger.info(` Failed: ${failCount}`); + logger.info(` Total pools in new file: ${newPools.length}`); + logger.info(`========================================\n`); +} + +async function main() { + logger.info('Starting pool template migration...\n'); + + const templatesDir = path.join(__dirname, '..', 'src', 'templates', 'pools'); + + const connectors = [ + { name: 'raydium', file: 'raydium.json' }, + { name: 'meteora', file: 'meteora.json' }, + { name: 'uniswap', file: 'uniswap.json' }, + ]; + + let totalSuccess = 0; + let totalFail = 0; + + for (const connector of connectors) { + const templatePath = path.join(templatesDir, connector.file); + + // Check if file exists + try { + await fs.access(templatePath); + await migrateTemplateFile(connector.name, templatePath); + } catch (error) { + if (error.code === 'ENOENT') { + logger.warn(`Template file not found: ${templatePath}\n`); + } else { + logger.error(`Error processing ${templatePath}: ${error.message}\n`); + totalFail++; + } + } + } + + logger.info('\n========================================'); + logger.info('Migration Complete!'); + logger.info('========================================'); + logger.info('Please review the migrated template files before committing.'); +} + +main().catch((error) => { + logger.error(`\nMigration failed with error: ${error.message}`); + logger.error(error.stack); + process.exit(1); +}); diff --git a/src/chains/ethereum/GAS_CONFIGURATION_GUIDE.md b/src/chains/ethereum/GAS_CONFIGURATION_GUIDE.md new file mode 100644 index 0000000000..8219e54e1c --- /dev/null +++ b/src/chains/ethereum/GAS_CONFIGURATION_GUIDE.md @@ -0,0 +1,463 @@ +# Ethereum Gas Configuration Guide + +This guide explains how to configure gas settings for Ethereum and EVM-compatible networks in Gateway. + +## Overview + +Gateway supports two types of gas pricing models: + +1. **EIP-1559** (Type 2 transactions) - Used by modern networks like Ethereum Mainnet, Base, Polygon, Arbitrum, Optimism +2. **Legacy** (Type 0 transactions) - Used by older networks like BSC, Avalanche, Celo + +## Configuration Files + +### Network Configuration +Gas settings are configured per network in `conf/chains/ethereum/{network}.yml`: + +```yaml +chainID: 8453 +nodeURL: https://mainnet.base.org +nativeCurrencySymbol: ETH +minGasPrice: 0.01 + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +# maxFeePerGas: 0.01 +# maxPriorityFeePerGas: 0.01 +``` + +### Chain Configuration +Etherscan API key is configured once for all networks in `conf/chains/ethereum/ethereum.yml`: + +```yaml +defaultNetwork: mainnet +defaultWallet: default_wallet +rpcProvider: standard + +# Etherscan API key for gas price estimates across all supported networks +# Get your free API key from https://etherscan.io/myapikey +# Works for Ethereum, Polygon, BSC (not available for Base, Arbitrum, Optimism) +etherscanAPIKey: 'YOUR_ETHERSCAN_API_KEY_HERE' +``` + +## Gas Parameters Explained + +### Common Parameters (All Networks) + +#### `minGasPrice` (GWEI) +- **Purpose**: Sets a minimum floor for gas prices +- **Applies to**: Both EIP-1559 and legacy networks +- **Default**: Value stored in network-specific template (e.g., `src/templates/chains/ethereum/mainnet.yml`) +- **Behavior**: + - For EIP-1559: Ensures `maxFeePerGas` doesn't go below this value + - For legacy: Ensures `gasPrice` doesn't go below this value +- **Use case**: Prevent transactions from using extremely low gas prices that may not confirm + +### Chain Parameters (ethereum.yml) + +#### `etherscanAPIKey` (String) +- **Purpose**: API key for Etherscan V2 API gas price estimates (gastracker module) +- **Location**: Set once in `conf/chains/ethereum/ethereum.yml` (not in individual network configs) +- **Optional**: If not set, uses standard RPC provider for gas estimates +- **Applies to**: Networks with Etherscan gastracker support: + - ✅ **Ethereum Mainnet** (chainID: 1) + - ✅ **Polygon** (chainID: 137) + - ✅ **BSC** (chainID: 56) + - ❌ Base (chainID: 8453) - gastracker not available, automatically falls back to RPC + - ❌ Arbitrum (chainID: 42161) - gastracker not available, automatically falls back to RPC + - ❌ Optimism (chainID: 10) - gastracker not available, automatically falls back to RPC +- **Benefit**: More accurate priority fee estimates compared to RPC providers +- **Where to get key**: [Etherscan API Keys](https://etherscan.io/myapikey) (free tier: 5 calls/second) +- **Note**: Single API key works across all supported chains via Etherscan V2 API + +### EIP-1559 Parameters (Type 2 Transactions) + +#### `maxFeePerGas` (GWEI) +- **Purpose**: The maximum total fee you're willing to pay per unit of gas +- **Optional**: If not set, fetches from network +- **Formula**: `maxFeePerGas = baseFee + maxPriorityFeePerGas` +- **Actual cost**: You typically pay less than this amount +- **Use case**: Set a ceiling to control maximum transaction costs + +#### `maxPriorityFeePerGas` (GWEI) +- **Purpose**: The tip paid to validators/miners for prioritizing your transaction +- **Optional**: If not set, fetches from network +- **Also called**: "Priority fee" or "tip" +- **Behavior**: Higher values = faster inclusion in blocks +- **Use case**: Control transaction speed and validator incentives + +### Legacy Parameters (Type 0 Transactions) + +#### `gasPrice` (GWEI) +- **Purpose**: Fixed price per unit of gas +- **Behavior**: Simple single-price model (no base fee + priority fee split) +- **Use case**: For networks that don't support EIP-1559 + +## How EIP-1559 Works + +When you submit an EIP-1559 transaction: + +1. **You specify**: + - `maxFeePerGas`: Maximum willing to pay (e.g., 0.1 GWEI) + - `maxPriorityFeePerGas`: Tip for validator (e.g., 0.01 GWEI) + +2. **Network determines**: + - `baseFee`: Current network congestion price (e.g., 0.004 GWEI) + +3. **You actually pay**: + ``` + effectiveGasPrice = baseFee + min(maxPriorityFeePerGas, maxFeePerGas - baseFee) + ``` + +4. **Example**: + - `maxFeePerGas = 0.1 GWEI` + - `maxPriorityFeePerGas = 0.01 GWEI` + - `baseFee = 0.004 GWEI` (from network) + - **Actual price paid** = `0.004 + 0.01 = 0.014 GWEI` ✓ + +The `baseFee` is burned (removed from circulation), and the `maxPriorityFeePerGas` goes to the validator. + +## Network Types + +### EIP-1559 Networks +These networks support Type 2 (EIP-1559) transactions: +- **Ethereum Mainnet** (chainID: 1) +- **Base** (chainID: 8453) +- **Polygon** (chainID: 137) +- **Arbitrum** (chainID: 42161) +- **Optimism** (chainID: 10) + +### Legacy Networks +These networks use Type 0 (legacy) transactions: +- **BSC** (Binance Smart Chain, chainID: 56) +- **Avalanche** (chainID: 43114) +- **Celo** (chainID: 42220) +- **Sepolia Testnet** (chainID: 11155111) + +## Configuration Strategies + +### Strategy 1: Use Network Values (Default) +**Config** (example for Base): +```yaml +minGasPrice: 0.01 +# maxFeePerGas: 0.01 # Commented out +# maxPriorityFeePerGas: 0.01 # Commented out +``` + +**Behavior**: +- Fetches current gas prices from the network +- Adapts to real-time network conditions +- May pay higher fees during congestion +- Respects `minGasPrice` as a floor + +**Best for**: Production environments where reliability is critical + +### Strategy 2: Fixed Low Fees +**Config** (example for Base): +```yaml +minGasPrice: 0.01 +maxFeePerGas: 0.01 +maxPriorityFeePerGas: 0.01 +``` + +**Behavior**: +- Always uses your configured values +- Predictable, low-cost transactions +- May be slower during high congestion +- Risk: Transactions may not confirm if fees too low + +**Best for**: Development, testing, or low-priority operations + +### Strategy 3: Use Etherscan API (Recommended for Supported Networks) +**Config in ethereum.yml**: +```yaml +etherscanAPIKey: 'YOUR_ETHERSCAN_API_KEY' +``` + +**Config in network.yml** (example for Ethereum Mainnet): +```yaml +minGasPrice: 1.0 +# maxFeePerGas: 10 # Commented out - fetch from Etherscan +# maxPriorityFeePerGas: 2 # Commented out - fetch from Etherscan +``` + +**Behavior**: +- Fetches accurate gas prices from Etherscan V2 API (gastracker module) +- More reliable priority fee estimates than RPC providers +- Works for Ethereum Mainnet, Polygon, and BSC +- Automatically falls back to RPC for unsupported chains or on API failure +- Respects `minGasPrice` as a floor + +**Best for**: Production environments on supported networks (Ethereum, Polygon, BSC) + +**Why use this**: +- RPC providers sometimes return inaccurate priority fees +- Single API key works across all supported chains +- Etherscan gastracker provides real-time data from block explorers +- Free tier sufficient for most use cases (5 calls/second) + +### Strategy 4: Hybrid Approach +**Config**: +```yaml +minGasPrice: 0.5 +# maxFeePerGas: 1.0 # Commented out +# maxPriorityFeePerGas: 0.1 # Commented out +``` + +**Behavior**: +- Fetches from network but enforces higher minimum +- Balances cost control with reliability +- Ensures minimum confirmation speed + +**Best for**: Production with cost constraints + +## Example Configurations + +### Ethereum Mainnet (With Etherscan API - Recommended) +**ethereum.yml**: +```yaml +etherscanAPIKey: 'YOUR_ETHERSCAN_API_KEY' +``` + +**mainnet.yml**: +```yaml +chainID: 1 +nodeURL: https://eth.llamarpc.com +nativeCurrencySymbol: ETH +minGasPrice: 1.0 +# maxFeePerGas: 10 # Commented out - fetch from Etherscan +# maxPriorityFeePerGas: 2 # Commented out - fetch from Etherscan +``` +- ✅ Mainnet fees vary significantly +- ✅ Etherscan gastracker provides accurate real-time gas oracle data +- ✅ Set higher `minGasPrice` to ensure confirmation +- ✅ Single API key works across all supported chains + +### Polygon (With Etherscan API - Recommended) +**ethereum.yml**: +```yaml +etherscanAPIKey: 'YOUR_ETHERSCAN_API_KEY' +``` + +**polygon.yml**: +```yaml +chainID: 137 +nodeURL: https://rpc.ankr.com/polygon +nativeCurrencySymbol: POL +minGasPrice: 10 +# maxFeePerGas: 10 # Commented out - fetch from Etherscan +# maxPriorityFeePerGas: 10 # Commented out - fetch from Etherscan +``` +- ✅ Polygon has higher gas prices than Ethereum in GWEI terms +- ✅ Etherscan gastracker provides accurate priority fee estimates +- ✅ Same API key used for Ethereum Mainnet works here + +### Base Network (No Etherscan API Support) +**base.yml**: +```yaml +chainID: 8453 +nodeURL: https://mainnet.base.org +nativeCurrencySymbol: ETH +minGasPrice: 0.01 +# maxFeePerGas: 0.01 # Commented out - fetch from RPC +# maxPriorityFeePerGas: 0.01 # Commented out - fetch from RPC +``` +- ❌ Base does not support Etherscan gastracker module +- ✅ Automatically falls back to RPC provider for gas estimates +- ⚠️ Base RPC may report inaccurate priority fees (e.g., 1.5 GWEI when actual is <0.001) +- ✅ Base typically has very low fees (0.01 GWEI minimum) + +### BSC (Legacy Network) +```yaml +chainID: 56 +nodeURL: https://bsc-dataseed.binance.org +nativeCurrencySymbol: BNB +minGasPrice: 3.0 +``` +- Uses legacy gas pricing (single `gasPrice`) +- No EIP-1559 parameters needed +- BSC recommends minimum 3 GWEI + +## Monitoring Gas Usage + +Gateway logs provide visibility into gas pricing: + +### EIP-1559 Transaction Logs (With Etherscan API - Supported Networks) +``` +2025-10-15 11:16:22 | info | ✅ Etherscan V2 API configured for mainnet (chainId: 1, key length: 34 chars) +2025-10-15 11:16:22 | info | Etherscan mainnet: baseFee=1.2500 GWEI, priority (safe/propose/fast)=0.5/1.0/2.0 GWEI +2025-10-15 11:16:22 | info | Etherscan API EIP-1559 fees: baseFee≈1.2500 GWEI, maxFee=3.5000 GWEI, priority=1.0000 GWEI +2025-10-15 11:16:22 | info | Using network EIP-1559 fees: maxFee=3.5000 GWEI, priority=1.0000 GWEI +2025-10-15 11:16:22 | info | Estimated: 3.5 GWEI for network mainnet +``` + +### EIP-1559 Transaction Logs (RPC Fallback - Unsupported Networks) +``` +2025-10-15 10:34:01 | info | Network RPC EIP-1559 fees: baseFee=0.0004 GWEI, maxFee=0.0104 GWEI, priority=0.0100 GWEI +2025-10-15 10:34:01 | info | Using network EIP-1559 fees: maxFee=0.0104 GWEI, priority=0.0100 GWEI +2025-10-15 10:34:01 | info | Estimated: 0.01 GWEI for network base +``` +Note: Base does not support Etherscan gastracker and automatically uses RPC. The minGasPrice floor (0.01 GWEI) is applied to ensure minimum confirmation. + +### Legacy Transaction Logs +``` +2025-10-15 10:45:49 | info | Network legacy gas price: 1.5000 GWEI +2025-10-15 10:45:49 | info | Using configured minimum gas price: 3.0 GWEI (network: 1.5000 GWEI) +2025-10-15 10:45:49 | info | Estimated: 3.0 GWEI for network bsc +``` +Note: BSC uses legacy gas pricing. The minGasPrice (3.0 GWEI) ensures transactions meet BSC's recommended minimum. + +The logs show: +1. **Source**: Whether using Etherscan API or RPC provider +2. **Network values**: What the network/API currently reports +3. **Configured values**: What you've set in config (if any) +4. **Used values**: What will actually be used for transactions + +## Estimating Gas Costs + +To see current gas estimates without sending a transaction: + +```bash +curl -X 'GET' \ + 'http://localhost:15888/chains/ethereum/estimate-gas?network=base' \ + -H 'accept: application/json' +``` + +**EIP-1559 Response** (Base network): +```json +{ + "feePerComputeUnit": 0.01, + "denomination": "gwei", + "computeUnits": 300000, + "feeAsset": "ETH", + "fee": 0.000003, + "timestamp": 1760553074872, + "gasType": "eip1559", + "maxFeePerGas": 0.01, + "maxPriorityFeePerGas": 0.01 +} +``` + +**Legacy Response** (BSC network): +```json +{ + "feePerComputeUnit": 3.0, + "denomination": "gwei", + "computeUnits": 300000, + "feeAsset": "BNB", + "fee": 0.0009, + "timestamp": 1760553074872, + "gasType": "legacy" +} +``` + +## Troubleshooting + +### Transactions Not Confirming +**Problem**: Transactions stuck in pending state + +**Solutions**: +1. Increase `maxFeePerGas` (EIP-1559) or `minGasPrice` (legacy) +2. Increase `maxPriorityFeePerGas` for faster inclusion +3. Comment out fixed values to use network prices +4. **Recommended**: Add `etherscanAPIKey` to ethereum.yml for more accurate gas estimates (if using Ethereum, Polygon, or BSC) + +### Paying Too Much for Gas +**Problem**: Transaction fees higher than expected + +**Solutions**: +1. Set explicit `maxFeePerGas` and `maxPriorityFeePerGas` values +2. Use lower values during off-peak hours +3. Check if `minGasPrice` is set too high +4. **Recommended**: Use `etherscanAPIKey` - RPC providers may overestimate fees + +### Inaccurate Priority Fees from RPC +**Problem**: RPC returns inflated priority fees (e.g., 1.5 GWEI when actual is 0.001 GWEI) + +**Solutions**: +1. Add `etherscanAPIKey` to `conf/chains/ethereum/ethereum.yml` +2. Check logs for "✅ Etherscan V2 API configured" message +3. Verify API key is valid (get free key from https://etherscan.io/myapikey) +4. Check logs show "Etherscan API EIP-1559 fees" instead of "Network RPC EIP-1559 fees" +5. **Note**: Only Ethereum, Polygon, and BSC support Etherscan gastracker; other chains automatically fall back to RPC + +### Configuration Not Taking Effect +**Problem**: Changes to config file not reflected in transactions + +**Solutions**: +1. Restart Gateway after changing config files +2. Check that values are **not commented out** (no `#` at start of line) +3. Verify config file locations: + - Network settings: `conf/chains/ethereum/{network}.yml` + - Etherscan API key: `conf/chains/ethereum/ethereum.yml` +4. For `etherscanAPIKey`: Check logs for "✅ Etherscan V2 API configured" message + +### Etherscan API Not Working +**Problem**: Logs show "Failed to fetch from Etherscan API" + +**Solutions**: +1. Verify API key is correct and not expired +2. Check rate limits (free tier: 5 calls/second) +3. Ensure network is supported (only Ethereum, Polygon, BSC support gastracker) +4. For unsupported networks (Base, Arbitrum, Optimism), Gateway automatically falls back to RPC + +## Best Practices + +1. **Always test with small amounts first** when changing gas configurations +2. **Use `etherscanAPIKey` for production** - More accurate than RPC for supported networks (Ethereum, Polygon, BSC) +3. **Get free API key** from Etherscan (works across all supported chains with single key) +4. **Monitor network conditions** before setting fixed gas prices +5. **Use block explorers** to verify actual gas prices paid +6. **Set appropriate minimums** to balance cost and reliability +7. **Document your strategy** in config file comments +8. **Review periodically** as network conditions change +9. **Check logs** to confirm which gas source is being used (API vs RPC) +10. **Understand chain support** - Only Ethereum, Polygon, and BSC support Etherscan gastracker; other chains use RPC + +## API Key Setup Guide + +### Step 1: Get Etherscan API Key +Get a free API key from Etherscan (works for all supported chains): + +- **Get key**: [Etherscan API Keys](https://etherscan.io/myapikey) +- **Free tier**: 5 calls/second +- **Works for**: Ethereum Mainnet, Polygon, BSC (via Etherscan V2 API) +- **Not needed for**: Base, Arbitrum, Optimism (these chains don't support Etherscan gastracker and automatically fall back to RPC) + +### Step 2: Add to Chain Config +Edit `conf/chains/ethereum/ethereum.yml` (NOT individual network configs): + +```yaml +# Add or uncomment this line +etherscanAPIKey: 'YOUR_ETHERSCAN_API_KEY_HERE' +``` + +**Note**: Single API key works across all supported chains (Ethereum, Polygon, BSC) via Etherscan V2 API. + +### Step 3: Restart Gateway +```bash +pnpm start --passphrase= +``` + +### Step 4: Verify in Logs +When using a supported network (Ethereum, Polygon, BSC), look for: +``` +✅ Etherscan V2 API configured for {network} (chainId: X, key length: XX chars) +``` + +When using an unsupported network (Base, Arbitrum, Optimism), you'll see: +``` +Etherscan API not supported for chainId: XXXX +``` +(This is normal - these chains automatically fall back to RPC) + +## References + +- [EIP-1559 Specification](https://eips.ethereum.org/EIPS/eip-1559) +- [Etherscan Gas Tracker](https://etherscan.io/gastracker) - Ethereum Mainnet +- [Polygon Gas Tracker](https://polygonscan.com/gastracker) +- [BSC Gas Tracker](https://bscscan.com/gastracker) +- [Etherscan V2 API Migration Guide](https://docs.etherscan.io/v2-migration) +- [Etherscan Gas Tracker API Documentation](https://docs.etherscan.io/api-endpoints/gas-tracker) +- [Etherscan Supported Chains](https://docs.etherscan.io/supported-chains) +- Gateway API Docs: `http://localhost:15888/docs` diff --git a/src/chains/ethereum/ethereum.config.ts b/src/chains/ethereum/ethereum.config.ts index 107603463b..437f09ec06 100644 --- a/src/chains/ethereum/ethereum.config.ts +++ b/src/chains/ethereum/ethereum.config.ts @@ -7,6 +7,8 @@ export interface EthereumNetworkConfig { nodeURL: string; nativeCurrencySymbol: string; minGasPrice?: number; + maxFeePerGas?: number; + maxPriorityFeePerGas?: number; infuraAPIKey?: string; useInfuraWebSocket?: boolean; } @@ -15,6 +17,7 @@ export interface EthereumChainConfig { defaultNetwork: string; defaultWallet: string; rpcProvider: string; + etherscanAPIKey?: string; } // Export available networks @@ -27,6 +30,8 @@ export function getEthereumNetworkConfig(network: string): EthereumNetworkConfig nodeURL: ConfigManagerV2.getInstance().get(namespaceId + '.nodeURL'), nativeCurrencySymbol: ConfigManagerV2.getInstance().get(namespaceId + '.nativeCurrencySymbol'), minGasPrice: ConfigManagerV2.getInstance().get(namespaceId + '.minGasPrice'), + maxFeePerGas: ConfigManagerV2.getInstance().get(namespaceId + '.maxFeePerGas'), + maxPriorityFeePerGas: ConfigManagerV2.getInstance().get(namespaceId + '.maxPriorityFeePerGas'), }; } @@ -35,5 +40,6 @@ export function getEthereumChainConfig(): EthereumChainConfig { defaultNetwork: ConfigManagerV2.getInstance().get('ethereum.defaultNetwork'), defaultWallet: ConfigManagerV2.getInstance().get('ethereum.defaultWallet'), rpcProvider: ConfigManagerV2.getInstance().get('ethereum.rpcProvider') || 'url', + etherscanAPIKey: ConfigManagerV2.getInstance().get('ethereum.etherscanAPIKey'), }; } diff --git a/src/chains/ethereum/ethereum.ts b/src/chains/ethereum/ethereum.ts index 43519844ac..77ff13cc99 100644 --- a/src/chains/ethereum/ethereum.ts +++ b/src/chains/ethereum/ethereum.ts @@ -11,6 +11,7 @@ import { TokenService } from '../../services/token-service'; import { walletPath, isHardwareWallet as checkIsHardwareWallet } from '../../wallet/utils'; import { getEthereumNetworkConfig, getEthereumChainConfig } from './ethereum.config'; +import { EtherscanService } from './etherscan-service'; import { InfuraService } from './infura-service'; // information about an Ethereum token @@ -35,12 +36,18 @@ export class Ethereum { public chainId: number; public rpcUrl: string; public minGasPrice: number; + public maxFeePerGas?: number; + public maxPriorityFeePerGas?: number; private _initialized: boolean = false; private infuraService?: InfuraService; + private etherscanService?: EtherscanService; private static lastGasPriceEstimate: { timestamp: number; gasPrice: number; + maxFeePerGas?: number; + maxPriorityFeePerGas?: number; + isEIP1559?: boolean; } | null = null; private static GAS_PRICE_CACHE_MS = 10000; // 10 second cache @@ -56,9 +63,25 @@ export class Ethereum { this.network = network; this.nativeTokenSymbol = config.nativeCurrencySymbol; this.minGasPrice = config.minGasPrice || 0.1; // Default to 0.1 GWEI if not specified + this.maxFeePerGas = config.maxFeePerGas; + this.maxPriorityFeePerGas = config.maxPriorityFeePerGas; - // Get rpcProvider from chain config + // Get chain config for etherscanAPIKey const chainConfig = getEthereumChainConfig(); + + // Initialize Etherscan service if API key is provided and chain is supported + if (chainConfig.etherscanAPIKey && EtherscanService.isSupported(this.chainId)) { + try { + this.etherscanService = new EtherscanService(this.chainId, network, chainConfig.etherscanAPIKey); + logger.info( + `✅ Etherscan V2 API configured for ${network} (chainId: ${this.chainId}, key length: ${chainConfig.etherscanAPIKey.length} chars)`, + ); + } catch (error: any) { + logger.warn(`Failed to initialize Etherscan service: ${error.message}`); + } + } + + // Get rpcProvider from chain config const rpcProvider = chainConfig.rpcProvider || 'url'; // Initialize RPC connection based on provider @@ -116,6 +139,110 @@ export class Ethereum { return Ethereum.lastGasPriceEstimate.gasPrice; } + // Check if the network supports EIP-1559 + const supportsEIP1559 = + this.network === 'mainnet' || + this.network === 'polygon' || + this.network === 'arbitrum' || + this.network === 'optimism' || + this.network === 'base'; + + if (supportsEIP1559) { + try { + let maxFeePerGasGwei: number; + let maxPriorityFeePerGasGwei: number; + let networkMaxFeeGwei: number | undefined; + let networkPriorityFeeGwei: number | undefined; + let networkBaseFeeGwei: number | undefined; + + // Try to fetch from Etherscan API first if available + if (this.etherscanService) { + try { + const gasPrices = await this.etherscanService.getRecommendedGasPrices('propose'); + networkMaxFeeGwei = gasPrices.maxFeePerGas; + networkPriorityFeeGwei = gasPrices.maxPriorityFeePerGas; + // Estimate baseFee from the formula: baseFee ≈ (maxFee - priority) / 2 + networkBaseFeeGwei = (networkMaxFeeGwei - networkPriorityFeeGwei) / 2; + logger.info( + `Etherscan API EIP-1559 fees: baseFee≈${networkBaseFeeGwei.toFixed(4)} GWEI, maxFee=${networkMaxFeeGwei.toFixed(4)} GWEI, priority=${networkPriorityFeeGwei.toFixed(4)} GWEI`, + ); + } catch (scanError: any) { + logger.warn(`Failed to fetch from Etherscan API: ${scanError.message}`); + logger.info('Using RPC provider for gas price estimation'); + } + } + + // Fallback to RPC provider if Etherscan not available or failed + if (networkMaxFeeGwei === undefined || networkPriorityFeeGwei === undefined) { + try { + const feeData = await this.provider.getFeeData(); + if (feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) { + const block = await this.provider.getBlock('latest'); + const baseFee = block.baseFeePerGas || BigNumber.from('0'); + + // Calculate recommended maxFeePerGas as 2 * baseFee + maxPriorityFeePerGas + const recommendedMaxFee = baseFee.mul(2).add(feeData.maxPriorityFeePerGas); + const maxFeePerGas = feeData.maxFeePerGas.gt(recommendedMaxFee) + ? feeData.maxFeePerGas + : recommendedMaxFee; + + networkBaseFeeGwei = parseFloat(utils.formatUnits(baseFee, 'gwei')); + networkMaxFeeGwei = parseFloat(utils.formatUnits(maxFeePerGas, 'gwei')); + networkPriorityFeeGwei = parseFloat(utils.formatUnits(feeData.maxPriorityFeePerGas, 'gwei')); + + logger.info( + `Network RPC EIP-1559 fees: baseFee=${networkBaseFeeGwei.toFixed(4)} GWEI, maxFee=${networkMaxFeeGwei.toFixed(4)} GWEI, priority=${networkPriorityFeeGwei.toFixed(4)} GWEI`, + ); + } + } catch (networkError: any) { + logger.warn(`Failed to fetch network EIP-1559 data: ${networkError.message}`); + } + } + + // Use configured values if available, otherwise use network values + if (this.maxFeePerGas !== undefined && this.maxPriorityFeePerGas !== undefined) { + maxFeePerGasGwei = this.maxFeePerGas; + maxPriorityFeePerGasGwei = this.maxPriorityFeePerGas; + logger.info( + `Using configured EIP-1559 fees: maxFee=${maxFeePerGasGwei} GWEI, priority=${maxPriorityFeePerGasGwei} GWEI`, + ); + } else if (networkMaxFeeGwei !== undefined && networkPriorityFeeGwei !== undefined) { + // Use network values + maxFeePerGasGwei = networkMaxFeeGwei; + maxPriorityFeePerGasGwei = networkPriorityFeeGwei; + logger.info( + `Using network EIP-1559 fees: maxFee=${maxFeePerGasGwei.toFixed(4)} GWEI, priority=${maxPriorityFeePerGasGwei.toFixed(4)} GWEI`, + ); + } else { + throw new Error('EIP-1559 fee data not available from network or config'); + } + + // Apply minimum gas price + const minGasPrice = this.minGasPrice; + if (maxFeePerGasGwei < minGasPrice) { + logger.info( + `Using configured minimum gas price. Current maxFee: ${maxFeePerGasGwei} GWEI, Minimum: ${minGasPrice} GWEI`, + ); + maxFeePerGasGwei = minGasPrice; + } + + // Cache the result + Ethereum.lastGasPriceEstimate = { + timestamp: Date.now(), + gasPrice: maxFeePerGasGwei, + maxFeePerGas: maxFeePerGasGwei, + maxPriorityFeePerGas: maxPriorityFeePerGasGwei, + isEIP1559: true, + }; + + logger.info(`Estimated: ${maxFeePerGasGwei} GWEI for network ${this.network}`); + return maxFeePerGasGwei; + } catch (error: any) { + logger.warn(`Failed to get EIP-1559 fee data, falling back to legacy pricing: ${error.message}`); + } + } + + // Legacy gas price estimation try { const baseFee: BigNumber = await this.provider.getGasPrice(); let priorityFee: BigNumber = BigNumber.from('0'); @@ -124,17 +251,24 @@ export class Ethereum { priorityFee = BigNumber.from(await this.provider.send('eth_maxPriorityFeePerGas', [])); } + const baseWithPriority = baseFee.add(priorityFee); + const networkGasPriceGwei = baseWithPriority.toNumber() * 1e-9; + + // Always log the network gas price + logger.info(`Network legacy gas price: ${networkGasPriceGwei.toFixed(4)} GWEI`); + // Apply minimum gas price for all networks const minGasPriceWei = utils.parseUnits(this.minGasPrice.toString(), 'gwei'); - const baseWithPriority = baseFee.add(priorityFee); // Use the larger of the current gas price or the configured minimum const adjustedFee = baseWithPriority.lt(minGasPriceWei) ? minGasPriceWei : baseWithPriority; if (baseWithPriority.lt(minGasPriceWei)) { logger.info( - `Using configured minimum gas price. Current: ${baseWithPriority.toNumber() * 1e-9} GWEI, Minimum: ${this.minGasPrice} GWEI`, + `Using configured minimum gas price: ${this.minGasPrice} GWEI (network: ${networkGasPriceGwei.toFixed(4)} GWEI)`, ); + } else { + logger.info(`Using network gas price: ${networkGasPriceGwei.toFixed(4)} GWEI`); } const totalFeeGwei = adjustedFee.toNumber() * 1e-9; @@ -144,6 +278,7 @@ export class Ethereum { Ethereum.lastGasPriceEstimate = { timestamp: Date.now(), gasPrice: totalFeeGwei, + isEIP1559: false, }; return totalFeeGwei; @@ -155,7 +290,7 @@ export class Ethereum { /** * Prepare gas options for a transaction - * @param gasPrice Gas price in Gwei (optional) + * @param gasPrice Gas price in Gwei (optional, uses cached estimate if not provided) * @param gasLimit Gas limit (optional, defaults to 300000) * @returns Gas options object for ethers.js transaction */ @@ -168,40 +303,67 @@ export class Ethereum { // Check if the network supports EIP-1559 const supportsEIP1559 = - this.chainId === 1 || - this.chainId === 137 || - this.chainId === 42161 || - this.chainId === 10 || - this.chainId === 8453; + this.network === 'mainnet' || + this.network === 'polygon' || + this.network === 'arbitrum' || + this.network === 'optimism' || + this.network === 'base'; if (supportsEIP1559) { - try { - // Use EIP-1559 gas pricing (type 2) - const feeData = await this.provider.getFeeData(); - - if (feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) { - // Get current base fee from latest block - const block = await this.provider.getBlock('latest'); - const baseFee = block.baseFeePerGas || BigNumber.from('0'); + // Use cached EIP-1559 values from estimateGasPrice if available and gasPrice not explicitly provided + if ( + !gasPrice && + Ethereum.lastGasPriceEstimate?.isEIP1559 && + Ethereum.lastGasPriceEstimate?.maxFeePerGas !== undefined && + Ethereum.lastGasPriceEstimate?.maxPriorityFeePerGas !== undefined + ) { + gasOptions.type = 2; + gasOptions.maxFeePerGas = utils.parseUnits(Ethereum.lastGasPriceEstimate.maxFeePerGas.toString(), 'gwei'); + gasOptions.maxPriorityFeePerGas = utils.parseUnits( + Ethereum.lastGasPriceEstimate.maxPriorityFeePerGas.toString(), + 'gwei', + ); - // Calculate recommended maxFeePerGas as 2 * baseFee + maxPriorityFeePerGas - const recommendedMaxFee = baseFee.mul(2).add(feeData.maxPriorityFeePerGas); + logger.info( + `Using cached EIP-1559 pricing: maxFee=${Ethereum.lastGasPriceEstimate.maxFeePerGas} GWEI, priority=${Ethereum.lastGasPriceEstimate.maxPriorityFeePerGas} GWEI`, + ); + return gasOptions; + } - // Use the higher of network estimate or our calculation - const maxFeePerGas = feeData.maxFeePerGas.gt(recommendedMaxFee) ? feeData.maxFeePerGas : recommendedMaxFee; + // If gasPrice is provided, use it as maxFeePerGas with a default priority fee + if (gasPrice) { + const priorityFee = this.maxPriorityFeePerGas || 0.01; // Default 0.01 GWEI priority fee + gasOptions.type = 2; + gasOptions.maxFeePerGas = utils.parseUnits(gasPrice.toString(), 'gwei'); + gasOptions.maxPriorityFeePerGas = utils.parseUnits(priorityFee.toString(), 'gwei'); - gasOptions.type = 2; - gasOptions.maxFeePerGas = maxFeePerGas; - gasOptions.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas; + logger.info( + `Using EIP-1559 pricing with provided gasPrice: maxFee=${gasPrice} GWEI, priority=${priorityFee} GWEI`, + ); + return gasOptions; + } - logger.info( - `Using EIP-1559 pricing: baseFee=${utils.formatUnits(baseFee, 'gwei')} GWEI, maxFee=${utils.formatUnits(maxFeePerGas, 'gwei')} GWEI, priority=${utils.formatUnits(feeData.maxPriorityFeePerGas, 'gwei')} GWEI`, - ); + // If no cached values and no gasPrice provided, call estimateGasPrice to fetch/cache values + logger.warn('No cached EIP-1559 data available, calling estimateGasPrice()'); + await this.estimateGasPrice(); + + // Try again with newly cached values + if ( + Ethereum.lastGasPriceEstimate?.isEIP1559 && + Ethereum.lastGasPriceEstimate?.maxFeePerGas !== undefined && + Ethereum.lastGasPriceEstimate?.maxPriorityFeePerGas !== undefined + ) { + gasOptions.type = 2; + gasOptions.maxFeePerGas = utils.parseUnits(Ethereum.lastGasPriceEstimate.maxFeePerGas.toString(), 'gwei'); + gasOptions.maxPriorityFeePerGas = utils.parseUnits( + Ethereum.lastGasPriceEstimate.maxPriorityFeePerGas.toString(), + 'gwei', + ); - return gasOptions; - } - } catch (error: any) { - logger.warn(`Failed to get EIP-1559 fee data, falling back to legacy pricing: ${error.message}`); + logger.info( + `Using newly fetched EIP-1559 pricing: maxFee=${Ethereum.lastGasPriceEstimate.maxFeePerGas} GWEI, priority=${Ethereum.lastGasPriceEstimate.maxPriorityFeePerGas} GWEI`, + ); + return gasOptions; } } @@ -246,7 +408,8 @@ export class Ethereum { const useWebSocket = configManager.get('infura.useWebSocket') || false; if (!infuraApiKey || infuraApiKey.trim() === '') { - logger.warn(`⚠️ Infura provider selected but no API key configured, falling back to standard RPC`); + logger.warn(`⚠️ Infura provider selected but no API key configured`); + logger.info(`Using standard RPC from nodeURL: ${this.rpcUrl}`); this.provider = new providers.StaticJsonRpcProvider(this.rpcUrl); return; } @@ -264,7 +427,8 @@ export class Ethereum { this.infuraService = new InfuraService(mergedConfig); this.provider = this.infuraService.getProvider() as providers.StaticJsonRpcProvider; } catch (error: any) { - logger.warn(`Failed to initialize Infura provider: ${error.message}, falling back to standard RPC`); + logger.warn(`Failed to initialize Infura provider: ${error.message}`); + logger.info(`Using standard RPC from nodeURL: ${this.rpcUrl}`); this.provider = new providers.StaticJsonRpcProvider(this.rpcUrl); } } diff --git a/src/chains/ethereum/etherscan-service.ts b/src/chains/ethereum/etherscan-service.ts new file mode 100644 index 0000000000..f7fa9df7e6 --- /dev/null +++ b/src/chains/ethereum/etherscan-service.ts @@ -0,0 +1,166 @@ +import axios from 'axios'; + +import { logger } from '../../services/logger'; + +/** + * Etherscan Gas Tracker API response + */ +interface EtherscanGasOracleResponse { + status: string; + message: string; + result: { + LastBlock: string; + SafeGasPrice: string; // Priority fee for safe/slow speed + ProposeGasPrice: string; // Priority fee for average speed + FastGasPrice: string; // Priority fee for fast speed + suggestBaseFee: string; // Suggested base fee for next block + gasUsedRatio: string; // Network congestion indicator + }; +} + +/** + * Gas price data from Etherscan + */ +export interface EtherscanGasData { + baseFee: number; // in GWEI + priorityFeeSafe: number; // in GWEI + priorityFeePropose: number; // in GWEI + priorityFeeFast: number; // in GWEI +} + +/** + * Service for fetching gas prices from Etherscan V2 API + * Uses unified endpoint with chainid parameter + * Supports all chains listed at: https://docs.etherscan.io/supported-chains + */ +export class EtherscanService { + private apiKey: string; + private chainId: number; + private network: string; + private static readonly BASE_URL = 'https://api.etherscan.io/v2/api'; + + // List of chain IDs that support the gastracker module + // Note: Not all Etherscan V2 supported chains have gastracker available + // These chains were tested and confirmed to support the gastracker module + private static readonly SUPPORTED_CHAIN_IDS = new Set([ + 1, // Ethereum Mainnet - CONFIRMED + 11155111, // Sepolia Testnet + 137, // Polygon Mainnet - CONFIRMED + 80002, // Polygon Amoy Testnet + 56, // BNB Smart Chain Mainnet - CONFIRMED + 97, // BNB Smart Chain Testnet + 43114, // Avalanche C-Chain + 43113, // Avalanche Fuji + // Note: Base, Arbitrum, Optimism do NOT support gastracker module + ]); + + constructor(chainId: number, network: string, apiKey: string) { + this.network = network; + this.apiKey = apiKey; + this.chainId = chainId; + + if (!EtherscanService.SUPPORTED_CHAIN_IDS.has(chainId)) { + throw new Error(`Etherscan API not supported for chainId: ${chainId}`); + } + } + + /** + * Check if Etherscan API is supported for the given chain ID + */ + public static isSupported(chainId: number): boolean { + return EtherscanService.SUPPORTED_CHAIN_IDS.has(chainId); + } + + /** + * Fetch current gas prices from Etherscan Gas Tracker API V2 + * Returns base fee and priority fees (safe, propose, fast) + */ + public async getGasOracle(): Promise { + try { + const params = { + chainid: this.chainId, + module: 'gastracker', + action: 'gasoracle', + apikey: this.apiKey, + }; + + logger.debug( + `Fetching gas prices from Etherscan V2 API for ${this.network} (chainId: ${this.chainId}) - URL: ${EtherscanService.BASE_URL}`, + ); + logger.debug( + `Request params: ${JSON.stringify({ chainid: this.chainId, module: 'gastracker', action: 'gasoracle', apikey: '***' })}`, + ); + + const response = await axios.get(EtherscanService.BASE_URL, { + params, + timeout: 5000, + }); + + if (response.data.status !== '1') { + throw new Error( + `Etherscan API error: ${response.data.message} (result: ${JSON.stringify(response.data.result || 'none')})`, + ); + } + + const result = response.data.result; + + // Parse gas prices (all in GWEI) + const gasData: EtherscanGasData = { + baseFee: parseFloat(result.suggestBaseFee), + priorityFeeSafe: parseFloat(result.SafeGasPrice), + priorityFeePropose: parseFloat(result.ProposeGasPrice), + priorityFeeFast: parseFloat(result.FastGasPrice), + }; + + logger.info( + `Etherscan ${this.network}: baseFee=${gasData.baseFee.toFixed(4)} GWEI, ` + + `priority (safe/propose/fast)=${gasData.priorityFeeSafe}/${gasData.priorityFeePropose}/${gasData.priorityFeeFast} GWEI`, + ); + + return gasData; + } catch (error: any) { + if (error.response?.status === 401) { + throw new Error('Invalid Etherscan API key'); + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + throw new Error('Etherscan API request timeout'); + } + throw new Error(`Failed to fetch gas data from Etherscan: ${error.message}`); + } + } + + /** + * Get recommended gas prices for a transaction + * @param speed 'safe' | 'propose' | 'fast' - default is 'propose' (average) + * @returns Object with maxFeePerGas and maxPriorityFeePerGas in GWEI + */ + public async getRecommendedGasPrices( + speed: 'safe' | 'propose' | 'fast' = 'propose', + ): Promise<{ maxFeePerGas: number; maxPriorityFeePerGas: number }> { + const gasData = await this.getGasOracle(); + + // Select priority fee based on speed + let priorityFee: number; + switch (speed) { + case 'safe': + priorityFee = gasData.priorityFeeSafe; + break; + case 'fast': + priorityFee = gasData.priorityFeeFast; + break; + case 'propose': + default: + priorityFee = gasData.priorityFeePropose; + break; + } + + // Calculate maxFeePerGas = baseFee * 2 + priorityFee + // This allows for base fee to potentially double before the tx becomes invalid + const maxFeePerGas = gasData.baseFee * 2 + priorityFee; + + return { + maxFeePerGas, + maxPriorityFeePerGas: priorityFee, + }; + } +} diff --git a/src/chains/ethereum/routes/approve.ts b/src/chains/ethereum/routes/approve.ts index 3a5c511c57..02e9d45b72 100644 --- a/src/chains/ethereum/routes/approve.ts +++ b/src/chains/ethereum/routes/approve.ts @@ -71,7 +71,15 @@ export async function approveEthereumToken( throw fastify.httpErrors.badRequest(`Token not found in token list: ${token}`); } - const amountBigNumber = amount ? utils.parseUnits(amount, fullToken.decimals) : constants.MaxUint256; + // Handle empty string, whitespace, undefined, or null as max approval + const amountBigNumber = + amount && amount.trim() !== '' ? utils.parseUnits(amount, fullToken.decimals) : constants.MaxUint256; + + if (amountBigNumber.eq(constants.MaxUint256)) { + logger.info(`Approving maximum amount (MaxUint256) for ${fullToken.symbol}`); + } else { + logger.info(`Approving ${amount} ${fullToken.symbol} (${amountBigNumber.toString()} in wei)`); + } try { let approval; diff --git a/src/chains/ethereum/routes/estimate-gas.ts b/src/chains/ethereum/routes/estimate-gas.ts index 0a9ded6f89..f35e4eafda 100644 --- a/src/chains/ethereum/routes/estimate-gas.ts +++ b/src/chains/ethereum/routes/estimate-gas.ts @@ -21,7 +21,15 @@ export async function estimateGasEthereum(fastify: FastifyInstance, network: str // Convert GWEI to ETH (1 ETH = 10^9 GWEI) const totalFeeInEth = totalFeeInGwei / 1e9; - return { + // Check if we have EIP-1559 data cached + const isEIP1559Network = + network === 'mainnet' || + network === 'polygon' || + network === 'arbitrum' || + network === 'optimism' || + network === 'base'; + + const response: EstimateGasResponse = { feePerComputeUnit: gasPrice, denomination: 'gwei', computeUnits: DEFAULT_GAS_LIMIT, @@ -29,6 +37,18 @@ export async function estimateGasEthereum(fastify: FastifyInstance, network: str fee: totalFeeInEth, timestamp: Date.now(), }; + + // Add EIP-1559 details if available + if (isEIP1559Network && (ethereum as any).constructor.lastGasPriceEstimate?.isEIP1559) { + const cached = (ethereum as any).constructor.lastGasPriceEstimate; + response.gasType = 'eip1559'; + response.maxFeePerGas = cached.maxFeePerGas; + response.maxPriorityFeePerGas = cached.maxPriorityFeePerGas; + } else if (!isEIP1559Network) { + response.gasType = 'legacy'; + } + + return response; } catch (error) { logger.error(`Error estimating gas for network ${network}: ${error.message}`); @@ -52,6 +72,7 @@ export async function estimateGasEthereum(fastify: FastifyInstance, network: str feeAsset: ethereum.nativeTokenSymbol, fee: totalFeeInEth, timestamp: Date.now(), + gasType: 'legacy', }; } catch (instanceError) { logger.error(`Error getting Ethereum instance for network ${network}: ${instanceError.message}`); diff --git a/src/chains/solana/solana.ts b/src/chains/solana/solana.ts index c979cc86fc..ccd011872b 100644 --- a/src/chains/solana/solana.ts +++ b/src/chains/solana/solana.ts @@ -143,17 +143,21 @@ export class Solana { this.connection = new Connection(rpcUrl, { commitment: 'confirmed', }); + + // Update this.config with Helius-specific fields so they're available throughout the class + this.config = mergedConfig; + + // Initialize HeliusService with merged config (always use mergedConfig, not this.config) + // This ensures HeliusService gets all Helius fields even if this.config wasn't updated + this.heliusService = new HeliusService(mergedConfig); } else { // Fallback to standard nodeURL if no API key - logger.warn(`⚠️ Helius provider selected but no API key configured, falling back to standard RPC`); - logger.info(`Using fallback RPC URL: ${this.config.nodeURL}`); + logger.warn(`⚠️ Helius provider selected but no API key configured`); + logger.info(`Using standard RPC from nodeURL: ${this.config.nodeURL}`); this.connection = new Connection(this.config.nodeURL, { commitment: 'confirmed', }); } - - // Initialize HeliusService with merged config - this.heliusService = new HeliusService(mergedConfig); } catch (error) { // If Helius config not found (e.g., in tests), fallback to standard RPC logger.warn(`Failed to initialize Helius provider: ${error.message}, falling back to standard RPC`); @@ -1422,9 +1426,16 @@ export class Solana { if (this.heliusService) { try { signature = await this.heliusService.sendWithSender(serializedTx); + logger.info('Using Helius Sender for optimized transaction delivery'); } catch (error) { - // Fallback to standard RPC if Sender fails or is not configured - logger.info('Falling back to standard RPC transaction sending'); + // Helius Sender not enabled/configured - use standard sendRawTransaction via Helius RPC + const chainConfig = getSolanaChainConfig(); + const rpcProvider = chainConfig.rpcProvider || 'url'; + if (rpcProvider === 'helius') { + logger.info('Using standard sendRawTransaction via Helius RPC (Sender disabled)'); + } else { + logger.info('Using standard sendRawTransaction'); + } signature = await this.connection.sendRawTransaction(serializedTx, { skipPreflight: true, maxRetries: 0, // Don't rely on RPC provider's retry logic @@ -1432,6 +1443,7 @@ export class Solana { } } else { // No Helius service, use standard RPC + logger.info('Using standard sendRawTransaction'); signature = await this.connection.sendRawTransaction(serializedTx, { skipPreflight: true, maxRetries: 0, diff --git a/src/connectors/meteora/clmm-routes/closePosition.ts b/src/connectors/meteora/clmm-routes/closePosition.ts index 77746dc5f4..66c0206402 100644 --- a/src/connectors/meteora/clmm-routes/closePosition.ts +++ b/src/connectors/meteora/clmm-routes/closePosition.ts @@ -90,12 +90,7 @@ async function closePosition( logger.info('Transaction simulated successfully, sending to network...'); // Send and confirm transaction using sendAndConfirmTransaction which handles signing - // Use higher compute units for closePosition - const { signature, fee } = await solana.sendAndConfirmTransaction( - closePositionTx, - [wallet], - 400000, // Higher compute units for close position - ); + const { signature, fee } = await solana.sendAndConfirmTransaction(closePositionTx, [wallet]); // Get transaction data for confirmation const txData = await solana.connection.getTransaction(signature, { diff --git a/src/connectors/raydium/clmm-routes/openPosition.ts b/src/connectors/raydium/clmm-routes/openPosition.ts index 7144606486..ff353da23b 100644 --- a/src/connectors/raydium/clmm-routes/openPosition.ts +++ b/src/connectors/raydium/clmm-routes/openPosition.ts @@ -22,8 +22,6 @@ async function openPosition( poolAddress: string, baseTokenAmount?: number, quoteTokenAmount?: number, - baseTokenSymbol?: string, - quoteTokenSymbol?: string, slippagePct?: number, ): Promise { const solana = await Solana.getInstance(network); @@ -32,25 +30,12 @@ async function openPosition( // Prepare wallet and check if it's hardware const { wallet, isHardwareWallet } = await raydium.prepareWallet(walletAddress); - // If no pool address provided, find default pool using base and quote tokens - let poolAddressToUse = poolAddress; - if (!poolAddressToUse) { - if (!baseTokenSymbol || !quoteTokenSymbol) { - throw new Error('Either poolAddress or both baseToken and quoteToken must be provided'); - } - - poolAddressToUse = await raydium.findDefaultPool(baseTokenSymbol, quoteTokenSymbol, 'clmm'); - if (!poolAddressToUse) { - throw new Error(`No CLMM pool found for pair ${baseTokenSymbol}-${quoteTokenSymbol}`); - } - } - - const poolResponse = await raydium.getClmmPoolfromAPI(poolAddressToUse); + const poolResponse = await raydium.getClmmPoolfromAPI(poolAddress); if (!poolResponse) { - throw _fastify.httpErrors.notFound(`Pool not found for address: ${poolAddressToUse}`); + throw _fastify.httpErrors.notFound(`Pool not found for address: ${poolAddress}`); } const [poolInfo, poolKeys] = poolResponse; - const rpcData = await raydium.getClmmPoolfromRPC(poolAddressToUse); + const rpcData = await raydium.getClmmPoolfromRPC(poolAddress); poolInfo.price = rpcData.currentPrice; const baseTokenInfo = await solana.getToken(poolInfo.mintA.address); @@ -77,7 +62,7 @@ async function openPosition( network, lowerPrice, upperPrice, - poolAddressToUse, + poolAddress, baseTokenAmount, quoteTokenAmount, slippagePct, @@ -199,8 +184,6 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { poolAddress, baseTokenAmount, quoteTokenAmount, - undefined, // baseToken not needed anymore - undefined, // quoteToken not needed anymore slippagePct, ); } catch (e) { diff --git a/src/connectors/uniswap/clmm-routes/openPosition.ts b/src/connectors/uniswap/clmm-routes/openPosition.ts index 8979fb710e..539da91a51 100644 --- a/src/connectors/uniswap/clmm-routes/openPosition.ts +++ b/src/connectors/uniswap/clmm-routes/openPosition.ts @@ -51,9 +51,7 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { walletAddress: { type: 'string', examples: [walletAddressExample] }, lowerPrice: { type: 'number', examples: [1000] }, upperPrice: { type: 'number', examples: [4000] }, - poolAddress: { type: 'string', examples: [''] }, - baseToken: { type: 'string', examples: ['WETH'] }, - quoteToken: { type: 'string', examples: ['USDC'] }, + poolAddress: { type: 'string', examples: ['0xd0b53d9277642d899df5c87a3966a349a798f224'] }, baseTokenAmount: { type: 'number', examples: [0.001] }, quoteTokenAmount: { type: 'number', examples: [3] }, slippagePct: { type: 'number', examples: [1] }, diff --git a/src/connectors/uniswap/router-routes/executeQuote.ts b/src/connectors/uniswap/router-routes/executeQuote.ts index dd082d26f1..af083bdd3b 100644 --- a/src/connectors/uniswap/router-routes/executeQuote.ts +++ b/src/connectors/uniswap/router-routes/executeQuote.ts @@ -77,13 +77,29 @@ async function executeQuote( const currentTime = Math.floor(Date.now() / 1000); const isExpired = expiration > 0 && expiration < currentTime; + // Log expiration details for debugging + logger.info( + `Permit2 allowance details: amount=${permit2Amount.toString()}, expiration=${expiration}, nonce=${nonce}`, + ); + if (expiration > 0) { + const expirationDate = new Date(expiration * 1000); + const timeUntilExpiration = expiration - currentTime; + logger.info( + `Expiration: ${expirationDate.toISOString()} (${timeUntilExpiration > 0 ? `${Math.floor(timeUntilExpiration / 60)} minutes remaining` : 'EXPIRED'})`, + ); + } else { + logger.info('Expiration: Never (expiration = 0)'); + } + if (isExpired || BigNumber.from(permit2Amount).lt(requiredAllowance)) { const inputAmount = utils.formatUnits(requiredAllowance, inputToken.decimals); const currentPermit2Allowance = utils.formatUnits(permit2Amount, inputToken.decimals); if (isExpired) { + const expirationDate = new Date(expiration * 1000); throw fastify.httpErrors.badRequest( `Permit2 allowance for ${inputToken.symbol} to Universal Router has expired. ` + + `Expired at: ${expirationDate.toISOString()}. ` + `Please approve ${inputToken.symbol} again using spender: "uniswap/router"`, ); } else { @@ -95,7 +111,7 @@ async function executeQuote( } } - logger.info(`Both allowances confirmed: Token->Permit2 and Permit2->UniversalRouter`); + logger.info(`✅ Both allowances confirmed: Token->Permit2 and Permit2->UniversalRouter`); } // Execute the swap transaction @@ -173,9 +189,15 @@ async function executeQuote( } } catch (error) { logger.error(`Swap execution error: ${error.message}`); - // Log more details about the error for debugging Universal Router issues + + // Decode Universal Router error data for better diagnostics + let errorData = ''; + let errorSelector = ''; if (error.error && error.error.data) { - logger.error(`Error data: ${error.error.data}`); + errorData = error.error.data; + errorSelector = errorData.substring(0, 10); // First 10 chars (0x + 8 hex chars) + logger.error(`Error data: ${errorData}`); + logger.error(`Error selector: ${errorSelector}`); } if (error.reason) { logger.error(`Error reason: ${error.reason}`); @@ -187,14 +209,42 @@ async function executeQuote( logger.debug(`Transaction receipt: ${JSON.stringify(error.receipt)}`); } - // Handle specific error cases + // Handle specific Universal Router error codes + if (errorSelector === '0xd81b2f2e') { + // AllowanceExpired error from Permit2 + throw fastify.httpErrors.badRequest( + `Universal Router error: Permit2 allowance has expired for ${inputToken.symbol}. ` + + `Please re-approve the token using spender: "uniswap/router" to set a new expiration.`, + ); + } else if (errorSelector === '0x39d35496' || errorData.includes('TooLittleReceived')) { + // V2TooLittleReceived / InsufficientAmountOut error + throw fastify.httpErrors.badRequest( + `Swap failed: Slippage tolerance exceeded. The output amount would be less than your minimum acceptable amount. ` + + `Try increasing slippage tolerance or request a new quote.`, + ); + } else if (errorSelector === '0x963b34a5' || errorData.includes('TooMuchInputPaid')) { + // V2TooMuchInputPaid error + throw fastify.httpErrors.badRequest( + `Swap failed: Slippage tolerance exceeded. The input amount would be more than your maximum acceptable amount. ` + + `Try increasing slippage tolerance or request a new quote.`, + ); + } + + // Handle general error patterns if (error.message && error.message.includes('insufficient funds')) { throw fastify.httpErrors.badRequest( 'Insufficient funds for transaction. Please ensure you have enough ETH to cover gas costs.', ); } else if (error.message && error.message.includes('cannot estimate gas')) { + // Provide more context if we have error data + let extraContext = ''; + if (errorData) { + extraContext = ` The transaction would revert with error: ${errorSelector}. Check logs for details.`; + } throw fastify.httpErrors.badRequest( - 'Transaction would fail. This could be due to an expired quote, insufficient token balance, or market conditions have changed. Please request a new quote.', + 'Transaction simulation failed. This usually means the transaction would revert on-chain. ' + + `Common causes: expired Permit2 allowance, insufficient balance, slippage tolerance too tight, or quote expired.${extraContext} ` + + 'Please check token approvals and request a new quote.', ); } else if (error.message.includes('rejected on Ledger')) { throw fastify.httpErrors.badRequest('Transaction rejected on Ledger device'); diff --git a/src/pools/pool-info-helpers.ts b/src/pools/pool-info-helpers.ts new file mode 100644 index 0000000000..be59515b60 --- /dev/null +++ b/src/pools/pool-info-helpers.ts @@ -0,0 +1,147 @@ +/** + * Helper functions for fetching pool info from connectors + */ + +import { FastifyInstance } from 'fastify'; + +import { Ethereum } from '../chains/ethereum/ethereum'; +import { Solana } from '../chains/solana/solana'; +import { Meteora } from '../connectors/meteora/meteora'; +import { Raydium } from '../connectors/raydium/raydium'; +import { Uniswap } from '../connectors/uniswap/uniswap'; +import { PoolInfo as AmmPoolInfo } from '../schemas/amm-schema'; +import { PoolInfo as ClmmPoolInfo } from '../schemas/clmm-schema'; +import { logger } from '../services/logger'; + +interface PoolInfoResult { + baseTokenAddress: string; + quoteTokenAddress: string; + feePct: number; +} + +/** + * Fetch pool info from the appropriate connector + */ +export async function fetchPoolInfo( + connector: string, + type: 'amm' | 'clmm', + network: string, + poolAddress: string, +): Promise { + try { + if (connector === 'raydium') { + const raydium = await Raydium.getInstance(network); + + if (type === 'clmm') { + const poolInfo = await raydium.getClmmPoolInfo(poolAddress); + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + }; + } else { + const poolInfo = await raydium.getAmmPoolInfo(poolAddress); + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + }; + } + } else if (connector === 'meteora') { + const meteora = await Meteora.getInstance(network); + const poolInfo = await meteora.getPoolInfo(poolAddress); + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: poolInfo.feePct, + }; + } else if (connector === 'uniswap') { + const ethereum = await Ethereum.getInstance(network); + const { getV2PoolInfo, getV3PoolInfo } = await import('../connectors/uniswap/uniswap.utils'); + + if (type === 'clmm') { + // For CLMM (V3) + const poolInfo = await getV3PoolInfo(poolAddress, network); + if (!poolInfo) { + return null; + } + + // Get fee from pool contract + const { Contract } = await import('@ethersproject/contracts'); + const v3PoolABI = [ + { + inputs: [], + name: 'fee', + outputs: [{ internalType: 'uint24', name: '', type: 'uint24' }], + stateMutability: 'view', + type: 'function', + }, + ]; + + const poolContract = new Contract(poolAddress, v3PoolABI, ethereum.provider); + const fee = await poolContract.fee(); + const feePct = fee / 10000; // Convert from basis points to percentage + + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: feePct, + }; + } else { + // For AMM (V2) + const poolInfo = await getV2PoolInfo(poolAddress, network); + if (!poolInfo) { + return null; + } + + // Uniswap V2 has fixed 0.3% fee + return { + baseTokenAddress: poolInfo.baseTokenAddress, + quoteTokenAddress: poolInfo.quoteTokenAddress, + feePct: 0.3, + }; + } + } + + logger.error(`Unsupported connector: ${connector}`); + return null; + } catch (error) { + logger.error(`Error fetching pool info for ${poolAddress}: ${error.message}`); + return null; + } +} + +/** + * Resolve token addresses to symbols using chain's token registry + */ +export async function resolveTokenSymbols( + connector: string, + network: string, + baseTokenAddress: string, + quoteTokenAddress: string, +): Promise<{ baseSymbol: string; quoteSymbol: string }> { + try { + // Determine chain based on connector + let chain: Solana | Ethereum; + + if (connector === 'raydium' || connector === 'meteora') { + chain = await Solana.getInstance(network); + } else if (connector === 'uniswap') { + chain = await Ethereum.getInstance(network); + } else { + throw new Error(`Unsupported connector: ${connector}`); + } + + // Get token info + const baseToken = await chain.getToken(baseTokenAddress); + const quoteToken = await chain.getToken(quoteTokenAddress); + + return { + baseSymbol: baseToken.symbol, + quoteSymbol: quoteToken.symbol, + }; + } catch (error) { + logger.error(`Error resolving token symbols: ${error.message}`); + throw error; + } +} diff --git a/src/pools/routes/addPool.ts b/src/pools/routes/addPool.ts index 74af5c6e34..a8b15876b7 100644 --- a/src/pools/routes/addPool.ts +++ b/src/pools/routes/addPool.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from 'fastify'; import { PoolService } from '../../services/pool-service'; +import { fetchPoolInfo, resolveTokenSymbols } from '../pool-info-helpers'; import { PoolAddRequestSchema, PoolSuccessResponseSchema } from '../schemas'; import { PoolAddRequest, Pool } from '../types'; @@ -24,36 +25,110 @@ export const addPoolRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request) => { - const { connector, type, network, baseSymbol, quoteSymbol, address } = request.body; + const { + connector, + type, + network, + address, + baseSymbol, + quoteSymbol, + baseTokenAddress, + quoteTokenAddress, + feePct, + } = request.body; const poolService = PoolService.getInstance(); try { + // Step 1: Determine if we need to fetch pool-info for feePct + let finalFeePct = feePct; + + if (finalFeePct === undefined) { + // Fetch pool-info to get fee percentage + const poolInfo = await fetchPoolInfo(connector, type, network, address); + + if (!poolInfo) { + throw fastify.httpErrors.notFound(`Pool not found or unable to fetch pool info: ${address}`); + } + + finalFeePct = poolInfo.feePct; + } + + // Step 2: Resolve token symbols (if not provided by user) + let finalBaseSymbol = baseSymbol; + let finalQuoteSymbol = quoteSymbol; + + if (!finalBaseSymbol || !finalQuoteSymbol) { + const { baseSymbol: resolvedBase, quoteSymbol: resolvedQuote } = await resolveTokenSymbols( + connector, + network, + baseTokenAddress, + quoteTokenAddress, + ); + + finalBaseSymbol = finalBaseSymbol || resolvedBase; + finalQuoteSymbol = finalQuoteSymbol || resolvedQuote; + } + + // Step 3: Create enhanced pool object const pool: Pool = { type, network, - baseSymbol, - quoteSymbol, + baseSymbol: finalBaseSymbol, + quoteSymbol: finalQuoteSymbol, + baseTokenAddress, + quoteTokenAddress, + feePct: finalFeePct, address, }; - // Check if pool already exists - const existingPool = await poolService.getPool(connector, network, type, baseSymbol, quoteSymbol); + // Step 4: Check if a pool with same token pair already exists (ignoring feePct) + const existingPoolByMetadata = await poolService.getPoolByMetadata( + connector, + type, + network, + baseTokenAddress, + quoteTokenAddress, + ); + + if (existingPoolByMetadata) { + if (existingPoolByMetadata.address.toLowerCase() === address.toLowerCase()) { + // Same pool (same address and token pair), just update it (fee tier may have changed) + await poolService.updatePool(connector, pool); + return { + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} updated to ${finalFeePct}% fee in ${connector} ${type} on ${network}`, + }; + } else { + // Different address but same token pair - replace the old pool + await poolService.removePool(connector, network, type, existingPoolByMetadata.address); + await poolService.addPool(connector, pool); + return { + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) replaced (old: ${existingPoolByMetadata.address} ${existingPoolByMetadata.feePct}% fee, new: ${address}) in ${connector} ${type} on ${network}`, + }; + } + } - if (existingPool) { - // Update existing pool + // Step 5: No pool with matching metadata exists - check if address is used + const existingPoolByAddress = await poolService.getPoolByAddress(connector, address); + + if (existingPoolByAddress) { + // Address exists but with different metadata - this shouldn't happen normally + // but we'll update it anyway await poolService.updatePool(connector, pool); return { - message: `Pool ${baseSymbol}-${quoteSymbol} updated successfully in ${connector} ${type} on ${network}`, - }; - } else { - // Add new pool - await poolService.addPool(connector, pool); - return { - message: `Pool ${baseSymbol}-${quoteSymbol} added successfully to ${connector} ${type} on ${network}`, + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) updated successfully in ${connector} ${type} on ${network}`, }; } + + // Step 6: Completely new pool - add it + await poolService.addPool(connector, pool); + return { + message: `Pool ${finalBaseSymbol}-${finalQuoteSymbol} (${finalFeePct}% fee) added successfully to ${connector} ${type} on ${network}`, + }; } catch (error) { + if (error.statusCode) { + throw error; // Already a Fastify error + } throw fastify.httpErrors.badRequest(error.message); } }, diff --git a/src/pools/routes/getPool.ts b/src/pools/routes/getPool.ts index 955879e669..a683fe6e32 100644 --- a/src/pools/routes/getPool.ts +++ b/src/pools/routes/getPool.ts @@ -1,8 +1,7 @@ -import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; import { PoolService } from '../../services/pool-service'; -import { PoolListResponseSchema } from '../schemas'; +import { GetPoolRequestSchema, PoolListResponseSchema } from '../schemas'; export const getPoolRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ @@ -23,26 +22,22 @@ export const getPoolRoute: FastifyPluginAsync = async (fastify) => { properties: { tradingPair: { type: 'string', - description: 'Trading pair (e.g., ETH-USDC, SOL-USDC)', - examples: ['ETH-USDC', 'SOL-USDC'], + description: 'Trading pair (e.g., SOL-USDC, ETH-USDC)', + examples: ['SOL-USDC', 'ETH-USDC'], }, }, required: ['tradingPair'], }, - querystring: Type.Object({ - connector: Type.String({ - description: 'Connector (raydium, meteora, uniswap)', - examples: ['raydium', 'meteora', 'uniswap'], - }), - network: Type.String({ - description: 'Network name (mainnet, mainnet-beta, etc)', - examples: ['mainnet', 'mainnet-beta'], - }), - type: Type.Union([Type.Literal('amm'), Type.Literal('clmm')], { - description: 'Pool type', - examples: ['amm', 'clmm'], - }), - }), + querystring: { + ...GetPoolRequestSchema, + properties: { + ...GetPoolRequestSchema.properties, + network: { + ...GetPoolRequestSchema.properties.network, + default: 'mainnet-beta', + }, + }, + }, response: { 200: PoolListResponseSchema.items, 404: { diff --git a/src/pools/schemas.ts b/src/pools/schemas.ts index fc71cd3a12..019699b815 100644 --- a/src/pools/schemas.ts +++ b/src/pools/schemas.ts @@ -31,6 +31,9 @@ export const PoolListResponseSchema = Type.Array( network: Type.String(), baseSymbol: Type.String(), quoteSymbol: Type.String(), + baseTokenAddress: Type.String(), + quoteTokenAddress: Type.String(), + feePct: Type.Number(), address: Type.String(), }), ); @@ -46,19 +49,58 @@ export const PoolAddRequestSchema = Type.Object({ }), network: Type.String({ description: 'Network name (mainnet, mainnet-beta, etc)', - examples: ['mainnet', 'mainnet-beta'], - }), - baseSymbol: Type.String({ - description: 'Base token symbol', - examples: ['ETH', 'SOL'], - }), - quoteSymbol: Type.String({ - description: 'Quote token symbol', - examples: ['USDC', 'USDT'], + examples: ['mainnet-beta', 'mainnet'], + default: 'mainnet-beta', }), address: Type.String({ description: 'Pool contract address', }), + baseSymbol: Type.Optional( + Type.String({ + description: 'Base token symbol (optional - fetched from pool-info if not provided)', + examples: ['SOL', 'ETH'], + }), + ), + quoteSymbol: Type.Optional( + Type.String({ + description: 'Quote token symbol (optional - fetched from pool-info if not provided)', + examples: ['USDC', 'USDT'], + }), + ), + baseTokenAddress: Type.String({ + description: 'Base token contract address', + examples: ['So11111111111111111111111111111111111111112'], + }), + quoteTokenAddress: Type.String({ + description: 'Quote token contract address', + examples: ['EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'], + }), + feePct: Type.Optional( + Type.Number({ + description: 'Pool fee percentage (optional - fetched from pool-info if not provided)', + examples: [0.25, 0.3, 1], + minimum: 0, + maximum: 100, + }), + ), +}); + +// Get pool request +export const GetPoolRequestSchema = Type.Object({ + connector: Type.String({ + description: 'Connector (raydium, meteora, uniswap)', + examples: ['raydium', 'meteora', 'uniswap'], + }), + network: Type.String({ + description: 'Network name (mainnet, mainnet-beta, etc)', + examples: ['mainnet-beta', 'mainnet'], + default: 'mainnet-beta', + }), + type: Type.String({ + description: 'Pool type', + examples: ['amm', 'clmm'], + enum: ['amm', 'clmm'], + }), }); // Success response diff --git a/src/pools/types.ts b/src/pools/types.ts index 48e102affa..c7d6bb0430 100644 --- a/src/pools/types.ts +++ b/src/pools/types.ts @@ -7,6 +7,9 @@ export interface Pool { network: string; baseSymbol: string; quoteSymbol: string; + baseTokenAddress: string; + quoteTokenAddress: string; + feePct: number; address: string; } @@ -36,7 +39,10 @@ export interface PoolAddRequest { connector: string; type: 'amm' | 'clmm'; network: string; - baseSymbol: string; - quoteSymbol: string; address: string; + baseSymbol?: string; + quoteSymbol?: string; + baseTokenAddress: string; + quoteTokenAddress: string; + feePct?: number; } diff --git a/src/schemas/chain-schema.ts b/src/schemas/chain-schema.ts index 9304577e7f..81b6e734af 100644 --- a/src/schemas/chain-schema.ts +++ b/src/schemas/chain-schema.ts @@ -17,12 +17,15 @@ export type EstimateGasRequestType = Static; export const EstimateGasResponseSchema = Type.Object( { - feePerComputeUnit: Type.Number(), // Fee per compute unit + feePerComputeUnit: Type.Number(), // Fee per compute unit (legacy gas price or maxFeePerGas for EIP-1559) denomination: Type.String(), // Denomination: "lamports" or "gwei" computeUnits: Type.Number(), // Default compute units/gas limit used for fee calculation feeAsset: Type.String(), // Native currency symbol from network config (ETH, SOL, etc.) fee: Type.Number(), // Total fee calculated using default gas/compute limits timestamp: Type.Number(), // Unix timestamp when estimate was made + gasType: Type.Optional(Type.String()), // Gas type: "legacy" or "eip1559" + maxFeePerGas: Type.Optional(Type.Number()), // EIP-1559: Maximum fee per gas in gwei + maxPriorityFeePerGas: Type.Optional(Type.Number()), // EIP-1559: Maximum priority fee per gas in gwei }, { $id: 'EstimateGasResponse' }, ); diff --git a/src/services/pool-service.ts b/src/services/pool-service.ts index ff0087fc99..8d32defca8 100644 --- a/src/services/pool-service.ts +++ b/src/services/pool-service.ts @@ -266,21 +266,52 @@ export class PoolService { throw new Error('Network is required'); } + // Validate token addresses + if (!pool.baseTokenAddress || pool.baseTokenAddress.trim() === '') { + throw new Error('Base token address is required'); + } + + if (!pool.quoteTokenAddress || pool.quoteTokenAddress.trim() === '') { + throw new Error('Quote token address is required'); + } + + // Validate fee percentage + if (pool.feePct === undefined || pool.feePct === null) { + throw new Error('Fee percentage is required'); + } + + if (pool.feePct < 0 || pool.feePct > 100) { + throw new Error('Fee percentage must be between 0 and 100'); + } + // Validate address format based on chain const chain = this.getChainForConnector(connector); if (chain === SupportedChain.SOLANA) { - // Validate Solana address + // Validate Solana addresses try { new PublicKey(pool.address); + new PublicKey(pool.baseTokenAddress); + new PublicKey(pool.quoteTokenAddress); } catch { - throw new Error('Invalid Solana pool address'); + throw new Error('Invalid Solana address'); } } else if (chain === SupportedChain.ETHEREUM) { - // Validate Ethereum address + // Validate Ethereum addresses if (!ethers.utils.isAddress(pool.address)) { throw new Error('Invalid Ethereum pool address'); } + if (!ethers.utils.isAddress(pool.baseTokenAddress)) { + throw new Error('Invalid Ethereum base token address'); + } + if (!ethers.utils.isAddress(pool.quoteTokenAddress)) { + throw new Error('Invalid Ethereum quote token address'); + } + } + + // Validate that base and quote tokens are different + if (pool.baseTokenAddress.toLowerCase() === pool.quoteTokenAddress.toLowerCase()) { + throw new Error('Base and quote tokens must be different'); } } @@ -292,24 +323,11 @@ export class PoolService { const pools = await this.loadPoolList(connector); - // Check for duplicate address + // Check for duplicate address only if (pools.some((p) => p.address.toLowerCase() === pool.address.toLowerCase())) { throw new Error(`Pool with address ${pool.address} already exists`); } - // Check for duplicate token pair on same network and type - if ( - pools.some( - (p) => - p.network === pool.network && - p.type === pool.type && - ((p.baseSymbol === pool.baseSymbol && p.quoteSymbol === pool.quoteSymbol) || - (p.baseSymbol === pool.quoteSymbol && p.quoteSymbol === pool.baseSymbol)), - ) - ) { - throw new Error(`Pool for ${pool.baseSymbol}-${pool.quoteSymbol} already exists on ${pool.network} ${pool.type}`); - } - pools.push(pool); await this.savePoolList(connector, pools); } @@ -340,6 +358,29 @@ export class PoolService { return pools.find((p) => p.address.toLowerCase() === address.toLowerCase()) || null; } + /** + * Get a pool by metadata (type, network, token addresses) + * This finds pools with identical token pair but potentially different fee tiers or addresses + */ + public async getPoolByMetadata( + connector: string, + type: 'amm' | 'clmm', + network: string, + baseTokenAddress: string, + quoteTokenAddress: string, + ): Promise { + const pools = await this.loadPoolList(connector); + return ( + pools.find( + (p) => + p.type === type && + p.network === network && + p.baseTokenAddress.toLowerCase() === baseTokenAddress.toLowerCase() && + p.quoteTokenAddress.toLowerCase() === quoteTokenAddress.toLowerCase(), + ) || null + ); + } + /** * Update an existing pool */ diff --git a/src/templates/chains/ethereum.yml b/src/templates/chains/ethereum.yml index 966943ddaf..abb5018fa7 100644 --- a/src/templates/chains/ethereum.yml +++ b/src/templates/chains/ethereum.yml @@ -1,4 +1,10 @@ defaultNetwork: mainnet defaultWallet: '' + # RPC provider: 'url' uses nodeURL from network config, 'infura' uses Infura service for all networks -rpcProvider: url \ No newline at end of file +rpcProvider: url + +# Etherscan API key for gas price estimates across all supported networks +# Get your free API key from https://etherscan.io/myapikey +# Works for all chains supported by Etherscan V2 API: https://docs.etherscan.io/supported-chains +# etherscanAPIKey: 'YOUR_ETHERSCAN_API_KEY_HERE' \ No newline at end of file diff --git a/src/templates/chains/ethereum/arbitrum.yml b/src/templates/chains/ethereum/arbitrum.yml index c38870b3f5..232c2115dc 100644 --- a/src/templates/chains/ethereum/arbitrum.yml +++ b/src/templates/chains/ethereum/arbitrum.yml @@ -1,4 +1,9 @@ chainID: 42161 nodeURL: https://arb1.arbitrum.io/rpc nativeCurrencySymbol: ETH -minGasPrice: 0.1 +minGasPrice: 0.01 + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +maxFeePerGas: 0.01 +maxPriorityFeePerGas: 0.001 diff --git a/src/templates/chains/ethereum/base.yml b/src/templates/chains/ethereum/base.yml index 43b8ea329d..9a74285538 100644 --- a/src/templates/chains/ethereum/base.yml +++ b/src/templates/chains/ethereum/base.yml @@ -1,4 +1,9 @@ chainID: 8453 nodeURL: https://mainnet.base.org nativeCurrencySymbol: ETH -minGasPrice: 0.1 +minGasPrice: 0.01 + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +maxFeePerGas: 0.01 +maxPriorityFeePerGas: 0.01 diff --git a/src/templates/chains/ethereum/bsc.yml b/src/templates/chains/ethereum/bsc.yml index 786d2850c3..3d6ad1e0ab 100644 --- a/src/templates/chains/ethereum/bsc.yml +++ b/src/templates/chains/ethereum/bsc.yml @@ -1,4 +1,4 @@ chainID: 56 nodeURL: https://binance.llamarpc.com nativeCurrencySymbol: BNB -minGasPrice: 0.1 +minGasPrice: 3 diff --git a/src/templates/chains/ethereum/mainnet.yml b/src/templates/chains/ethereum/mainnet.yml index 7f307d771b..696f7b18e8 100644 --- a/src/templates/chains/ethereum/mainnet.yml +++ b/src/templates/chains/ethereum/mainnet.yml @@ -2,3 +2,8 @@ chainID: 1 nodeURL: https://eth.llamarpc.com nativeCurrencySymbol: ETH minGasPrice: 0.1 + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +# maxFeePerGas: 0.1 +# maxPriorityFeePerGas: 0.01 diff --git a/src/templates/chains/ethereum/optimism.yml b/src/templates/chains/ethereum/optimism.yml index eab8b18fe0..be4f150992 100644 --- a/src/templates/chains/ethereum/optimism.yml +++ b/src/templates/chains/ethereum/optimism.yml @@ -1,4 +1,9 @@ chainID: 10 nodeURL: https://mainnet.optimism.io nativeCurrencySymbol: OETH -minGasPrice: 0.1 +minGasPrice: 0.01 + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +maxFeePerGas: 0.01 +maxPriorityFeePerGas: 0.01 diff --git a/src/templates/chains/ethereum/polygon.yml b/src/templates/chains/ethereum/polygon.yml index 78b9aae2eb..9880404ade 100644 --- a/src/templates/chains/ethereum/polygon.yml +++ b/src/templates/chains/ethereum/polygon.yml @@ -1,4 +1,9 @@ chainID: 137 nodeURL: https://rpc.ankr.com/polygon nativeCurrencySymbol: POL -minGasPrice: 0.1 +minGasPrice: 10 + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +# maxFeePerGas: 10 +# maxPriorityFeePerGas: 10 diff --git a/src/templates/namespace/ethereum-chain-schema.json b/src/templates/namespace/ethereum-chain-schema.json index 5f775c6311..4b219d2cea 100644 --- a/src/templates/namespace/ethereum-chain-schema.json +++ b/src/templates/namespace/ethereum-chain-schema.json @@ -15,6 +15,10 @@ "enum": ["url", "infura"], "default": "url", "description": "RPC provider to use: 'url' for standard nodeURL from network config, 'infura' for Infura service across all networks" + }, + "etherscanAPIKey": { + "type": "string", + "description": "Etherscan API key for gas price estimates (works across all Etherscan V2 supported chains)" } }, "required": ["defaultNetwork", "defaultWallet"], diff --git a/src/templates/namespace/ethereum-network-schema.json b/src/templates/namespace/ethereum-network-schema.json index 8055fa62c1..23a55bc433 100644 --- a/src/templates/namespace/ethereum-network-schema.json +++ b/src/templates/namespace/ethereum-network-schema.json @@ -8,6 +8,14 @@ "minGasPrice": { "type": "number", "description": "Minimum gas price in GWEI" + }, + "maxFeePerGas": { + "type": "number", + "description": "EIP-1559 maximum fee per gas in GWEI (optional, will fetch from network or Etherscan API if not set)" + }, + "maxPriorityFeePerGas": { + "type": "number", + "description": "EIP-1559 maximum priority fee per gas in GWEI (optional, will fetch from network or Etherscan API if not set)" } }, "required": ["chainID", "nodeURL", "nativeCurrencySymbol"], diff --git a/src/templates/pools/meteora.json b/src/templates/pools/meteora.json index 15429c6bc6..bcb6720395 100644 --- a/src/templates/pools/meteora.json +++ b/src/templates/pools/meteora.json @@ -4,20 +4,29 @@ "network": "mainnet-beta", "baseSymbol": "SOL", "quoteSymbol": "USDC", - "address": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + "address": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.04 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "TRUMP", "quoteSymbol": "USDC", - "address": "9d9mb8kooFfaD3SctgZtkxQypkshx6ezhbKio89ixyy2" + "address": "9d9mb8kooFfaD3SctgZtkxQypkshx6ezhbKio89ixyy2", + "baseTokenAddress": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.1 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "JUP", "quoteSymbol": "USDC", - "address": "7HR1ouGwPsCyPScUCx4WJCPyktXMoLitrGkLczd7Vabx" + "address": "7HR1ouGwPsCyPScUCx4WJCPyktXMoLitrGkLczd7Vabx", + "baseTokenAddress": "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.01 } ] diff --git a/src/templates/pools/pancakeswap.json b/src/templates/pools/pancakeswap.json index 292674b1ea..79c014ae01 100644 --- a/src/templates/pools/pancakeswap.json +++ b/src/templates/pools/pancakeswap.json @@ -1,16 +1,42 @@ [ { - "type": "amm", + "type": "clmm", "network": "bsc", - "baseSymbol": "WBNB", + "baseSymbol": "ASTER", "quoteSymbol": "USDT", - "address": "0x16b9a82891338f9ba80e2d6970fdda79d1eb0dae" + "baseTokenAddress": "0x000Ae314E2A2172a039B26378814C252734f556A", + "quoteTokenAddress": "0x55d398326f99059fF775485246999027B3197955", + "feePct": 0.25, + "address": "0xaead6bd31dd66eb3a6216aaf271d0e661585b0b1" }, { "type": "clmm", "network": "bsc", - "baseSymbol": "WBNB", + "baseSymbol": "USDT", + "quoteSymbol": "WBNB", + "baseTokenAddress": "0x55d398326f99059fF775485246999027B3197955", + "quoteTokenAddress": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + "feePct": 0.01, + "address": "0x172fcd41e0913e95784454622d1c3724f546f849" + }, + { + "type": "clmm", + "network": "bsc", + "baseSymbol": "CAKE", "quoteSymbol": "USDT", - "address": "0x36696169c63e42cd08ce11f5deebbcebae652050" + "baseTokenAddress": "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", + "quoteTokenAddress": "0x55d398326f99059fF775485246999027B3197955", + "feePct": 0.25, + "address": "0x7f51c8aaa6b0599abd16674e2b17fec7a9f674a1" + }, + { + "type": "amm", + "network": "bsc", + "baseSymbol": "WBNB", + "quoteSymbol": "BUSD", + "baseTokenAddress": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + "quoteTokenAddress": "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", + "feePct": 0.25, + "address": "0x58f876857a02d6762e0101bb5c46a8c1ed44dc16" } ] diff --git a/src/templates/pools/raydium.json b/src/templates/pools/raydium.json index 986301b48c..a9e5d249d6 100644 --- a/src/templates/pools/raydium.json +++ b/src/templates/pools/raydium.json @@ -1,282 +1,142 @@ [ - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "BONK", - "quoteSymbol": "SOL", - "address": "Hs1X5YtXwZACueUtS9azZyXFDWVxAMLvm3tttubpK7ph" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "HNT", - "quoteSymbol": "SOL", - "address": "CnUuRHkn1yAaL274bWJ7kJN3CQQhcHkh1frZC6YUb6UA" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "JITOSOL", - "quoteSymbol": "SOL", - "address": "7TbGqz32RsuwXbXY7EyBCiAnMbJq1gm1wKmfjQjuwVma" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "JTO", - "quoteSymbol": "SOL", - "address": "GJvWJL7k3CSBvK71cX1Eo3DgTb1JVDNTft8DWqeXfpah" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "JUP", - "quoteSymbol": "SOL", - "address": "BqnpCdDLPV2pFdAaLnVidmn3G93RP2p5oRdGEY2sJGez" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "MSOL", - "quoteSymbol": "SOL", - "address": "EWy2hPdVT4uG6QahQ6o4uXGLpzEEQgpF2gZWPpYsrmzS" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "PENGU", - "quoteSymbol": "SOL", - "address": "FAqh648xeeaTqL7du49sztp9nfj5PjRQrfvaMccyd9cz" - }, { "type": "amm", "network": "mainnet-beta", "baseSymbol": "POPCAT", "quoteSymbol": "SOL", - "address": "FRhB8L7Y9Qq41qZXYLtC2nw8An1RJfLLxRF2x9RwLLMo" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "PYTH", - "quoteSymbol": "SOL", - "address": "84npwQqQAZmB3wwFHKJsDCgxnd7b6uLpK9wyDZgW5xJX" + "address": "FRhB8L7Y9Qq41qZXYLtC2nw8An1RJfLLxRF2x9RwLLMo", + "baseTokenAddress": "7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr", + "quoteTokenAddress": "So11111111111111111111111111111111111111112", + "feePct": 0.0025 }, { "type": "amm", "network": "mainnet-beta", "baseSymbol": "RAY", "quoteSymbol": "SOL", - "address": "AVs9TA4nWDzfPJE9gGVNJMVhcQy3V9PGazuz33BfG2RA" + "address": "AVs9TA4nWDzfPJE9gGVNJMVhcQy3V9PGazuz33BfG2RA", + "baseTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "quoteTokenAddress": "So11111111111111111111111111111111111111112", + "feePct": 0.0025 }, { "type": "amm", "network": "mainnet-beta", "baseSymbol": "RAY", "quoteSymbol": "USDC", - "address": "6UmmUiYoBjSrhakAobJw8BvkmJtDVxaeBtbt7rxWo1mg" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "RAY", - "quoteSymbol": "USDT", - "address": "C4z32zw9WKaGPhNuU54FN8DQ3jFkCnbvPKE63RVVoVtt" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "RENDER", - "quoteSymbol": "SOL", - "address": "AtNnsY1AyRERWJ8xCskfz38YdvruWVJQUVXgScC1iPb" + "address": "6UmmUiYoBjSrhakAobJw8BvkmJtDVxaeBtbt7rxWo1mg", + "baseTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.0025 }, { "type": "amm", "network": "mainnet-beta", "baseSymbol": "SOL", "quoteSymbol": "USDC", - "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "USDT", - "address": "7XawhbbxtsRcQA8FJ1pd41rbmkrYXMpK6Ns6YWnmUfVW" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "STSOL", - "quoteSymbol": "SOL", - "address": "2QdhepnKRTLjjSqPL1PtKNwqrUkoLee5Gqs8bvZhRdMv" + "address": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.0025 }, { "type": "amm", "network": "mainnet-beta", "baseSymbol": "TRUMP", "quoteSymbol": "SOL", - "address": "HKuJrP5tYQLbEUdjKwjgnHs2957QKjR2iWhJKTtMa1xs" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "USDC", - "quoteSymbol": "PYUSD", - "address": "AUXCBLyRWuHJ93xJSbQiGBhJeMWvR1UBxxYTy2hzfGNu" - }, - { - "type": "amm", - "network": "mainnet-beta", - "baseSymbol": "USDC", - "quoteSymbol": "USDT", - "address": "77quYg4MGneUdjgXCunt9GgM1usmrxKY31twEy3WHwcS" + "address": "HKuJrP5tYQLbEUdjKwjgnHs2957QKjR2iWhJKTtMa1xs", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "feePct": 0 }, { "type": "amm", "network": "mainnet-beta", "baseSymbol": "WIF", "quoteSymbol": "SOL", - "address": "EP2ib6dYdEeqD8MfE2ezHCxX3kP3K2eLKkirfPm5eyMx" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "BONK", - "quoteSymbol": "USDC", - "address": "3ne4mWqdYuNiYrYZC9TrA3FcfuFdErghH97vNPbjicr1" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "HNT", - "quoteSymbol": "USDC", - "address": "5HQhuhoRGAYs9amq4bzsaVGJYpKQq1ETCZmxWdMMjPr1" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "JTO", - "quoteSymbol": "USDC", - "address": "DGnU5hTf9FgEqN5s5C8RJmCGWFvSrNfY7CqHHetquLYJ" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "JUP", - "quoteSymbol": "USDC", - "address": "7o3FJ2VBsq9n4Pz3J9FqoF7kMSjqVe5KHXaJqLqNWxWt" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "PENGU", - "quoteSymbol": "SOL", - "address": "2giZ8bfaq2yZRxvvJrwJmVstVr4WXb5GWxh5cPvCz3KH" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "POPCAT", - "quoteSymbol": "USDC", - "address": "EJKqF4p7xVhXkcDNCrVQJE4osow6DgS2X9Q1kaVBFnUY" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "PYTH", - "quoteSymbol": "USDC", - "address": "FT4tqtU5XiJPhbM5nGnbS5Mh5zr9KD1KCtcgmwa2fHBh" + "address": "EP2ib6dYdEeqD8MfE2ezHCxX3kP3K2eLKkirfPm5eyMx", + "baseTokenAddress": "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", + "quoteTokenAddress": "So11111111111111111111111111111111111111112", + "feePct": 0.0025 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "RAY", "quoteSymbol": "USDC", - "address": "61R1ndXxvsWXXkWSyNkCxnzwd3zUNB8Q2ibmkiLPC8ht" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "RENDER", - "quoteSymbol": "USDC", - "address": "6a1CsrpeZubDjEJE9s1CMVheB6HWM5d7m1cj2jkhyXhj" + "address": "61R1ndXxvsWXXkWSyNkCxnzwd3zUNB8Q2ibmkiLPC8ht", + "baseTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.25 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "SOL", "quoteSymbol": "JITOSOL", - "address": "2uoKbPEidR7KAMYtY4x7xdkHXWqYib5k4CutJauSL3Mc" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "MSOL", - "address": "DfgCnzaiTXfPkAH1C1Z441b5MzjjTCEh134ioxqRZxYf" + "address": "2uoKbPEidR7KAMYtY4x7xdkHXWqYib5k4CutJauSL3Mc", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", + "feePct": 0.01 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "SOL", "quoteSymbol": "RAY", - "address": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "SOL", - "quoteSymbol": "STSOL", - "address": "HnDJ5n2XC2UqYbPrG6TyTzfN7WQ1ctudQPeDUfWgrFmP" + "address": "2AXXcN6oN9bBT5owwmTH53C7QHUXvhLeu718Kqt8rvY2", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R", + "feePct": 0.05 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "SOL", "quoteSymbol": "TRUMP", - "address": "GQsPr4RJk9AZkkfWHud7v4MtotcxhaYzZHdsPCg9vNvW" + "address": "GQsPr4RJk9AZkkfWHud7v4MtotcxhaYzZHdsPCg9vNvW", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "feePct": 0.25 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "SOL", "quoteSymbol": "USDC", - "address": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv" + "address": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.04 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "SOL", "quoteSymbol": "USDT", - "address": "3nMFwZXwY1s1M5s8vYAHqd4wGs4iSxXE4LRoUMMYqEgF" + "address": "3nMFwZXwY1s1M5s8vYAHqd4wGs4iSxXE4LRoUMMYqEgF", + "baseTokenAddress": "So11111111111111111111111111111111111111112", + "quoteTokenAddress": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "feePct": 0.01 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "TRUMP", "quoteSymbol": "USDC", - "address": "7XzVsjqTebULfkUofTDH5gDdZDmxacPmPuTfHa1n9kuh" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "USDC", - "quoteSymbol": "PYUSD", - "address": "2YP2zSKjqcKm6NQeKiiwHYJTh9xCgHywKupTF8TdHCfm" + "address": "7XzVsjqTebULfkUofTDH5gDdZDmxacPmPuTfHa1n9kuh", + "baseTokenAddress": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "quoteTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "feePct": 0.25 }, { "type": "clmm", "network": "mainnet-beta", "baseSymbol": "USDC", "quoteSymbol": "USDT", - "address": "BZtgQEyS6eXUXicYPHecYQ7PybqodXQMvkjUbP4R8mUU" - }, - { - "type": "clmm", - "network": "mainnet-beta", - "baseSymbol": "WIF", - "quoteSymbol": "USDC", - "address": "Bq25CEmFVfhqA1b4FGPWQKaUoYPiHorPFeBZPRFpump" + "address": "BZtgQEyS6eXUXicYPHecYQ7PybqodXQMvkjUbP4R8mUU", + "baseTokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "quoteTokenAddress": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", + "feePct": 0.01 } ] diff --git a/src/templates/pools/uniswap.json b/src/templates/pools/uniswap.json index 56470f225f..410ae08476 100644 --- a/src/templates/pools/uniswap.json +++ b/src/templates/pools/uniswap.json @@ -4,300 +4,329 @@ "network": "base", "baseSymbol": "AERO", "quoteSymbol": "USDC", - "address": "0x6cdcb1c4a4d1c3c6d054b27ac5b77e89eafb971d" + "address": "0x6cdcb1c4a4d1c3c6d054b27ac5b77e89eafb971d", + "baseTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "quoteTokenAddress": "0x940181a94A35A4569E4529A3CDfB74e38FD98631", + "feePct": 0.3 }, { "type": "amm", "network": "arbitrum", "baseSymbol": "ARB", "quoteSymbol": "USDC", - "address": "0xcda53b1f66614552f834ceef361a8d12a0b8dad8" - }, - { - "type": "amm", - "network": "base", - "baseSymbol": "cbETH", - "quoteSymbol": "WETH", - "address": "0x8f9eec1f47f96e82ad454c070bbf7f3e1e4f3c75" + "address": "0xcda53b1f66614552f834ceef361a8d12a0b8dad8", + "baseTokenAddress": "0x912CE59144191C1204E64559FE8253a0e49E6548", + "quoteTokenAddress": "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", + "feePct": 0.3 }, { "type": "amm", "network": "optimism", "baseSymbol": "OP", "quoteSymbol": "USDC", - "address": "0x1c3140ab59d6caf9fa7459c6f83d4b52ba881d36" + "address": "0x1c3140ab59d6caf9fa7459c6f83d4b52ba881d36", + "baseTokenAddress": "0x4200000000000000000000000000000000000042", + "quoteTokenAddress": "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", + "feePct": 0.3 }, { "type": "amm", "network": "mainnet", "baseSymbol": "USDC", "quoteSymbol": "USDT", - "address": "0x3416cf6c708da44db2624d63ea0aaef7113527c6" + "address": "0x3416cf6c708da44db2624d63ea0aaef7113527c6", + "baseTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "quoteTokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "feePct": 0.3 }, { "type": "amm", "network": "mainnet", "baseSymbol": "WBTC", "quoteSymbol": "WETH", - "address": "0xbb2b8038a1640196fbe3e38816f3e67cba72d940" + "address": "0xbb2b8038a1640196fbe3e38816f3e67cba72d940", + "baseTokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "feePct": 0.3 }, { "type": "amm", "network": "arbitrum", "baseSymbol": "WETH", "quoteSymbol": "ARB", - "address": "0xc24f7d8e51a64dc1238880bd00bb961d54cbeb29" + "address": "0xc24f7d8e51a64dc1238880bd00bb961d54cbeb29", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0xFa7F8980b0f1E64A2062791cc3b0871572f1F7f0", + "feePct": 0.3 }, { "type": "amm", "network": "mainnet", "baseSymbol": "WETH", "quoteSymbol": "DAI", - "address": "0xa478c2975ab1ea89e8196811f51a7b7ade33eb11" + "address": "0xa478c2975ab1ea89e8196811f51a7b7ade33eb11", + "baseTokenAddress": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "feePct": 0.3 }, { "type": "amm", "network": "optimism", "baseSymbol": "WETH", "quoteSymbol": "OP", - "address": "0x68f5c0a2de713a54991e01858fd27a3832401849" + "address": "0x68f5c0a2de713a54991e01858fd27a3832401849", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0x4200000000000000000000000000000000000042", + "feePct": 0.3 }, { "type": "amm", "network": "base", "baseSymbol": "WETH", "quoteSymbol": "USDbC", - "address": "0xc9034c3e7f58003e6ae0c8438e7c8f4598d5acaa" + "address": "0xc9034c3e7f58003e6ae0c8438e7c8f4598d5acaa", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed", + "feePct": 0.3 }, { "type": "amm", "network": "mainnet", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640" + "address": "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640", + "baseTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "feePct": 0.3 }, { "type": "amm", "network": "arbitrum", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0xc24f7d8e51a64dc1238880bd00bb961d54cbeb29" + "address": "0xc24f7d8e51a64dc1238880bd00bb961d54cbeb29", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0xFa7F8980b0f1E64A2062791cc3b0871572f1F7f0", + "feePct": 0.3 }, { "type": "amm", "network": "optimism", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0x85149247691df622eaf1a8bd0cafd40bc45154a9" + "address": "0x85149247691df622eaf1a8bd0cafd40bc45154a9", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", + "feePct": 0.3 }, { "type": "amm", "network": "polygon", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0x45dda9cb7c25131df268515131f647d726f50608" + "address": "0x45dda9cb7c25131df268515131f647d726f50608", + "baseTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "quoteTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "feePct": 0.3 }, { "type": "amm", "network": "base", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C" + "address": "0x88A43bbDF9D098eEC7bCEda4e2494615dfD9bB9C", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "feePct": 0.3 }, { "type": "amm", "network": "mainnet", "baseSymbol": "WETH", "quoteSymbol": "USDT", - "address": "0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852" + "address": "0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852", + "baseTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "quoteTokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "feePct": 0.3 }, { "type": "amm", "network": "arbitrum", "baseSymbol": "WETH", "quoteSymbol": "USDT", - "address": "0x641c00a822e8b671738d32a431a4fb6074e5c79d" - }, - { - "type": "amm", - "network": "polygon", - "baseSymbol": "WETH", - "quoteSymbol": "USDT", - "address": "0x4ccdabe6fe4a5f9f01f39142fdadafb07fc3e766" + "address": "0x641c00a822e8b671738d32a431a4fb6074e5c79d", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "feePct": 0.3 }, { "type": "amm", "network": "polygon", "baseSymbol": "WMATIC", "quoteSymbol": "USDC", - "address": "0xa374094527e1673a86de625aa59517c5de346d32" + "address": "0xa374094527e1673a86de625aa59517c5de346d32", + "baseTokenAddress": "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + "quoteTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "feePct": 0.3 }, { "type": "amm", "network": "polygon", "baseSymbol": "WMATIC", "quoteSymbol": "WETH", - "address": "0x86f1d8390222a3691c28938ec7404a1661e618e0" - }, - { - "type": "clmm", - "network": "base", - "baseSymbol": "AERO", - "quoteSymbol": "WETH", - "address": "0x8909f73188c4fe68b298a5c6dca21444c6d5ee20" - }, - { - "type": "clmm", - "network": "arbitrum", - "baseSymbol": "ARB", - "quoteSymbol": "USDC", - "address": "0xfb29e74d955f10ba95e70bb9c6da3055d1635c34" + "address": "0x86f1d8390222a3691c28938ec7404a1661e618e0", + "baseTokenAddress": "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + "quoteTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "feePct": 0.3 }, { "type": "clmm", "network": "arbitrum", "baseSymbol": "ARB", "quoteSymbol": "WETH", - "address": "0xc6f780497a95e246eb9449f5e4770916dcd6396a" - }, - { - "type": "clmm", - "network": "base", - "baseSymbol": "cbETH", - "quoteSymbol": "WETH", - "address": "0x44e44b28bd8aa6e37ba298c8217103fec8ed99aa" - }, - { - "type": "clmm", - "network": "optimism", - "baseSymbol": "OP", - "quoteSymbol": "USDC", - "address": "0x0df83a3da7763b6c8dc84bfb84db8fb0dd5b7316" + "address": "0xc6f780497a95e246eb9449f5e4770916dcd6396a", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0x912CE59144191C1204E64559FE8253a0e49E6548", + "feePct": 0.05 }, { "type": "clmm", "network": "mainnet", "baseSymbol": "USDC", "quoteSymbol": "USDT", - "address": "0x7858e59e0c01ea06df3af3d20ac7b0003275d4bf" - }, - { - "type": "clmm", - "network": "base", - "baseSymbol": "VIRTUAL", - "quoteSymbol": "WETH", - "address": "0x5500721a44b0e25f45a9c30044624a12b5586760" + "address": "0x7858e59e0c01ea06df3af3d20ac7b0003275d4bf", + "baseTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "quoteTokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "feePct": 0.05 }, { "type": "clmm", "network": "mainnet", "baseSymbol": "WBTC", "quoteSymbol": "WETH", - "address": "0xcbcdf9626bc03e24f779434178a73a0b4bad62ed" + "address": "0xcbcdf9626bc03e24f779434178a73a0b4bad62ed", + "baseTokenAddress": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "feePct": 0.3 }, { "type": "clmm", "network": "mainnet", "baseSymbol": "WETH", "quoteSymbol": "DAI", - "address": "0x60594a405d53811d3bc4766596efd80fd545a270" + "address": "0x60594a405d53811d3bc4766596efd80fd545a270", + "baseTokenAddress": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "feePct": 0.05 }, { "type": "clmm", "network": "arbitrum", "baseSymbol": "WETH", "quoteSymbol": "GMX", - "address": "0x80a9ae39310abf666a87c743d6ebbd0e8c42158e" + "address": "0x80a9ae39310abf666a87c743d6ebbd0e8c42158e", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a", + "feePct": 1 }, { "type": "clmm", "network": "optimism", "baseSymbol": "WETH", "quoteSymbol": "OP", - "address": "0xfc1f3296458f9b2a27a0b91dd7681c4020e09d05" + "address": "0xfc1f3296458f9b2a27a0b91dd7681c4020e09d05", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0x4200000000000000000000000000000000000042", + "feePct": 0.05 }, { "type": "clmm", "network": "base", "baseSymbol": "WETH", "quoteSymbol": "USDbC", - "address": "0x4c36388be6f416a29c8d8eee81c771ce6be14b18" + "address": "0x4c36388be6f416a29c8d8eee81c771ce6be14b18", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA", + "feePct": 0.05 }, { "type": "clmm", "network": "mainnet", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8" + "address": "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8", + "baseTokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "quoteTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "feePct": 0.3 }, { "type": "clmm", "network": "arbitrum", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0xc473e2aee3441bf9240be85eb122abb059a3b57c" - }, - { - "type": "clmm", - "network": "optimism", - "baseSymbol": "WETH", - "quoteSymbol": "USDC", - "address": "0xac53e1ccc953502726f4b5f672faaaa65dcf618a" + "address": "0xc473e2aee3441bf9240be85eb122abb059a3b57c", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "feePct": 0.3 }, { "type": "clmm", "network": "polygon", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0x45dda9cb7c25131df268515131f647d726f50608" + "address": "0x45dda9cb7c25131df268515131f647d726f50608", + "baseTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "quoteTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "feePct": 0.05 }, { "type": "clmm", "network": "base", "baseSymbol": "WETH", "quoteSymbol": "USDC", - "address": "0xd0b53d9277642d899df5c87a3966a349a798f224" + "address": "0xd0b53d9277642d899df5c87a3966a349a798f224", + "baseTokenAddress": "0x4200000000000000000000000000000000000006", + "quoteTokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "feePct": 0.05 }, { "type": "clmm", "network": "mainnet", "baseSymbol": "WETH", "quoteSymbol": "USDT", - "address": "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36" + "address": "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36", + "baseTokenAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "quoteTokenAddress": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "feePct": 0.3 }, { "type": "clmm", "network": "arbitrum", "baseSymbol": "WETH", "quoteSymbol": "USDT", - "address": "0xc82819f72a9e77e2c0c3a69b3196478f44303cf4" - }, - { - "type": "clmm", - "network": "optimism", - "baseSymbol": "WETH", - "quoteSymbol": "USDT", - "address": "0xd9e08e2d1d0a3354b1dc3cf09fe9ff3d90027b96" - }, - { - "type": "clmm", - "network": "polygon", - "baseSymbol": "WETH", - "quoteSymbol": "USDT", - "address": "0x3b3f747c4c5e22d0cf31eec7832e5dc1dd98e421" + "address": "0xc82819f72a9e77e2c0c3a69b3196478f44303cf4", + "baseTokenAddress": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "quoteTokenAddress": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "feePct": 0.3 }, { "type": "clmm", "network": "polygon", "baseSymbol": "WMATIC", "quoteSymbol": "USDC", - "address": "0xa374094527e1673a86de625aa59517c5de346d32" + "address": "0xa374094527e1673a86de625aa59517c5de346d32", + "baseTokenAddress": "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + "quoteTokenAddress": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "feePct": 0.05 }, { "type": "clmm", "network": "polygon", "baseSymbol": "WMATIC", "quoteSymbol": "WETH", - "address": "0x86f1d8390222a3691c28938ec7404a1661e618e0" + "address": "0x86f1d8390222a3691c28938ec7404a1661e618e0", + "baseTokenAddress": "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + "quoteTokenAddress": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619", + "feePct": 0.05 } ] diff --git a/src/templates/tokens/ethereum/bsc.json b/src/templates/tokens/ethereum/bsc.json index bc27bc607b..4cd3f5e74e 100644 --- a/src/templates/tokens/ethereum/bsc.json +++ b/src/templates/tokens/ethereum/bsc.json @@ -1,4 +1,25 @@ [ + { + "chainId": 56, + "name": "Aster", + "symbol": "ASTER", + "address": "0x000Ae314E2A2172a039B26378814C252734f556A", + "decimals": 18 + }, + { + "chainId": 56, + "name": "BUSD Token", + "symbol": "BUSD", + "address": "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", + "decimals": 18 + }, + { + "chainId": 56, + "name": "PancakeSwap Token", + "symbol": "Cake", + "address": "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", + "decimals": 18 + }, { "chainId": 56, "name": "Binance Pegged DAI", diff --git a/test/chains/ethereum/etherscan-service.test.ts b/test/chains/ethereum/etherscan-service.test.ts new file mode 100644 index 0000000000..fc173d05d4 --- /dev/null +++ b/test/chains/ethereum/etherscan-service.test.ts @@ -0,0 +1,238 @@ +import axios from 'axios'; + +import { EtherscanService } from '../../../src/chains/ethereum/etherscan-service'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('EtherscanService', () => { + let etherscanService: EtherscanService; + const testApiKey = 'test-etherscan-key-123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Constructor and isSupported', () => { + it('should create service for Ethereum mainnet (chainId 1)', () => { + expect(() => { + new EtherscanService(1, 'mainnet', testApiKey); + }).not.toThrow(); + }); + + it('should create service for Polygon (chainId 137)', () => { + expect(() => { + new EtherscanService(137, 'polygon', testApiKey); + }).not.toThrow(); + }); + + it('should create service for BSC (chainId 56)', () => { + expect(() => { + new EtherscanService(56, 'bsc', testApiKey); + }).not.toThrow(); + }); + + it('should throw error for Base (chainId 8453) - not supported', () => { + expect(() => { + new EtherscanService(8453, 'base', testApiKey); + }).toThrow('Etherscan API not supported for chainId: 8453'); + }); + + it('should throw error for Arbitrum (chainId 42161) - not supported', () => { + expect(() => { + new EtherscanService(42161, 'arbitrum', testApiKey); + }).toThrow('Etherscan API not supported for chainId: 42161'); + }); + + it('should throw error for Optimism (chainId 10) - not supported', () => { + expect(() => { + new EtherscanService(10, 'optimism', testApiKey); + }).toThrow('Etherscan API not supported for chainId: 10'); + }); + + it('should return true for supported chain IDs', () => { + expect(EtherscanService.isSupported(1)).toBe(true); // Ethereum + expect(EtherscanService.isSupported(137)).toBe(true); // Polygon + expect(EtherscanService.isSupported(56)).toBe(true); // BSC + }); + + it('should return false for unsupported chain IDs', () => { + expect(EtherscanService.isSupported(8453)).toBe(false); // Base + expect(EtherscanService.isSupported(42161)).toBe(false); // Arbitrum + expect(EtherscanService.isSupported(10)).toBe(false); // Optimism + }); + }); + + describe('getGasOracle', () => { + beforeEach(() => { + etherscanService = new EtherscanService(1, 'mainnet', testApiKey); + }); + + it('should fetch gas prices successfully from Etherscan API', async () => { + const mockResponse = { + data: { + status: '1', + message: 'OK', + result: { + LastBlock: '23585305', + SafeGasPrice: '0.241833537', + ProposeGasPrice: '0.241833538', + FastGasPrice: '0.32341308', + suggestBaseFee: '0.241833537', + gasUsedRatio: '0.435419730941365,0.0126618899438999', + }, + }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + const result = await etherscanService.getGasOracle(); + + expect(result).toEqual({ + baseFee: 0.241833537, + priorityFeeSafe: 0.241833537, + priorityFeePropose: 0.241833538, + priorityFeeFast: 0.32341308, + }); + + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://api.etherscan.io/v2/api', + expect.objectContaining({ + params: { + chainid: 1, + module: 'gastracker', + action: 'gasoracle', + apikey: testApiKey, + }, + timeout: 5000, + }), + ); + }); + + it('should handle API error response', async () => { + const mockResponse = { + data: { + status: '0', + message: 'NOTOK', + result: 'Error! Invalid API key', + }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + await expect(etherscanService.getGasOracle()).rejects.toThrow( + 'Failed to fetch gas data from Etherscan: Etherscan API error: NOTOK', + ); + }); + + it('should handle 401 unauthorized error', async () => { + const mockError = { + response: { status: 401 }, + message: 'Unauthorized', + }; + + mockedAxios.get.mockRejectedValue(mockError); + + await expect(etherscanService.getGasOracle()).rejects.toThrow('Invalid Etherscan API key'); + }); + + it('should handle timeout error', async () => { + const mockError = { + code: 'ETIMEDOUT', + message: 'Timeout', + }; + + mockedAxios.get.mockRejectedValue(mockError); + + await expect(etherscanService.getGasOracle()).rejects.toThrow('Etherscan API request timeout'); + }); + + it('should use correct chainId for Polygon', async () => { + const polygonService = new EtherscanService(137, 'polygon', testApiKey); + + const mockResponse = { + data: { + status: '1', + message: 'OK', + result: { + LastBlock: '77726694', + SafeGasPrice: '32.97', + ProposeGasPrice: '45', + FastGasPrice: '45.5', + suggestBaseFee: '0.000000095', + gasUsedRatio: '0.199596133333333,0.274860822222222', + UsdPrice: '0.19475759691524', + }, + }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + + await polygonService.getGasOracle(); + + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://api.etherscan.io/v2/api', + expect.objectContaining({ + params: expect.objectContaining({ + chainid: 137, + }), + }), + ); + }); + }); + + describe('getRecommendedGasPrices', () => { + beforeEach(() => { + etherscanService = new EtherscanService(1, 'mainnet', testApiKey); + + const mockResponse = { + data: { + status: '1', + message: 'OK', + result: { + LastBlock: '23585305', + SafeGasPrice: '0.2', + ProposeGasPrice: '0.3', + FastGasPrice: '0.5', + suggestBaseFee: '0.1', + gasUsedRatio: '0.5', + }, + }, + }; + + mockedAxios.get.mockResolvedValue(mockResponse); + }); + + it('should return propose (average) speed prices by default', async () => { + const result = await etherscanService.getRecommendedGasPrices(); + + expect(result.maxPriorityFeePerGas).toBe(0.3); + // maxFeePerGas = baseFee * 2 + priorityFee = 0.1 * 2 + 0.3 = 0.5 + expect(result.maxFeePerGas).toBe(0.5); + }); + + it('should return safe (slow) speed prices', async () => { + const result = await etherscanService.getRecommendedGasPrices('safe'); + + expect(result.maxPriorityFeePerGas).toBe(0.2); + // maxFeePerGas = baseFee * 2 + priorityFee = 0.1 * 2 + 0.2 = 0.4 + expect(result.maxFeePerGas).toBe(0.4); + }); + + it('should return fast speed prices', async () => { + const result = await etherscanService.getRecommendedGasPrices('fast'); + + expect(result.maxPriorityFeePerGas).toBe(0.5); + // maxFeePerGas = baseFee * 2 + priorityFee = 0.1 * 2 + 0.5 = 0.7 + expect(result.maxFeePerGas).toBe(0.7); + }); + + it('should calculate maxFeePerGas correctly (baseFee * 2 + priorityFee)', async () => { + const result = await etherscanService.getRecommendedGasPrices('propose'); + + // baseFee = 0.1, priorityFee = 0.3 + // maxFeePerGas = 0.1 * 2 + 0.3 = 0.5 + expect(result.maxFeePerGas).toBe(0.5); + }); + }); +}); diff --git a/test/chains/ethereum/infura-config-regression.test.ts b/test/chains/ethereum/infura-config-regression.test.ts new file mode 100644 index 0000000000..b8b26f37e2 --- /dev/null +++ b/test/chains/ethereum/infura-config-regression.test.ts @@ -0,0 +1,128 @@ +/** + * Regression test for Infura configuration + * + * Ensures that when Infura provider is configured (rpcProvider: infura): + * 1. InfuraService is properly initialized with API key + * 2. Provider is using Infura endpoints (not standard nodeURL) + * 3. WebSocket configuration is properly applied when enabled + * 4. Health checks work correctly + * + * This test verifies the complete Infura integration for Ethereum networks. + * + * Requirements: + * - GATEWAY_TEST_MODE=dev environment variable + * - conf/chains/ethereum.yml with rpcProvider: infura + * - conf/rpc/infura.yml with valid apiKey + */ + +import { Ethereum } from '../../../src/chains/ethereum/ethereum'; +import { getEthereumChainConfig } from '../../../src/chains/ethereum/ethereum.config'; + +describe('Infura Configuration Regression Test', () => { + // Only run if GATEWAY_TEST_MODE=dev AND rpcProvider is set to infura + const isTestMode = process.env.GATEWAY_TEST_MODE === 'dev'; + const chainConfig = isTestMode ? getEthereumChainConfig() : { rpcProvider: 'url' }; + const isInfuraConfigured = isTestMode && chainConfig.rpcProvider === 'infura'; + + (isInfuraConfigured ? describe : describe.skip)('When Infura provider is enabled', () => { + let ethereum: Ethereum; + + beforeAll(async () => { + ethereum = await Ethereum.getInstance('mainnet'); + }, 60000); + + it('should have InfuraService initialized', () => { + const infuraService = (ethereum as any).infuraService; + expect(infuraService).toBeDefined(); + }); + + it('should be using Infura provider (not standard RPC)', () => { + const provider = ethereum.provider; + expect(provider).toBeDefined(); + + // The provider should be connected (has a connection property) + expect(provider.connection).toBeDefined(); + + // For Infura, the URL should contain 'infura.io' + const url = provider.connection.url; + expect(url).toContain('infura.io'); + }); + + it('should have valid Infura API key in URL', () => { + const provider = ethereum.provider; + const url = provider.connection.url; + + // URL should match pattern: https://{network}.infura.io/v3/{apiKey} + expect(url).toMatch(/https:\/\/[\w-]+\.infura\.io\/v3\/[\w-]+/); + + // API key should not be placeholder + expect(url).not.toContain('INFURA_API_KEY'); + expect(url).not.toContain('YOUR_API_KEY'); + }); + + it('should successfully connect to Infura RPC', async () => { + // Simple test: fetch block number + const blockNumber = await ethereum.provider.getBlockNumber(); + expect(blockNumber).toBeGreaterThan(0); + expect(typeof blockNumber).toBe('number'); + }); + + it('should pass Infura health check', async () => { + const infuraService = (ethereum as any).infuraService; + if (infuraService) { + const healthy = await infuraService.healthCheck(); + expect(healthy).toBe(true); + } + }); + + it('should estimate gas price without errors', async () => { + // Verify that estimateGasPrice works + const gasPrice = await ethereum.estimateGasPrice(); + + expect(gasPrice).toBeGreaterThan(0); + expect(typeof gasPrice).toBe('number'); + expect(isNaN(gasPrice)).toBe(false); + }); + + it('should have correct network configuration', () => { + expect(ethereum.network).toBe('mainnet'); + expect(ethereum.chainId).toBe(1); + expect(ethereum.nativeTokenSymbol).toBe('ETH'); + }); + }); + + describe('Configuration structure validation', () => { + it('config object should have required Infura fields', () => { + // This test ensures the config structure supports Infura fields + const config: any = { + nodeURL: 'https://mainnet.infura.io/v3/test', + chainID: 1, + nativeCurrencySymbol: 'ETH', + infuraAPIKey: 'test-api-key', + useInfuraWebSocket: false, + }; + + // These fields should all be defined + expect(config.infuraAPIKey).toBeDefined(); + expect(config.useInfuraWebSocket).toBeDefined(); + expect(config.chainID).toBeDefined(); + expect(config.nativeCurrencySymbol).toBeDefined(); + }); + }); + + (isInfuraConfigured ? describe : describe.skip)('Infura provider comparison', () => { + it('should use Infura URL instead of configured nodeURL when rpcProvider=infura', async () => { + const ethereum = await Ethereum.getInstance('mainnet'); + const provider = ethereum.provider; + const url = provider.connection.url; + + // Should be using Infura, not the nodeURL from config + expect(url).toContain('infura.io'); + + // Verify it's NOT using a different RPC provider + expect(url).not.toContain('alchemy.com'); + expect(url).not.toContain('quicknode.com'); + expect(url).not.toContain('ankr.com'); + }); + }); +}); diff --git a/test/chains/solana/helius-config-regression.test.ts b/test/chains/solana/helius-config-regression.test.ts new file mode 100644 index 0000000000..ceee14f103 --- /dev/null +++ b/test/chains/solana/helius-config-regression.test.ts @@ -0,0 +1,131 @@ +/** + * Regression test for Helius configuration bug + * + * Bug: When Helius provider was initialized, the mergedConfig with useHeliusRestRPC=true + * was only passed to HeliusService but not applied to this.config, causing + * estimateGasPrice() to always fall back to minimum fee. + * + * Fix: Update this.config with mergedConfig after successful Helius initialization + * + * This test configures Helius and then verifies that the config is properly applied. + */ + +import fs from 'fs'; +import path from 'path'; + +import { Solana } from '../../../src/chains/solana/solana'; +import { ConfigManagerV2 } from '../../../src/services/config-manager-v2'; + +describe('Helius Configuration Regression Test', () => { + const isTestMode = process.env.GATEWAY_TEST_MODE === 'dev'; + + (isTestMode ? describe : describe.skip)('When Helius provider is configured', () => { + let solana: Solana; + let originalChainConfig: string; + let originalHeliusConfig: string; + const chainConfigPath = path.join(process.cwd(), 'conf/chains/solana.yml'); + const heliusConfigPath = path.join(process.cwd(), 'conf/rpc/helius.yml'); + + beforeAll(async () => { + // Backup original configs + originalChainConfig = fs.existsSync(chainConfigPath) ? fs.readFileSync(chainConfigPath, 'utf8') : ''; + originalHeliusConfig = fs.existsSync(heliusConfigPath) ? fs.readFileSync(heliusConfigPath, 'utf8') : ''; + + // Configure Helius for testing + const chainConfig = `defaultNetwork: mainnet-beta +defaultWallet: 82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5 +rpcProvider: helius +`; + + const heliusConfig = `apiKey: 'test-api-key-for-regression-test' +useWebSocketRPC: false +useSender: false +regionCode: 'slc' +jitoTipSOL: 0.001 +`; + + // Ensure directories exist + fs.mkdirSync(path.dirname(chainConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(heliusConfigPath), { recursive: true }); + + // Write test configs + fs.writeFileSync(chainConfigPath, chainConfig); + fs.writeFileSync(heliusConfigPath, heliusConfig); + + // Clear singletons to force reload with new config + // CRITICAL: Must clear ConfigManagerV2 first so it reloads the configs we just wrote + (ConfigManagerV2 as any)._instance = undefined; + (Solana as any)._instances = {}; + + solana = await Solana.getInstance('mainnet-beta'); + }, 60000); + + afterAll(() => { + // Restore original configs + if (originalChainConfig) { + fs.writeFileSync(chainConfigPath, originalChainConfig); + } + if (originalHeliusConfig) { + fs.writeFileSync(heliusConfigPath, originalHeliusConfig); + } + // Clear singletons to reset state + (ConfigManagerV2 as any)._instance = undefined; + (Solana as any)._instances = {}; + }); + + it('should have useHeliusRestRPC flag set to true in config', () => { + // This is the key assertion - before the fix, this would be undefined + // because the merged config wasn't applied to this.config + expect(solana.config.useHeliusRestRPC).toBe(true); + }); + + it('should have heliusAPIKey in config', () => { + // HeliusService needs this to make API calls + expect(solana.config.heliusAPIKey).toBeDefined(); + expect(solana.config.heliusAPIKey).not.toBe(''); + expect(typeof solana.config.heliusAPIKey).toBe('string'); + }); + + it('should successfully estimate gas price without errors', async () => { + // Verify that estimateGasPrice works and returns a valid fee + const priorityFee = await solana.estimateGasPrice(); + + expect(priorityFee).toBeGreaterThan(0); + expect(typeof priorityFee).toBe('number'); + expect(isNaN(priorityFee)).toBe(false); + }); + + it('should pass complete config to HeliusService', () => { + const heliusService = (solana as any).heliusService; + expect(heliusService).toBeDefined(); + + // HeliusService should have the same config as Solana class + expect(heliusService.config.useHeliusRestRPC).toBe(solana.config.useHeliusRestRPC); + expect(heliusService.config.heliusAPIKey).toBe(solana.config.heliusAPIKey); + }); + }); + + describe('Configuration structure validation', () => { + it('config object should be properly typed', () => { + // This test ensures the config has the expected Helius fields + const config: any = { + nodeURL: 'https://api.mainnet-beta.solana.com', + nativeCurrencySymbol: 'SOL', + useHeliusRestRPC: true, + heliusAPIKey: 'test', + useHeliusWebSocketRPC: false, + useHeliusSender: false, + heliusRegionCode: 'slc', + jitoTipSOL: 0.001, + }; + + // These fields should all be defined + expect(config.useHeliusRestRPC).toBeDefined(); + expect(config.heliusAPIKey).toBeDefined(); + expect(config.useHeliusWebSocketRPC).toBeDefined(); + expect(config.useHeliusSender).toBeDefined(); + expect(config.heliusRegionCode).toBeDefined(); + expect(config.jitoTipSOL).toBeDefined(); + }); + }); +}); diff --git a/test/pools/pool-service.test.ts b/test/pools/pool-service.test.ts index 2f918c9a9a..466b6b285e 100644 --- a/test/pools/pool-service.test.ts +++ b/test/pools/pool-service.test.ts @@ -42,11 +42,14 @@ describe('PoolService', () => { }); describe('validatePool', () => { - it('should validate Solana pool', async () => { + it('should validate Solana pool with new fields', async () => { const pool: Pool = { type: 'amm', baseSymbol: 'SOL', quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, network: 'mainnet-beta', address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }; @@ -59,11 +62,40 @@ describe('PoolService', () => { type: 'amm', baseSymbol: 'SOL', quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, network: 'mainnet-beta', address: 'invalid-address', }; - await expect(poolService.validatePool('raydium', pool)).rejects.toThrow('Invalid Solana pool address'); + await expect(poolService.validatePool('raydium', pool)).rejects.toThrow('Invalid Solana address'); + }); + + it('should reject pool without token addresses', async () => { + const pool: any = { + type: 'amm', + baseSymbol: 'SOL', + quoteSymbol: 'USDC', + network: 'mainnet-beta', + address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', + }; + + await expect(poolService.validatePool('raydium', pool)).rejects.toThrow('Base token address is required'); + }); + + it('should reject pool without fee percentage', async () => { + const pool: any = { + type: 'amm', + baseSymbol: 'SOL', + quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + network: 'mainnet-beta', + address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', + }; + + await expect(poolService.validatePool('raydium', pool)).rejects.toThrow('Fee percentage is required'); }); }); }); diff --git a/test/pools/pools.routes.test.ts b/test/pools/pools.routes.test.ts index 86374b0faf..a017dce580 100644 --- a/test/pools/pools.routes.test.ts +++ b/test/pools/pools.routes.test.ts @@ -63,10 +63,12 @@ describe('Pool Routes Tests', () => { getPool: jest.fn(), addPool: jest.fn(), removePool: jest.fn(), + updatePool: jest.fn(), loadPoolList: jest.fn(), savePoolList: jest.fn(), validatePool: jest.fn(), getPoolByAddress: jest.fn(), + getPoolByMetadata: jest.fn(), getDefaultPools: jest.fn(), } as any; @@ -118,6 +120,9 @@ describe('Pool Routes Tests', () => { network: 'mainnet-beta', baseSymbol: 'SOL', quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }, { @@ -125,6 +130,9 @@ describe('Pool Routes Tests', () => { network: 'mainnet-beta', baseSymbol: 'RAY', quoteSymbol: 'USDC', + baseTokenAddress: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, address: '6UmmUiYoBjSrhakAobJw8BvkmJtDVxaeBtbt7rxWo1mg', }, ]; @@ -148,6 +156,9 @@ describe('Pool Routes Tests', () => { network: 'mainnet-beta', baseSymbol: 'SOL', quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, address: '3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv', }, ]; @@ -171,6 +182,9 @@ describe('Pool Routes Tests', () => { network: 'mainnet-beta', baseSymbol: 'SOL', quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }, ]; @@ -207,6 +221,9 @@ describe('Pool Routes Tests', () => { network: 'mainnet-beta', baseSymbol: 'SOL', quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }; @@ -249,6 +266,8 @@ describe('Pool Routes Tests', () => { describe('POST /pools', () => { it('should add new pool successfully', async () => { mockPoolService.addPool.mockResolvedValue(undefined); + mockPoolService.getPoolByMetadata.mockResolvedValue(null); + mockPoolService.getPoolByAddress.mockResolvedValue(null); const response = await fastify.inject({ method: 'POST', @@ -260,24 +279,45 @@ describe('Pool Routes Tests', () => { baseSymbol: 'WIF', quoteSymbol: 'SOL', address: 'EP2ib6dYdEeqD8MfE2ezHCxX3kP3K2eLKkirfPm5eyMx', + baseTokenAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', + quoteTokenAddress: 'So11111111111111111111111111111111111111112', + feePct: 0.25, }, }); expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toHaveProperty('message'); - expect(JSON.parse(response.payload).message).toContain('Pool WIF-SOL added successfully'); + expect(JSON.parse(response.payload).message).toContain('Pool WIF-SOL'); - expect(mockPoolService.addPool).toHaveBeenCalledWith('raydium', { + // Verify addPool was called with pool data + expect(mockPoolService.addPool).toHaveBeenCalledWith( + 'raydium', + expect.objectContaining({ + type: 'amm', + network: 'mainnet-beta', + baseSymbol: 'WIF', + quoteSymbol: 'SOL', + address: 'EP2ib6dYdEeqD8MfE2ezHCxX3kP3K2eLKkirfPm5eyMx', + baseTokenAddress: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', + quoteTokenAddress: 'So11111111111111111111111111111111111111112', + feePct: 0.25, + }), + ); + }); + + it('should update existing pool with same address', async () => { + mockPoolService.getPoolByMetadata.mockResolvedValue(null); + mockPoolService.getPoolByAddress.mockResolvedValue({ type: 'amm', network: 'mainnet-beta', - baseSymbol: 'WIF', - quoteSymbol: 'SOL', - address: 'EP2ib6dYdEeqD8MfE2ezHCxX3kP3K2eLKkirfPm5eyMx', + baseSymbol: 'SOL', + quoteSymbol: 'USDC', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.25, + address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', }); - }); - - it('should return 400 for duplicate pool', async () => { - mockPoolService.addPool.mockRejectedValue(new Error('Pool with address already exists')); + mockPoolService.updatePool.mockResolvedValue(undefined); const response = await fastify.inject({ method: 'POST', @@ -289,11 +329,15 @@ describe('Pool Routes Tests', () => { baseSymbol: 'SOL', quoteSymbol: 'USDC', address: '58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2', + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.3, }, }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(200); expect(JSON.parse(response.payload)).toHaveProperty('message'); + expect(mockPoolService.updatePool).toHaveBeenCalled(); }); it('should return 400 for missing required fields', async () => {