Skip to content

Conversation

@katspaugh
Copy link
Member

@katspaugh katspaugh commented Dec 15, 2025

Resolves https://linear.app/safe-global/issue/WA-1216/enable-zerion-balances-globally-with-dynamic-chain-discovery

Summary

  • Converts FF_ZERION_BALANCES_CHAIN_IDS from a per-chain allowlist to a global enable/disable flag
  • Fetches Zerion's supported chains dynamically on first request
  • Removes hardcoded chain list entirely - zero maintenance burden
  • Enables support for all 40+ Zerion chains automatically

Changes

Configuration

Before:

FF_ZERION_BALANCES_CHAIN_IDS=1,10,137,42161,...  # Comma-separated chain IDs

After:

FF_ZERION_BALANCES_CHAIN_IDS=true  # Any non-empty value enables Zerion globally
# Or leave unset/empty to disable

Backwards Compatibility

Zero breaking changes! Existing configurations with chain ID lists will continue to work:

  • Old format: FF_ZERION_BALANCES_CHAIN_IDS=1,10,137Zerion enabled globally
  • New format: FF_ZERION_BALANCES_CHAIN_IDS=trueZerion enabled globally
  • Disabled: FF_ZERION_BALANCES_CHAIN_IDS= or unset → Zerion disabled

The feature flag now uses truthy checking (!!process.env.FF_ZERION_BALANCES_CHAIN_IDS), so any non-empty value enables it.

Dynamic Chain Fetching

  • Lazy initialization: Chains fetched on first balance/collectibles request
  • In-memory storage: Mappings stored for service lifetime
  • Concurrent handling: Multiple simultaneous requests handled gracefully during initial fetch
  • No ongoing overhead: After first fetch, lookups are from memory (zero cost)
  • No fallback needed: Service properly fails if Zerion API unavailable

Chain Resolution Priority

  1. chain.balancesProvider.chainName (from Safe Config API) - if configured
  2. Dynamic fetch from Zerion (fetched once on first request, cached in memory)

Balances API Manager

  • Simplified from checking if (chainId in array) to if (zerionBalancesEnabled)
  • Works on all chains when enabled

Benefits

  • No breaking changes: Existing deployments work without config updates
  • Zero maintenance: Hardcoded chain list completely removed
  • 40+ chains supported: Automatic support for all Zerion chains
  • Low cost: Single API call per network type (mainnet/testnet) on first request
  • Fast: No startup delay, no per-request overhead after initial fetch
  • Clean: No fallback code, simpler logic

Performance Impact

  • Startup: Zero impact (lazy initialization)
  • First request: +1 API call to Zerion per network type
  • Subsequent requests: Zero overhead (in-memory lookup)
  • Total API calls: 2 per service restart (mainnet + testnet)

Migration Path

For chains not in old hardcoded list (e.g., Linea, Blast):

  • Option 1: Let Zerion API provide chain name automatically (preferred)
  • Option 2: Add balancesProvider.chainName to Safe Config for that chain

Test plan

  • Updated all test files to use new boolean flag behavior
  • Verify Zerion balances work on chains not in old hardcoded list
  • Test first request triggers chain fetch
  • Confirm subsequent requests use in-memory cache
  • Verify proper error if chain not supported by Zerion
  • Test with various flag values: true, 1,10,137, empty, unset

🤖 Generated with Claude Code


Note

Introduces a global toggle for Zerion and removes chain-specific allowlists by discovering supported chains at runtime.

  • Config/Flags: Replace features.zerionBalancesChainIds with features.zerionBalancesEnabled; derive from FF_ZERION_ENABLED or legacy FF_ZERION_BALANCES_CHAIN_IDS. Update .env.sample accordingly.
  • Balances routing: BalancesApiManager now selects ZerionBalancesApi when zerionBalancesEnabled is true; retains counterfactual fallback logic otherwise.
  • Dynamic chain discovery: ZerionBalancesApi fetches /v1/chains (mainnet/testnet) on first request, caches mappings in memory, validates external_id hex, and errors if a chain is unsupported. Removes static chain config.
  • Collectibles/balances: Resolve chain name via chain.balancesProvider.chainName or dynamic mapping; include testnet header via getZerionHeaders.
  • Tests: Update specs to use the new flag and dynamic behavior; add unsupported-chain error case; simplify chain ID usage.

