Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion packages/services/src/lib/config-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, number> = (() => {
const map = new Map<string, number>();

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());
}
Comment on lines +216 to +220
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a second exported getKnownTokenDecimals helper (the other lives in lib/blockchain-math-utils.ts and is keyed by token symbol), but the two functions have different input semantics (address vs symbol). Having two identically named helpers with different expectations is likely to cause confusion or incorrect imports in the future; consider renaming this address-based variant (e.g., getKnownTokenDecimalsByAddress) or consolidating known-decimals logic into a single shared utility.

Copilot uses AI. Check for mistakes.

/**
* Parse EVM chains configuration from environment variable
* Used by both API and comps apps to ensure consistent chain configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
);
});
127 changes: 127 additions & 0 deletions packages/services/src/providers/__tests__/rpc-spot.provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
Loading
Loading