diff --git a/packages/services/src/lib/config-utils.ts b/packages/services/src/lib/config-utils.ts index 3667e8de6..4073ed576 100644 --- a/packages/services/src/lib/config-utils.ts +++ b/packages/services/src/lib/config-utils.ts @@ -116,7 +116,7 @@ export const specificChainTokens = { base: { eth: "0x4200000000000000000000000000000000000006", // WETH on Base usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Native USDC on Base - usdt: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb", // USDT on Base + usdt: "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2", // Bridged USDT on Base }, svm: { sol: "So11111111111111111111111111111111111111112", @@ -155,6 +155,70 @@ export const specificChainTokens = { }, } as const; +/** + * Token decimals by token type key (as used in specificChainTokens) + * + * These values are blockchain constants that don't change. + * The integration test suite verifies these against on-chain data. + */ +const TOKEN_DECIMALS_BY_TYPE = { + // Stablecoins - 6 decimals + usdc: 6, + usdt: 6, + + // Wrapped native tokens - 18 decimals (standard for ETH-like chains) + eth: 18, // WETH on all EVM chains + matic: 18, // WMATIC on Polygon + avax: 18, // WAVAX on Avalanche + mnt: 18, // WMNT on Mantle + + // Solana native - 9 decimals + sol: 9, +} as const; + +/** + * Known token decimals by normalized (lowercased) address + * + * Built dynamically from specificChainTokens + TOKEN_DECIMALS_BY_TYPE + * to avoid address duplication and keep data in sync. + * + * Used as a reliable fallback when RPC decimals() calls fail, + * preventing incorrect 18-decimal assumptions for non-18 decimal tokens + * (e.g., USDC with 6 decimals being treated as 18 would be off by 10^12). + * + * IMPORTANT: The integration test `rpc-spot-integration.test.ts` validates + * all entries against actual on-chain RPC calls to ensure correctness. + */ +export const KNOWN_TOKEN_DECIMALS: ReadonlyMap = (() => { + const map = new Map(); + + for (const tokens of Object.values(specificChainTokens)) { + for (const [tokenType, address] of Object.entries(tokens)) { + const decimals = + TOKEN_DECIMALS_BY_TYPE[ + tokenType as keyof typeof TOKEN_DECIMALS_BY_TYPE + ]; + if (decimals !== undefined) { + map.set(address.toLowerCase(), decimals); + } + } + } + + return map; +})(); + +/** + * Get known token decimals from the hardcoded map + * + * @param tokenAddress The token contract address (case-insensitive) + * @returns The known decimals, or undefined if token is not in the known list + */ +export function getKnownTokenDecimals( + tokenAddress: string, +): number | undefined { + return KNOWN_TOKEN_DECIMALS.get(tokenAddress.toLowerCase()); +} + /** * Parse EVM chains configuration from environment variable * Used by both API and comps apps to ensure consistent chain configuration diff --git a/packages/services/src/providers/__tests__/alchemy-rpc.provider.test.ts b/packages/services/src/providers/__tests__/alchemy-rpc.provider.test.ts index cf8ece9ae..129af9153 100644 --- a/packages/services/src/providers/__tests__/alchemy-rpc.provider.test.ts +++ b/packages/services/src/providers/__tests__/alchemy-rpc.provider.test.ts @@ -207,18 +207,18 @@ describe("AlchemyRpcProvider", () => { expect(decimals).toBe(18); }); - it("should return 18 for native ETH address", async () => { + it("should throw for native ETH address (not an ERC20 contract)", async () => { if (!process.env.ALCHEMY_API_KEY) { console.log("Skipping test - no API key"); return; } + // Native token address is not an ERC20 contract - calling decimals() is invalid + // Production code (spot-data-processor) checks isNative BEFORE calling getTokenDecimals const ethAddress = "0x0000000000000000000000000000000000000000"; - const decimals = await provider.getTokenDecimals( - ethAddress, - "eth" as SpecificChain, - ); - expect(decimals).toBe(18); + await expect( + provider.getTokenDecimals(ethAddress, "eth" as SpecificChain), + ).rejects.toThrow(); }); }); diff --git a/packages/services/src/providers/__tests__/rpc-spot-integration.test.ts b/packages/services/src/providers/__tests__/rpc-spot-integration.test.ts index 0ac306205..36d7d9ece 100644 --- a/packages/services/src/providers/__tests__/rpc-spot-integration.test.ts +++ b/packages/services/src/providers/__tests__/rpc-spot-integration.test.ts @@ -3,7 +3,11 @@ import path from "path"; import { Logger } from "pino"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { NATIVE_TOKEN_ADDRESS } from "../../lib/config-utils.js"; +import { + KNOWN_TOKEN_DECIMALS, + NATIVE_TOKEN_ADDRESS, + specificChainTokens, +} from "../../lib/config-utils.js"; import { getDexProtocolConfig } from "../../lib/dex-protocols.js"; import type { ProtocolFilter } from "../../types/spot-live.js"; import { AlchemyRpcProvider } from "../spot-live/alchemy-rpc.provider.js"; @@ -1292,4 +1296,127 @@ describe("RpcSpotProvider - Integration Tests (Real Blockchain)", () => { }, 30000, ); + + // =========================================================================== + // KNOWN_TOKEN_DECIMALS VERIFICATION TESTS + // These tests verify that our hardcoded decimals match on-chain reality + // =========================================================================== + + test.skipIf(!process.env.ALCHEMY_API_KEY)( + "KNOWN_TOKEN_DECIMALS should match on-chain decimals for all tokens", + async () => { + console.log( + `\n[DECIMALS VERIFICATION] Verifying ${KNOWN_TOKEN_DECIMALS.size} tokens against on-chain data...`, + ); + + const results: { + address: string; + chain: string; + expected: number; + actual: number | null; + match: boolean; + }[] = []; + + // Build a reverse lookup: address -> { chain, tokenType } + // We need the chain to make the RPC call + const addressToChain = new Map< + string, + { chain: string; tokenType: string } + >(); + for (const [chain, tokens] of Object.entries(specificChainTokens)) { + for (const [tokenType, address] of Object.entries(tokens)) { + addressToChain.set(address.toLowerCase(), { chain, tokenType }); + } + } + + // Verify each token in KNOWN_TOKEN_DECIMALS + for (const [ + address, + expectedDecimals, + ] of KNOWN_TOKEN_DECIMALS.entries()) { + const chainInfo = addressToChain.get(address); + if (!chainInfo) { + console.warn(` ⚠️ No chain info for address ${address}`); + continue; + } + + // Skip Solana tokens - they use a different RPC provider + if (chainInfo.chain === "svm") { + console.log( + ` ⏭️ Skipping ${chainInfo.tokenType} on svm (different RPC)`, + ); + continue; + } + + try { + const actualDecimals = await realRpcProvider.getTokenDecimals( + address, + chainInfo.chain as + | "eth" + | "base" + | "polygon" + | "arbitrum" + | "optimism" + | "avalanche" + | "linea" + | "zksync" + | "scroll" + | "mantle", + ); + + const match = actualDecimals === expectedDecimals; + results.push({ + address, + chain: chainInfo.chain, + expected: expectedDecimals, + actual: actualDecimals, + match, + }); + + if (match) { + console.log( + ` ✓ ${chainInfo.tokenType.toUpperCase()} on ${chainInfo.chain}: ${actualDecimals} decimals`, + ); + } else { + console.error( + ` ❌ ${chainInfo.tokenType.toUpperCase()} on ${chainInfo.chain}: expected ${expectedDecimals}, got ${actualDecimals}`, + ); + } + } catch (error) { + console.error( + ` ❌ Failed to fetch decimals for ${chainInfo.tokenType} on ${chainInfo.chain}: ${error}`, + ); + results.push({ + address, + chain: chainInfo.chain, + expected: expectedDecimals, + actual: null, + match: false, + }); + } + } + + // Summary + const passed = results.filter((r) => r.match).length; + const failed = results.filter((r) => !r.match).length; + console.log(`\n Summary: ${passed} passed, ${failed} failed`); + + // Assert all tokens match + const mismatches = results.filter((r) => !r.match); + if (mismatches.length > 0) { + console.error("\n Mismatched tokens:"); + for (const m of mismatches) { + console.error( + ` ${m.address} (${m.chain}): expected ${m.expected}, got ${m.actual}`, + ); + } + } + + expect(mismatches).toHaveLength(0); + console.log( + `\n✓ All ${passed} token decimals verified against on-chain data`, + ); + }, + 120000, // 2 minute timeout for multiple RPC calls + ); }); diff --git a/packages/services/src/providers/__tests__/rpc-spot.provider.test.ts b/packages/services/src/providers/__tests__/rpc-spot.provider.test.ts index 4fe1b7b2c..f578d4a50 100644 --- a/packages/services/src/providers/__tests__/rpc-spot.provider.test.ts +++ b/packages/services/src/providers/__tests__/rpc-spot.provider.test.ts @@ -1619,4 +1619,131 @@ describe("RpcSpotProvider", () => { expect(mockRpcProvider.getTransactionReceipt).not.toHaveBeenCalled(); }); }); + + describe("decimals fallback and rejection", () => { + const TRANSFER_TOPIC = + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + const WALLET = "0xwallet0000000000000000000000000000000000"; + const ROUTER = "0xrouter0000000000000000000000000000000000"; + // Unknown token addresses NOT in KNOWN_TOKEN_DECIMALS + const UNKNOWN_TOKEN_A = "0xunknowntokena00000000000000000000000000"; + const UNKNOWN_TOKEN_B = "0xunknowntokenb00000000000000000000000000"; + + beforeEach(() => { + provider = new RpcSpotProvider(mockRpcProvider, [], mockLogger); + mockRpcProvider.getBlockNumber.mockResolvedValue(1000000); + }); + + it("should reject swap when decimals cannot be determined for unknown tokens", async () => { + // Setup: Alchemy returns null values (can't determine decimals) + const unknownTokenTransfers = [ + createMockTransfer({ + from: WALLET, + to: ROUTER, + value: null, // Alchemy couldn't determine value + asset: "UNKNOWN_A", + hash: "0xtxhash_unknown", + blockNum: "0xf4240", + metadata: { blockTimestamp: "2025-01-15T10:00:00.000Z" }, + rawContract: { + address: UNKNOWN_TOKEN_A, + decimal: null, // Alchemy couldn't determine decimals + value: "0x5f5e100", // Raw value exists + }, + category: AssetTransfersCategory.ERC20, + uniqueId: "unique1", + }), + createMockTransfer({ + from: ROUTER, + to: WALLET, + value: null, // Alchemy couldn't determine value + asset: "UNKNOWN_B", + hash: "0xtxhash_unknown", + blockNum: "0xf4240", + metadata: { blockTimestamp: "2025-01-15T10:00:00.000Z" }, + rawContract: { + address: UNKNOWN_TOKEN_B, + decimal: null, + value: "0x2540be400", // Raw value exists + }, + category: AssetTransfersCategory.ERC20, + uniqueId: "unique2", + }), + ]; + + mockRpcProvider.getAssetTransfers.mockResolvedValue({ + transfers: unknownTokenTransfers, + pageKey: undefined, + }); + + // RPC getTokenDecimals fails for unknown tokens + mockRpcProvider.getTokenDecimals.mockRejectedValue( + new Error("Invalid decimals response from RPC: 0x"), + ); + + // Provide receipt with transfer logs + mockRpcProvider.getTransactionReceipt.mockResolvedValue({ + transactionHash: "0xtxhash_unknown", + blockNumber: 1000000, + gasUsed: "100000", + effectiveGasPrice: "50000000000", + status: true, + from: WALLET, + to: ROUTER, + logs: [ + { + address: UNKNOWN_TOKEN_A, + topics: [ + TRANSFER_TOPIC, + "0x" + WALLET.slice(2).padStart(64, "0"), + "0x" + ROUTER.slice(2).padStart(64, "0"), + ], + data: "0x" + BigInt("100000000").toString(16).padStart(64, "0"), + logIndex: 0, + blockNumber: 1000000, + blockHash: "0xblockhash", + transactionIndex: 0, + transactionHash: "0xtxhash_unknown", + removed: false, + }, + { + address: UNKNOWN_TOKEN_B, + topics: [ + TRANSFER_TOPIC, + "0x" + ROUTER.slice(2).padStart(64, "0"), + "0x" + WALLET.slice(2).padStart(64, "0"), + ], + data: "0x" + BigInt("10000000000").toString(16).padStart(64, "0"), + logIndex: 1, + blockNumber: 1000000, + blockHash: "0xblockhash", + transactionIndex: 0, + transactionHash: "0xtxhash_unknown", + removed: false, + }, + ], + }); + + const result = await provider.getTradesSince(WALLET, 999990, ["base"]); + + // Swap should be rejected because decimals couldn't be determined + expect(result.trades).toHaveLength(0); + + // Verify that getTokenDecimals was called (attempted to fetch) + expect(mockRpcProvider.getTokenDecimals).toHaveBeenCalled(); + + // Verify warning was logged about missing decimals for the token that caused rejection + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: expect.any(String), + chain: "base", + token: expect.any(String), + }), + expect.stringContaining("Cannot determine decimals"), + ); + }); + + // Note: The KNOWN_TOKEN_DECIMALS fallback behavior is tested by the integration test + // in rpc-spot-integration.test.ts which verifies all 25 known tokens against on-chain data + }); }); diff --git a/packages/services/src/providers/spot-live/alchemy-rpc.provider.ts b/packages/services/src/providers/spot-live/alchemy-rpc.provider.ts index 4baef053b..0768dfc64 100644 --- a/packages/services/src/providers/spot-live/alchemy-rpc.provider.ts +++ b/packages/services/src/providers/spot-live/alchemy-rpc.provider.ts @@ -849,8 +849,12 @@ export class AlchemyRpcProvider implements IRpcProvider { } } - // Default to 18 if call fails (common for ETH/WETH) - return 18; + // Throw error to trigger retry - empty/invalid response suggests transient issue + // Previously this defaulted to 18, which caused incorrect amounts for + // non-18 decimal tokens (e.g., USDC with 6 decimals) + throw new Error( + `Invalid decimals response from RPC: ${result || "empty"}`, + ); }, { maxRetries: this.maxRetries, @@ -862,14 +866,12 @@ export class AlchemyRpcProvider implements IRpcProvider { return result; } catch (error) { this.logger.warn( - `[AlchemyRpcProvider] Failed to fetch decimals for token ${tokenAddress} on ${chain}, defaulting to 18`, + { tokenAddress, chain, error }, + `[AlchemyRpcProvider] Failed to fetch decimals after retries`, ); - if (error instanceof Error) { - this.logger.debug( - `[AlchemyRpcProvider] Decimals fetch error: ${error.message}`, - ); - } - return 18; + // Re-throw so calling code can use KNOWN_TOKEN_DECIMALS fallback + // or reject the operation for unknown tokens + throw error; } } diff --git a/packages/services/src/providers/spot-live/rpc-spot.provider.ts b/packages/services/src/providers/spot-live/rpc-spot.provider.ts index a1310e7cc..59837e142 100644 --- a/packages/services/src/providers/spot-live/rpc-spot.provider.ts +++ b/packages/services/src/providers/spot-live/rpc-spot.provider.ts @@ -3,7 +3,10 @@ import { AssetTransfersWithMetadataResult } from "alchemy-sdk"; import { Logger } from "pino"; import { formatTokenAmount } from "../../lib/blockchain-math-utils.js"; -import { NATIVE_TOKEN_ADDRESS } from "../../lib/config-utils.js"; +import { + NATIVE_TOKEN_ADDRESS, + getKnownTokenDecimals, +} from "../../lib/config-utils.js"; import { SpecificChain } from "../../types/index.js"; import { IRpcProvider, TransactionReceipt } from "../../types/rpc.js"; import { @@ -540,6 +543,13 @@ export class RpcSpotProvider implements ISpotLiveDataProvider { { txHash, token: swap.fromToken, amount }, "[RpcSpotProvider] Calculated fromAmount from receipt log (Alchemy returned null)", ); + } else { + // Cannot determine decimals for unknown token - reject swap to prevent incorrect amounts + this.logger.warn( + { txHash, chain, token: swap.fromToken }, + "[RpcSpotProvider] Cannot determine decimals for outbound token - rejecting swap", + ); + continue; } } } @@ -564,6 +574,13 @@ export class RpcSpotProvider implements ISpotLiveDataProvider { { txHash, token: swap.toToken, amount }, "[RpcSpotProvider] Calculated toAmount from receipt log (Alchemy returned null)", ); + } else { + // Cannot determine decimals for unknown token - reject swap to prevent incorrect amounts + this.logger.warn( + { txHash, chain, token: swap.toToken }, + "[RpcSpotProvider] Cannot determine decimals for inbound token - rejecting swap", + ); + continue; } } } @@ -1015,7 +1032,7 @@ export class RpcSpotProvider implements ISpotLiveDataProvider { return 0; } - // Fetch actual decimals from chain + // Fetch actual decimals from chain, with fallback to known token decimals try { const decimals = await this.rpcProvider.getTokenDecimals( tokenAddress, @@ -1024,13 +1041,26 @@ export class RpcSpotProvider implements ISpotLiveDataProvider { // Use formatTokenAmount for precision-safe BigInt to decimal conversion return parseFloat(formatTokenAmount(rawValue.toString(), decimals)); } catch (error) { - // If decimals fetch fails, fall back to 18 (most common) - this.logger.warn( + // RPC failed - try known token decimals as fallback + const knownDecimals = getKnownTokenDecimals(tokenAddress); + if (knownDecimals !== undefined) { + this.logger.warn( + { tokenAddress, chain, knownDecimals, error }, + "[RpcSpotProvider] RPC decimals fetch failed, using known token decimals", + ); + return parseFloat( + formatTokenAmount(rawValue.toString(), knownDecimals), + ); + } + + // Unknown token with failed RPC - reject to prevent incorrect amounts + // This is safer than guessing 18 decimals (e.g., USDC has 6 decimals, + // using 18 would make amounts 10^12 times too small) + this.logger.error( { tokenAddress, chain, error }, - "[RpcSpotProvider] Failed to fetch decimals, falling back to 18", + "[RpcSpotProvider] Failed to fetch decimals for unknown token - rejecting", ); - // Use formatTokenAmount for precision-safe BigInt to decimal conversion - return parseFloat(formatTokenAmount(rawValue.toString(), 18)); + return null; } } @@ -1074,8 +1104,29 @@ export class RpcSpotProvider implements ISpotLiveDataProvider { // 1. Validation: reject 0-value swaps before returning // 2. Fallback: used if getAssetTransfers doesn't have matching tokens (edge case safety net) // In normal flow, amounts are overridden by getAssetTransfers values which are pre-adjusted - const fromDecimals = tokenDecimals.get(outbound.tokenAddress) ?? 18; - const toDecimals = tokenDecimals.get(inbound.tokenAddress) ?? 18; + const fromDecimals = + tokenDecimals.get(outbound.tokenAddress) ?? + getKnownTokenDecimals(outbound.tokenAddress); + const toDecimals = + tokenDecimals.get(inbound.tokenAddress) ?? + getKnownTokenDecimals(inbound.tokenAddress); + + // Reject swap if we can't determine decimals for either token + // This prevents incorrect amount calculations (e.g., USDC with 6 decimals + // being treated as 18 would be off by 10^12) + if (fromDecimals === undefined || toDecimals === undefined) { + this.logger.warn( + { + txHash: receipt.transactionHash, + fromToken: outbound.tokenAddress, + toToken: inbound.tokenAddress, + hasFromDecimals: fromDecimals !== undefined, + hasToDecimals: toDecimals !== undefined, + }, + "[RpcSpotProvider] Cannot determine decimals for swap tokens - rejecting", + ); + return null; + } // Use formatTokenAmount for precision-safe BigInt to decimal conversion const fromAmount = parseFloat(