Written by Cursor Bugbot for commit fcd4a5d. This will update automatically on new commits. Configure here.

Replace per-chain allowlist with global on/off switch for Zerion balances.
The balances API now dynamically fetches supported chains from Zerion's API,
eliminating the need to maintain hardcoded chain lists.

Changes:
- Replace FF_ZERION_BALANCES_CHAIN_IDS with FF_ZERION_BALANCES_ENABLED
- Add dynamic chain fetching to ZerionBalancesApi (similar to portfolio API)
- Fetch and cache chain mappings from Zerion's /v1/chains endpoint
- Update BalancesApiManager to use boolean flag instead of chain ID array
- Update all tests to reflect new configuration structure

This enables Zerion on all 40+ supported chains with a single flag, instead
of maintaining a hardcoded list of 14 chains.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@katspaugh katspaugh requested a review from a team as a code owner December 15, 2025 13:25
katspaugh and others added 2 commits December 15, 2025 14:28
Instead of introducing a new environment variable, reuse the existing
FF_ZERION_BALANCES_CHAIN_IDS but change its behavior to accept a boolean
value (true/false) instead of a comma-separated list of chain IDs.

This maintains backwards compatibility and simplifies deployment:
- No new environment variables needed
- Existing variable name preserved
- Old format (chain IDs) implicitly disabled (evaluates to false)
- New format: FF_ZERION_BALANCES_CHAIN_IDS=true

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Check if FF_ZERION_BALANCES_CHAIN_IDS is set to any value (truthy)
instead of specifically checking for 'true'. This provides:

- Better backwards compatibility: existing chain ID lists automatically enable Zerion
- Simpler logic: any non-empty value enables the feature
- No breaking changes: deployments with the old format continue working

Usage:
- FF_ZERION_BALANCES_CHAIN_IDS=true (enabled)
- FF_ZERION_BALANCES_CHAIN_IDS=1,10,137 (enabled, old format)
- FF_ZERION_BALANCES_CHAIN_IDS= (disabled)
- Unset (disabled)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@katspaugh katspaugh marked this pull request as draft December 15, 2025 13:30
katspaugh and others added 4 commits December 15, 2025 14:31
Remove dynamic Zerion chain fetching from _getChainName to prevent:
- Extra cache lookups on every balance/collectibles request
- Increased API usage and costs
- Unnecessary async overhead

Chain names are now resolved from:
1. chain.balancesProvider.chainName (from Safe Config API)
2. Hardcoded chainsConfiguration (fallback)

New chains can be added to Safe Config's chain configuration instead
of relying on dynamic Zerion API calls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implements lazy initialization to fetch chain mappings from Zerion's API:
- Fetches chains on first balance/collectibles request
- Stores mappings in memory for the lifetime of the service
- Handles concurrent requests during initial fetch
- Falls back to minimal hardcoded list if fetch fails

Benefits:
- No hardcoded chain list maintenance (reduced from 14 to 3 fallback chains)
- Automatic support for all 40+ Zerion chains
- No startup delay (fetches only when needed)
- No per-request overhead (in-memory after first fetch)
- Single API call per network type (mainnet/testnet)

Chain resolution priority:
1. chain.balancesProvider.chainName (from Safe Config)
2. Dynamically fetched mapping from Zerion API
3. Hardcoded fallback (ethereum, polygon, arbitrum)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Remove the hardcoded chains configuration and fallback logic:
- Deleted chains object from zerion config
- Removed chainsConfiguration property and import
- Simplified chain resolution to rely fully on dynamic fetch

