Skip to content

fix(services): prevent incorrect token decimals fallback causing amount calculation errors#1712

Open
mzkrasner wants to merge 2 commits intomainfrom
mzk/fix-decimal-fallback
Open

fix(services): prevent incorrect token decimals fallback causing amount calculation errors#1712
mzkrasner wants to merge 2 commits intomainfrom
mzk/fix-decimal-fallback

Conversation

@mzkrasner
Copy link
Contributor

@mzkrasner mzkrasner commented Jan 26, 2026

Summary

Fixes a critical bug where failed Alchemy RPC calls would silently return 18 decimals as default, causing severe calculation errors for tokens with different decimals (e.g., USDC with 6 decimals would have amounts off by 10^12).

Original Incident

Competition: a4d9fae6-e169-47c7-a261-eb9a397a6143
Agent: grok 4 vision (8359ab64-2f9c-4db0-9d02-16d6d2b54b32)
Transaction: 0x5a817fb0e6bfa035638f94297368f679066378ef9ee77b50542fdb0c7a3b08ca
Timestamp: 2026-01-16 15:16:03 UTC

On-Chain Reality (from Basescan)

  • Swap: 119.198739 USDC → 0.036035 WETH (~$118 swap)
  • Protocol: Aerodrome SlipStream Swap Router

What Our System Recorded

Field Recorded Value Actual Value Error Factor
from_amount 0.000000000119198739 USDC 119.198739 USDC 10^12 off
to_amount 0.03603504585385052 WETH 0.036035 WETH ✓ Correct
price 302,310,629.76 ~0.0003 Inverted & wrong

Impact

  • System credited 0.036 WETH ($120) while only debiting ~0 USDC
  • Net effect: ~$120 artificial portfolio inflation
  • Agent appeared at top of leaderboard with false ROI

Root Cause

The Alchemy provider getTokenDecimals returned 18 without throwing when RPC returned empty data:

  1. Retries never triggered (no error was thrown)
  2. Calling code incorrectly assumed it received valid decimals
  3. USDC (6 decimals) was treated as 18 decimal token
  4. Raw value 119198739 divided by 10^18 = 0.000000000119198739 (should be 10^6 = 119.198739)

The Bug Path

1. Alchemy getAssetTransfers returns null value for USDC transfer
2. Code falls back to calculateAmountFromReceiptLog()
3. getTokenDecimals() RPC call returns empty ("0x") - transient failure
4. Code silently returns 18 decimals (no error thrown, no retry)
5. Raw value 119198739 / 10^18 = 0.000000000119198739
   (Should be / 10^6 for USDC = 119.198739)

Changes

alchemy-rpc.provider.ts

  • Throw error on invalid RPC response instead of silently returning 18
  • Triggers existing retry logic and propagates failures to caller

config-utils.ts

  • Added KNOWN_TOKEN_DECIMALS map derived from specificChainTokens
  • Contains verified decimals: USDC (6), USDT (6), WETH (18), WMATIC (18), WAVAX (18), WMNT (18), SOL (9)
  • Fixed incorrect Base USDT address - was DAI (18 decimals), now bridged USDT (6 decimals)

rpc-spot.provider.ts

  • Resilient fallback: RPC with retries → KNOWN_TOKEN_DECIMALS → reject swap
  • Unknown tokens now reject instead of guessing 18 decimals

rpc-spot-integration.test.ts

  • Added test verifying all 25 token decimals against on-chain Alchemy RPC

alchemy-rpc.provider.test.ts

  • Updated test for native ETH address to expect error (not an ERC20 contract)

The New Flow

1. Try RPC with retries (circuit breaker + exponential backoff)
   ↓ fails
2. Use KNOWN_TOKEN_DECIMALS lookup (USDC=6, USDT=6, WETH=18, etc.)
   ↓ not found
3. REJECT the swap (safer than guessing 18)

Test plan

  • All 150 RpcSpotProvider/AlchemyRpcProvider tests pass
  • All 25 known token decimals verified against on-chain data
  • CI pipeline tests

Database Investigation

To find potentially affected trades in the database:

SELECT t.id, t.competition_id, t.agent_id, t.from_token, t.to_token,
       t.from_amount, t.to_amount, t.tx_hash, t.timestamp
FROM trades t
WHERE (t.from_token ILIKE '%USDC%' OR t.from_token ILIKE '%USDT%')
  AND t.from_amount < 0.0001
  AND t.to_amount > 0.01
ORDER BY t.timestamp DESC;

🤖 Generated with Claude Code

…nt calculation errors

Previously, when Alchemy RPC failed to fetch token decimals, the code silently
returned 18 decimals as a default. This caused severe calculation errors for
tokens with different decimals (e.g., USDC with 6 decimals would have amounts
off by 10^12).

Root cause: The Alchemy provider's getTokenDecimals returned 18 without throwing
when RPC returned empty data, meaning retries never triggered and the calling
code incorrectly assumed it received valid decimals.

Changes:
- alchemy-rpc.provider.ts: Throw error on invalid RPC response to trigger retries
  and propagate failure to caller instead of silently returning 18
- config-utils.ts: Add KNOWN_TOKEN_DECIMALS map with verified decimals for all
  tokens in specificChainTokens (USDC=6, USDT=6, WETH=18, etc.)
- config-utils.ts: Fix incorrect Base USDT address (was DAI with 18 decimals)
- rpc-spot.provider.ts: Use KNOWN_TOKEN_DECIMALS as fallback when RPC fails,
  reject swap for unknown tokens instead of guessing 18
- rpc-spot-integration.test.ts: Add test verifying all 25 token decimals
  against on-chain data via Alchemy RPC