Chain resolution now:
1. chain.balancesProvider.chainName (from Safe Config) - if set
2. Dynamically fetched from Zerion API - all other chains

This completes the goal of removing maintenance burden of hardcoded
chain lists. If Zerion API is unavailable, the service will properly
fail rather than partially work with a limited fallback list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Remove unused 'sample' import from balances-api.manager.spec.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@katspaugh katspaugh marked this pull request as ready for review December 15, 2025 13:58
katspaugh and others added 3 commits December 15, 2025 15:07
- Remove chains object from test configuration
- Update zerion-balances-api tests to reflect new dynamic chain fetching
- Replace static chain fallback test with error case test for unsupported chains
- Add warn method to mock logging service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Set zerionBalancesEnabled: false in default test configuration to allow most tests to use SafeBalancesApi behavior
- Override zerionBalancesEnabled to true in Zerion-specific tests (zerion-collectibles.controller.spec.ts)
- This fixes failing tests that expected new API instances after cache clearing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@katspaugh
Copy link
Member Author

@claude review

@claude
Copy link

claude bot commented Dec 29, 2025

Claude finished @katspaugh's task —— View job


PR Review: Zerion Global Balances Feature

Todo List:

  • Analyze configuration changes
  • Review Zerion service implementation
  • Check balances API manager changes
  • Examine test coverage and quality
  • Verify error handling and edge cases
  • Check for security concerns
  • Assess performance implications

Code Review Findings

🛠️ Issues to Address

1. Race Condition in Chain Mapping Initialization

Location: src/modules/balances/datasources/zerion-balances-api.service.ts:339-357

The concurrent handling during chain mapping fetch has a potential race condition:

private async _ensureChainMappings(): Promise<void> {
  // If already fetched, return immediately
  if (
    Object.keys(this.chainMappings.mainnet).length > 0 ||
    Object.keys(this.chainMappings.testnet).length > 0
  ) {
    return;
  }

Problem: The condition checks if either mainnet OR testnet has mappings, but the fetch process could fail for one network while succeeding for the other. This could cause the service to think mappings are complete when only partial data exists.

Fix: Change the logic to track completion status separately or ensure both networks are properly fetched.

2. Error Handling Inconsistency

Location: src/modules/balances/datasources/zerion-balances-api.service.ts:359-378

try {
  // Fetch mainnet chains
  const mainnetMapping = await this._fetchChainMappingForNetwork(false);
  this.chainMappings.mainnet = mainnetMapping;
} catch (error) {
  this.loggingService.warn(
    `Failed to fetch mainnet chains from Zerion: ${error}`,
  );
}

Problem: If chain fetching fails for both networks, the service silently continues with empty mappings, leading to runtime failures later when _getChainName() is called.

Fix: Consider throwing an error if both network fetches fail, or implement a retry mechanism.

3. Potential Memory Leak

Location: src/modules/balances/datasources/zerion-balances-api.service.ts:60-69

The chain mappings are stored in memory indefinitely without any cleanup mechanism. In a long-running service, if Zerion adds many new chains, this could lead to memory bloat.

Recommendation: Consider implementing a TTL mechanism or periodic refresh strategy.

4. Missing Input Validation

Location: src/modules/balances/datasources/zerion-balances-api.service.ts:396-399

const decimalChainId = hexToNumber(
  chain.attributes.external_id as Hex,
).toString();

Problem: No validation that external_id is a valid hex string before conversion.

Fix: Add validation or proper error handling for malformed external IDs.

Performance Considerations

  1. API Call Optimization: The implementation correctly batches chain fetching (2 calls total vs potential per-request calls) ✅

  2. Lazy Loading: Good implementation of lazy initialization to avoid startup delays ✅

  3. Caching: Proper use of in-memory storage after initial fetch ✅

🔒 Security Assessment

No security vulnerabilities identified. The implementation properly:

  • Uses configuration service for API keys
  • Validates input through Zod schemas
  • Handles rate limiting appropriately

📊 Test Coverage

Needs Improvement: The test files show updated boolean flag behavior, but missing tests for:

  • Chain mapping fetch failure scenarios
  • Concurrent request handling during initialization
  • Backwards compatibility with old chain ID format
  • Error cases when chain not found in mappings

Positive Aspects

  • Clean Architecture: Well-separated concerns between configuration, service, and manager layers
  • Backwards Compatibility: Elegant handling of old vs new configuration format
  • Performance: Smart caching and lazy loading implementation
  • Code Quality: Good use of TypeScript types and proper error handling patterns

Overall Assessment: Solid implementation with good architectural decisions. The main concerns are around edge case error handling and potential race conditions during initialization. Consider addressing the race condition issue and improving error resilience for production deployment.


@katspaugh katspaugh requested review from LucieFaire and vseehausen and removed request for LucieFaire December 29, 2025 07:56
katspaugh and others added 2 commits December 29, 2025 09:14
- Fix race condition by tracking initialization status separately
- Improve error handling to fail fast when both network fetches fail
- Add input validation for external_id hex conversion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

@vseehausen vseehausen left a comment

Choose a reason for hiding this comment

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

Nice one!

I would vouch got renaming the env variable to "zerion enabled", like internally, so the env variable name is precise.

Also, there should be logic to fetch and cache chain configuration that could be reused.

Last comment is that the Zerion Balance endpoint is not used in production currently. We could think about removing it.

None of this is blocking for me, but the first comment is a more-than-nice-to-have.

@katspaugh
Copy link
Member Author

I would vouch got renaming the env variable to "zerion enabled", like internally, so the env variable name is precise.

I kept the variable name the same to avoid migrating the existing one, but it's a valid point for clarity. I'll rename it.

Renames the environment variable to be more precise about its purpose,
as requested in PR review. The variable now clearly indicates it enables
Zerion globally rather than referencing the old chain IDs behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Thanks for the review @vseehausen!

I've addressed the env variable naming concern:

  1. ✅ Renamed env variable: Changed FF_ZERION_BALANCES_CHAIN_IDSFF_ZERION_ENABLED to be more precise about its purpose (commit 19b5253).

Regarding the other points:

  1. Chain configuration caching: The current implementation uses in-memory storage for Zerion chain mappings (fetched once on first request). The existing ChainsRepository fetches from Safe Config API which is a different source. The Zerion /v1/chains endpoint provides the mapping between chain IDs and Zerion's internal chain names. I could move this to Redis cache instead of in-memory if preferred - let me know.

  2. Zerion Balance endpoint in production: Noted - happy to discuss this separately. If Zerion balances aren't being used in production, we could consider removing it in a follow-up PR.

Let me know if you'd like any additional changes!

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

This is the final PR Bugbot will review for you during this billing cycle

Your free Bugbot reviews will reset on January 29

Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

Support both the new FF_ZERION_ENABLED and legacy FF_ZERION_BALANCES_CHAIN_IDS
environment variables to ensure existing deployments continue to work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Responding to @cursor[bot]'s review comment about backwards compatibility:

Good catch! Fixed in commit fcd4a5d - now supporting both environment variables:

zerionBalancesEnabled:
  !!process.env.FF_ZERION_ENABLED ||
  !!process.env.FF_ZERION_BALANCES_CHAIN_IDS,

This maintains backwards compatibility:

  • New deployments can use FF_ZERION_ENABLED=true
  • Existing deployments with FF_ZERION_BALANCES_CHAIN_IDS=1,10,137 continue to work

@katspaugh katspaugh requested a review from vseehausen January 7, 2026 13:51
@katspaugh katspaugh merged commit 22f514d into main Jan 8, 2026
24 checks passed
@katspaugh katspaugh deleted the feat/enable-zerion-globally branch January 8, 2026 15:17
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.

4 participants