The new flow:
1. Try RPC with retries (existing circuit breaker + retry logic)
2. If fails → use KNOWN_TOKEN_DECIMALS lookup
3. If unknown token → reject the swap (safer than guessing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings January 26, 2026 22:25
@vercel
Copy link
Contributor

vercel bot commented Jan 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
comps Ready Ready Preview, Comment Jan 26, 2026 10:55pm

@github-actions
Copy link
Contributor

github-actions bot commented Jan 26, 2026

📊 Test Coverage Report

Package Lines Statements Functions Branches
apps/api 2.50% 2.50% 43.70% 51.74%
apps/comps 0.26% 0.26% 37.66% 39.70%
packages/conversions 100.00% 100.00% 100.00% 100.00%
packages/db 4.27% 4.27% 20.86% 37.11%
packages/rewards 100.00% 100.00% 100.00% 100.00%
packages/services 51.59% (+0.21%) 51.59% (+0.21%) 64.88% (+0.34%) 79.09% (+0.05%)
packages/staking-contracts 100.00% 100.00% 100.00% 100.00%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens token-decimal handling to prevent incorrect 18-decimal fallbacks from corrupting swap amount calculations, especially for tokens like USDC/USDT with 6 decimals. It changes the Alchemy RPC provider to fail loudly on invalid decimals responses and introduces a curated, tested map of known token decimals that RpcSpotProvider uses as a safe fallback before ultimately rejecting ambiguous swaps.

Changes:

  • Updated AlchemyRpcProvider.getTokenDecimals to throw on empty/invalid RPC results, leveraging retries and propagating failures instead of silently defaulting to 18 decimals.
  • Introduced TOKEN_DECIMALS_BY_TYPE, KNOWN_TOKEN_DECIMALS, and getKnownTokenDecimals derived from specificChainTokens, plus fixed the Base USDT address to the correct bridged USDT contract.
  • Updated RpcSpotProvider swap detection and receipt-based amount calculation to use KNOWN_TOKEN_DECIMALS after RPC failures and to reject swaps when decimals cannot be reliably determined, with new integration and provider tests to validate behavior and known-decimal correctness.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/services/src/providers/spot-live/rpc-spot.provider.ts Adds KNOWN_TOKEN_DECIMALS-based fallback and rejection logic in receipt-based amount calculation and swap detection, ensuring swaps are only accepted when token decimals are known.
packages/services/src/providers/spot-live/alchemy-rpc.provider.ts Changes getTokenDecimals to treat empty/invalid RPC responses as errors (with retries and structured logging) and rethrow, removing the unsafe 18-decimal default.
packages/services/src/providers/__tests__/rpc-spot-integration.test.ts Adds an integration test (skipped without API key) to verify all entries in KNOWN_TOKEN_DECIMALS against on-chain decimals, ensuring the hardcoded map stays accurate over time.
packages/services/src/providers/__tests__/alchemy-rpc.provider.test.ts Adjusts decimals tests to match new behavior, including asserting that native ETH (zero address) now causes getTokenDecimals to throw as it is not an ERC20.
packages/services/src/lib/config-utils.ts Fixes the Base USDT address, defines TOKEN_DECIMALS_BY_TYPE, builds KNOWN_TOKEN_DECIMALS from specificChainTokens, and exposes getKnownTokenDecimals for safe, centralized decimal fallbacks.

Comment on lines +1093 to +1115
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;
}
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.

The updated detectSwapFromReceiptLogs logic now rejects swaps when decimals cannot be determined for either token (falling back to getKnownTokenDecimals before giving up), but there are no tests asserting this rejection behavior. To guard against regressions in this safety check, it would be helpful to add a unit test that constructs a receipt with ERC20 transfers for tokens absent from tokenDecimals and KNOWN_TOKEN_DECIMALS, and verifies that the method returns null and emits the expected warning log (optional but recommended).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the suggestion! I've added a unit test in commit 501d699 that verifies the rejection behavior when decimals cannot be determined for unknown tokens.

The test:

  1. Sets up transfers with unknown tokens (addresses not in KNOWN_TOKEN_DECIMALS)
  2. Mocks getTokenDecimals to fail (simulating RPC failure)
  3. Verifies the swap is rejected (returns 0 trades)
  4. Verifies the warning is logged

I also fixed the rejection logic in rpc-spot.provider.ts - previously when calculateAmountFromReceiptLog returned null, the code would silently continue with an incorrect amount. Now it properly rejects the swap with a warning log.

Add unit test to verify that swaps are rejected when decimals cannot
be determined for unknown tokens (RPC fails and token not in KNOWN_TOKEN_DECIMALS).

Also fix the rejection logic in rpc-spot.provider to actually reject
swaps when calculateAmountFromReceiptLog returns null, instead of
silently continuing with an incorrect amount calculated using 0 decimals.

This ensures the full fallback chain works correctly:
1. Try RPC with retries
2. Fall back to KNOWN_TOKEN_DECIMALS
3. Reject swap if both fail (prevents incorrect amounts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment on lines +216 to +220
export function getKnownTokenDecimals(
tokenAddress: string,
): number | undefined {
return KNOWN_TOKEN_DECIMALS.get(tokenAddress.toLowerCase());
}
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.
Comment on lines +1045 to +1053
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),
);
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.

The new fallback path that uses getKnownTokenDecimals(tokenAddress) when rpcProvider.getTokenDecimals throws does not appear to be covered by tests: existing tests either let getTokenDecimals succeed or, in the new "decimals fallback and rejection" case, make it fail for an unknown token so the fallback isn’t exercised. Given this branch is the safety net for transient RPC failures on known tokens, consider adding a unit test that simulates getTokenDecimals rejecting for a token present in KNOWN_TOKEN_DECIMALS and asserts that the amount is computed via the fallback and an appropriate warning is logged.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant