From 1194c9ae610318c71079f91159d93a1cbf2bb9d2 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 6 Jan 2026 10:00:10 +0100 Subject: [PATCH 1/6] test: highlight external usage for assets-controllers --- packages/assets-controllers/EXTERNAL_USAGE.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/assets-controllers/EXTERNAL_USAGE.md diff --git a/packages/assets-controllers/EXTERNAL_USAGE.md b/packages/assets-controllers/EXTERNAL_USAGE.md new file mode 100644 index 00000000000..3ca7929be03 --- /dev/null +++ b/packages/assets-controllers/EXTERNAL_USAGE.md @@ -0,0 +1,61 @@ +# External Controller Dependencies + +The following controllers from other packages depend on `@metamask/assets-controllers` state. + +## Summary + +| External Controller | Assets-Controllers Consumed | Primary Use Case | +|--------------------|---------------------------|------------------| +| **`transaction-pay-controller`** | `TokenBalancesController`, `AccountTrackerController`, `TokensController`, `TokenRatesController`, `CurrencyRateController` | Pay gas fees with alternative tokens - needs balances, metadata, and rates | +| **`bridge-controller`** | `CurrencyRateController`, `TokenRatesController`, `MultichainAssetsRatesController` | Cross-chain bridging - needs exchange rates for EVM + non-EVM assets for quote calculations | +| **`subscription-controller`** | `MultichainBalancesController` | Crypto subscription payments - needs multi-chain balances to offer payment options | +| **`core-backend`** | `TokenBalancesController` | Real-time balance coordination - adjusts polling based on WebSocket connection state | + +## State Properties Used + +| Assets-Controller | Key State Properties | How It's Used | External Controller | +|-------------------|---------------------|---------------|---------------------| +| `TokenBalancesController` | `tokenBalances[account][chainId][token]` | Get ERC-20 token balances to check available funds for gas payment | `transaction-pay-controller` | +| `TokenBalancesController` | `updateChainPollingConfigs` action | Coordinate polling intervals based on WebSocket connection status | `core-backend` | +| `AccountTrackerController` | `accountsByChainId[chainId][account].balance` | Get native currency balance (ETH, MATIC) when paying with native token | `transaction-pay-controller` | +| `TokensController` | `allTokens[chainId][*]` → `decimals`, `symbol` | Get token metadata to format amounts and display token info | `transaction-pay-controller` | +| `TokenRatesController` | `marketData[chainId][token].price`, `currency` | Get token-to-native price for fiat calculations | `transaction-pay-controller`, `bridge-controller` | +| `CurrencyRateController` | `currencyRates[ticker].conversionRate` | Get native-to-fiat rate for USD/local currency display | `transaction-pay-controller`, `bridge-controller` | +| `CurrencyRateController` | `currencyRates[ticker].usdConversionRate` | Get native-to-USD rate for standardized value comparison | `transaction-pay-controller`, `bridge-controller` | +| `CurrencyRateController` | `currentCurrency` | Get user's selected fiat currency for fetching rates | `bridge-controller` | +| `MultichainAssetsRatesController` | `conversionRates[assetId].rate` | Get non-EVM asset prices (Solana, Bitcoin) for cross-chain quotes | `bridge-controller` | +| `MultichainBalancesController` | Full state via `getState()` | Check user's crypto balances across all chains for subscription payment options | `subscription-controller` | +| `MultichainBalancesController` | `AccountBalancesUpdatesEvent` | Monitor real-time balance changes to update payment options | `subscription-controller` | + +## Detailed Usage + +### `transaction-pay-controller` + +Handles gas fee payment with alternative tokens (pay for transactions with tokens other than the native currency). + +- **`TokenBalancesController`**: Queries `tokenBalances[account][chainId][tokenAddress]` to get ERC-20 token balances for determining available funds +- **`AccountTrackerController`**: Queries `accountsByChainId[chainId][account].balance` to get native currency balance when the payment token is native (ETH, MATIC, etc.) +- **`TokensController`**: Queries `allTokens[chainId][*]` to get token metadata (decimals, symbol) for proper amount formatting +- **`TokenRatesController`**: Queries `marketData[chainId][tokenAddress].price` to calculate token-to-native conversion for fiat display +- **`CurrencyRateController`**: Queries `currencyRates[ticker].conversionRate` and `usdConversionRate` to convert native amounts to fiat + +### `bridge-controller` + +Handles cross-chain token bridging and swapping, fetching quotes from bridge providers. + +- **`CurrencyRateController`**: Gets native currency rates for EVM chains and user's preferred currency via `currencyRates` and `currentCurrency` +- **`TokenRatesController`**: Gets EVM token prices relative to native currency via `marketData[chainId][tokenAddress]` +- **`MultichainAssetsRatesController`**: Gets non-EVM asset prices (Solana, Bitcoin, etc.) via `conversionRates[assetId]` for cross-chain quote calculations + +### `subscription-controller` + +Handles MetaMask subscription management, including crypto-based payments. + +- **`MultichainBalancesController`**: Queries full state to check user's crypto balances across all chains to determine available payment options. Subscribes to `AccountBalancesUpdatesEvent` to update payment options in real-time. + +### `core-backend` + +Provides real-time data delivery via WebSocket for account activity and balance updates. + +- **`TokenBalancesController`**: Calls `updateChainPollingConfigs` to coordinate polling intervals. When WebSocket is connected, reduces polling (10 min backup). When disconnected, increases polling frequency (30s) for HTTP fallback. + From 9ed0ba6b6a126dabe3cbc93071176aa0f1b36b87 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 7 Jan 2026 15:44:08 +0100 Subject: [PATCH 2/6] test: migration strategy --- packages/assets-controllers/EXTERNAL_USAGE.md | 28 +- .../assets-controllers/MIGRATION_STRATEGY.md | 291 ++++++++++++++++++ 2 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 packages/assets-controllers/MIGRATION_STRATEGY.md diff --git a/packages/assets-controllers/EXTERNAL_USAGE.md b/packages/assets-controllers/EXTERNAL_USAGE.md index 3ca7929be03..61cd62c0444 100644 --- a/packages/assets-controllers/EXTERNAL_USAGE.md +++ b/packages/assets-controllers/EXTERNAL_USAGE.md @@ -33,11 +33,29 @@ The following controllers from other packages depend on `@metamask/assets-contro Handles gas fee payment with alternative tokens (pay for transactions with tokens other than the native currency). -- **`TokenBalancesController`**: Queries `tokenBalances[account][chainId][tokenAddress]` to get ERC-20 token balances for determining available funds -- **`AccountTrackerController`**: Queries `accountsByChainId[chainId][account].balance` to get native currency balance when the payment token is native (ETH, MATIC, etc.) -- **`TokensController`**: Queries `allTokens[chainId][*]` to get token metadata (decimals, symbol) for proper amount formatting -- **`TokenRatesController`**: Queries `marketData[chainId][tokenAddress].price` to calculate token-to-native conversion for fiat display -- **`CurrencyRateController`**: Queries `currencyRates[ticker].conversionRate` and `usdConversionRate` to convert native amounts to fiat +**Call Chain to `TokenBalancesController`:** + +``` +TransactionPayController (constructor) + │ + └─► pollTransactionChanges() // subscribes to TransactionController events + │ + └─► onTransactionChange() // triggered when tx is new/updated + │ + └─► parseRequiredTokens() // in required-tokens.ts + │ + └─► getTokenBalance() // in token.ts + │ + └─► messenger.call('TokenBalancesController:getState') +``` + +**State accessed:** + +- **`TokenBalancesController`**: `tokenBalances[account][chainId][token]` → ERC-20 balances +- **`AccountTrackerController`**: `accountsByChainId[chainId][account].balance` → native balance (ETH, MATIC) +- **`TokensController`**: `allTokens[chainId][*]` → token metadata (decimals, symbol) +- **`TokenRatesController`**: `marketData[chainId][token].price` → token-to-native price +- **`CurrencyRateController`**: `currencyRates[ticker].conversionRate` → native-to-fiat rate ### `bridge-controller` diff --git a/packages/assets-controllers/MIGRATION_STRATEGY.md b/packages/assets-controllers/MIGRATION_STRATEGY.md new file mode 100644 index 00000000000..8bfd0469aa8 --- /dev/null +++ b/packages/assets-controllers/MIGRATION_STRATEGY.md @@ -0,0 +1,291 @@ +# Assets Controller State Migration Strategy: Balances + +## Overview + +This document outlines the migration strategy for consolidating **balance state** from `TokenBalancesController` and `AccountTrackerController` into the unified `AssetsController.assetsBalance` structure. + +> **Note:** This same migration pattern (dual-write → dual-read → gradual rollout → confidence period → cleanup) should be followed for migrating: +> - **Metadata** (`TokensController`, `TokenListController` → `assetsMetadata`) +> - **Prices** (`TokenRatesController`, `CurrencyRateController` → `assetsPrice`) +> +> Each migration should can be done **sequentially**, not in parallel, to reduce risk and simplify debugging. + +### Target State Structure + +```typescript +// assetsController.ts +export type AssetsControllerState = { + /** Shared metadata for all assets (stored once per asset) */ + assetsMetadata: { [assetId: string]: Json }; + /** Price data for assets (stored once per asset) */ + assetsPrice: { [assetId: string]: Json }; + /** Per-account balance data */ + assetsBalance: { [accountId: string]: { [assetId: string]: Json } }; +}; +``` + +### Current State Sources Being Migrated (This Document: Balances) + +| Current Controller | Current State Property | Target Property | +|-------------------|----------------------|-----------------| +| `TokenBalancesController` | `tokenBalances[account][chainId][token]` | `assetsBalance[accountId][assetId]` | +| `AccountTrackerController` | `accountsByChainId[chainId][account].balance` | `assetsBalance[accountId][assetId]` (native) | + +### Future Migrations (Same Pattern) + +| Current Controller | Current State Property | Target Property | +|-------------------|----------------------|-----------------| +| `TokensController` | `allTokens[chainId][account]` | `assetsMetadata[assetId]` | +| `TokenListController` | `tokenList`, `tokensChainsCache` | `assetsMetadata[assetId]` | +| `TokenRatesController` | `marketData[chainId][token].price` | `assetsPrice[assetId]` | +| `CurrencyRateController` | `currencyRates[ticker]` | `assetsPrice[assetId]` (native assets) | + +--- + +## Feature Flags + +Two feature flags control the entire migration: + +| Flag | Type | Purpose | +|------|------|---------| +| `assets_controller_dual_write` | `boolean` | When `true`, balance updates write to both legacy and new state. Keep enabled through Phase 4 to ensure rollback always has fresh data. | +| `assets_controller_read_percentage` | `number (0-100)` | Percentage of users reading from new state. `0` = all legacy, `100` = all new. | + +**Flag states by phase:** + +| Phase | `dual_write` | `read_percentage` | +|-------|--------------|-------------------| +| Phase 1: Dual-Write | `true` | `0` | +| Phase 2: Dual-Read (comparison) | `true` | `0` (logging enabled in code) | +| Phase 3: Gradual Rollout | `true` | `10 → 25 → 50 → 75 → 100` | +| Phase 4: Confidence Period | `true` | `100` | +| Phase 5: Cleanup | removed | removed | + +--- + +## Migration Phases (TokenBalancesController) + +### Phase 1: Dual-Write + +**Goal:** Write to both old and new state structures simultaneously. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Phase 1: Dual-Write │ +│ │ +│ Balance Update │ +│ │ │ +│ ├──► TokenBalancesController.state.tokenBalances (WRITE) │ +│ │ │ +│ └──► AssetsController.state.assetsBalance (WRITE) │ +│ │ +│ External Controllers │ +│ │ │ +│ └──► TokenBalancesController.state.tokenBalances (READ) ◄── still │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Pseudo code:** + +``` +ON balance_update(account, chainId, token, balance): + + 1. WRITE to TokenBalancesController.state (legacy) + + 2. IF feature_flag("assets_controller_dual_write") THEN + WRITE to AssetsController.state (new) +``` + +--- + +### Phase 2: Dual-Read with Comparison + +**Goal:** Read from both sources, compare results, log discrepancies. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Phase 2: Dual-Read + Compare │ +│ │ +│ External Controller Request │ +│ │ │ +│ ├──► TokenBalancesController.state (PRIMARY READ) │ +│ │ │ │ +│ │ └──► Return to caller │ +│ │ │ +│ └──► AssetsController.state (SHADOW READ) │ +│ │ │ +│ └──► Compare with primary, log discrepancies │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Pseudo code:** + +``` +ON get_balance(account, chainId, token): + + 1. legacyBalance = READ from TokenBalancesController.state + + 2. IF feature_flag("assets_controller_dual_write") AND read_percentage == 0 THEN + // Phase 2: Shadow read for comparison only + newBalance = READ from AssetsController.state + + IF legacyBalance != newBalance THEN + LOG discrepancy for investigation + + 3. RETURN legacyBalance // Still return legacy +``` + +--- + +### Phase 3: Gradual Read Migration + +**Goal:** Gradually shift reads to new state with percentage-based rollout. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Phase 3: Percentage-Based Rollout │ +│ │ +│ Feature Flag: assets_controller_read_percentage = 10 │ +│ │ +│ Request 1-10: Read from AssetsController ────────┐ │ +│ Request 11-100: Read from TokenBalancesController ─┴──► Return │ +│ │ +│ Gradually increase: 10% → 25% → 50% → 75% → 100% │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Rollout:** Gradually increase `assets_controller_read_percentage`: 10% → 25% → 50% → 75% → 100% + +--- + +### Phase 4: Confidence Period (Keep Dual-Write Active) + +**Goal:** Maintain dual-write while 100% of reads use new state. This ensures rollback is always to fresh data. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Phase 4: Confidence Period │ +│ │ +│ Balance Update (DUAL-WRITE CONTINUES) │ +│ │ │ +│ ├──► TokenBalancesController.state.tokenBalances (WRITE) ◄── fresh! │ +│ │ │ +│ └──► AssetsController.state.assetsBalance (WRITE) │ +│ │ +│ External Controllers │ +│ │ │ +│ └──► AssetsController.state (READ 100%) │ +│ │ +│ Rollback available: Set read_percentage=0 → instant switch to fresh │ +│ legacy data │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Why keep dual-write?** +- Rollback to stale data is worse than the original problem +- Storage cost is temporary +- Peace of mind during high-risk period + +**Duration:** 4+ weeks at 100% new state reads with zero issues + +--- + +### Phase 5: Legacy Removal + +**Goal:** Remove legacy state and controllers. This is a one-way door. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Phase 5: Point of No Return │ +│ │ +│ BEFORE: Dual-write active, rollback possible │ +│ │ +│ AFTER: Legacy controllers removed, no rollback to old state │ +│ │ +│ Rollback strategy changes to: │ +│ - Fix forward (patch the new controller) │ +│ - Restore from backup (if catastrophic) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Checklist before removal:** +- [ ] 100% reads from new state for 4+ weeks +- [ ] Zero rollbacks triggered during confidence period +- [ ] All external controllers migrated and tested +- [ ] Performance metrics stable + +--- + +## Rollback Strategy + +### During Phases 1-4: Instant Rollback (< 1 minute) + +Because dual-write is active, legacy state is always fresh. + +```typescript +// Set feature flag to disable new state reads +{ + "assets_controller_read_percentage": 0 +} +``` + +**Result:** All reads immediately use legacy state with fresh data (dual-write keeps it current). + + +--- + +## Monitoring & Alerts + +### Key Metrics + +| Metric | Threshold | Action | +|--------|-----------|--------| +| Balance discrepancy rate | > 0.1% | Pause rollout | +| Read latency increase | > 20ms | Investigate | +| State size increase | > 50% | Optimize | +| Error rate | > 0.01% | Rollback | + +### Logging + +```typescript +// Log all discrepancies during dual-read phase +interface DiscrepancyLog { + timestamp: number; + account: string; + assetId: string; + legacyValue: string; + newValue: string; + phase: 'dual_read' | 'percentage_rollout'; +} +``` + +--- + + +--- + +## Checklist + +### Pre-Migration +- [ ] New `AssetsController` implemented with new state structure +- [ ] Feature flags created in LaunchDarkly/remote config +- [ ] Monitoring dashboards set up +- [ ] Rollback runbook documented + +### During Migration +- [ ] Dual-write enabled and verified +- [ ] Discrepancy logging active +- [ ] Performance baseline established +- [ ] External team communication (if applicable) + +### Post-Migration +- [ ] Legacy state writes disabled +- [ ] Legacy controller deprecated +- [ ] Documentation updated +- [ ] Storage cleanup verified + From 6474ce4df97fce53566447f55eb67dca414bb608 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 7 Jan 2026 15:47:24 +0100 Subject: [PATCH 3/6] test: migration strategy --- packages/assets-controllers/MIGRATION_STRATEGY.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/assets-controllers/MIGRATION_STRATEGY.md b/packages/assets-controllers/MIGRATION_STRATEGY.md index 8bfd0469aa8..fe5a3975dfd 100644 --- a/packages/assets-controllers/MIGRATION_STRATEGY.md +++ b/packages/assets-controllers/MIGRATION_STRATEGY.md @@ -190,8 +190,6 @@ ON get_balance(account, chainId, token): - Storage cost is temporary - Peace of mind during high-risk period -**Duration:** 4+ weeks at 100% new state reads with zero issues - --- ### Phase 5: Legacy Removal @@ -223,7 +221,7 @@ ON get_balance(account, chainId, token): ## Rollback Strategy -### During Phases 1-4: Instant Rollback (< 1 minute) +### During Phases 1-4: Instant Rollback Because dual-write is active, legacy state is always fresh. From e87fae98f6fe3e0715994e86c5ead022594b0345 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 7 Jan 2026 15:48:57 +0100 Subject: [PATCH 4/6] test: migration strategy --- packages/assets-controllers/MIGRATION_STRATEGY.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/assets-controllers/MIGRATION_STRATEGY.md b/packages/assets-controllers/MIGRATION_STRATEGY.md index fe5a3975dfd..142a44dab08 100644 --- a/packages/assets-controllers/MIGRATION_STRATEGY.md +++ b/packages/assets-controllers/MIGRATION_STRATEGY.md @@ -237,16 +237,7 @@ Because dual-write is active, legacy state is always fresh. --- -## Monitoring & Alerts - -### Key Metrics - -| Metric | Threshold | Action | -|--------|-----------|--------| -| Balance discrepancy rate | > 0.1% | Pause rollout | -| Read latency increase | > 20ms | Investigate | -| State size increase | > 50% | Optimize | -| Error rate | > 0.01% | Rollback | +## Monitoring ### Logging From 9321dea8631e78ac2835396a1d9711d06823cbe5 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 12 Jan 2026 09:26:00 +0100 Subject: [PATCH 5/6] fix: add details --- packages/assets-controllers/EXTERNAL_USAGE.md | 153 +++++++-- .../assets-controllers/MIGRATION_STRATEGY.md | 294 +++++++++++++----- 2 files changed, 344 insertions(+), 103 deletions(-) diff --git a/packages/assets-controllers/EXTERNAL_USAGE.md b/packages/assets-controllers/EXTERNAL_USAGE.md index 61cd62c0444..5bf30ae4bfb 100644 --- a/packages/assets-controllers/EXTERNAL_USAGE.md +++ b/packages/assets-controllers/EXTERNAL_USAGE.md @@ -8,24 +8,19 @@ The following controllers from other packages depend on `@metamask/assets-contro |--------------------|---------------------------|------------------| | **`transaction-pay-controller`** | `TokenBalancesController`, `AccountTrackerController`, `TokensController`, `TokenRatesController`, `CurrencyRateController` | Pay gas fees with alternative tokens - needs balances, metadata, and rates | | **`bridge-controller`** | `CurrencyRateController`, `TokenRatesController`, `MultichainAssetsRatesController` | Cross-chain bridging - needs exchange rates for EVM + non-EVM assets for quote calculations | -| **`subscription-controller`** | `MultichainBalancesController` | Crypto subscription payments - needs multi-chain balances to offer payment options | -| **`core-backend`** | `TokenBalancesController` | Real-time balance coordination - adjusts polling based on WebSocket connection state | ## State Properties Used | Assets-Controller | Key State Properties | How It's Used | External Controller | |-------------------|---------------------|---------------|---------------------| | `TokenBalancesController` | `tokenBalances[account][chainId][token]` | Get ERC-20 token balances to check available funds for gas payment | `transaction-pay-controller` | -| `TokenBalancesController` | `updateChainPollingConfigs` action | Coordinate polling intervals based on WebSocket connection status | `core-backend` | -| `AccountTrackerController` | `accountsByChainId[chainId][account].balance` | Get native currency balance (ETH, MATIC) when paying with native token | `transaction-pay-controller` | +| `AccountTrackerController` | `accountsByChainId[chainId][account].balance` | Get native currency balance when paying with native token | `transaction-pay-controller` | | `TokensController` | `allTokens[chainId][*]` → `decimals`, `symbol` | Get token metadata to format amounts and display token info | `transaction-pay-controller` | | `TokenRatesController` | `marketData[chainId][token].price`, `currency` | Get token-to-native price for fiat calculations | `transaction-pay-controller`, `bridge-controller` | | `CurrencyRateController` | `currencyRates[ticker].conversionRate` | Get native-to-fiat rate for USD/local currency display | `transaction-pay-controller`, `bridge-controller` | | `CurrencyRateController` | `currencyRates[ticker].usdConversionRate` | Get native-to-USD rate for standardized value comparison | `transaction-pay-controller`, `bridge-controller` | | `CurrencyRateController` | `currentCurrency` | Get user's selected fiat currency for fetching rates | `bridge-controller` | | `MultichainAssetsRatesController` | `conversionRates[assetId].rate` | Get non-EVM asset prices (Solana, Bitcoin) for cross-chain quotes | `bridge-controller` | -| `MultichainBalancesController` | Full state via `getState()` | Check user's crypto balances across all chains for subscription payment options | `subscription-controller` | -| `MultichainBalancesController` | `AccountBalancesUpdatesEvent` | Monitor real-time balance changes to update payment options | `subscription-controller` | ## Detailed Usage @@ -33,26 +28,74 @@ The following controllers from other packages depend on `@metamask/assets-contro Handles gas fee payment with alternative tokens (pay for transactions with tokens other than the native currency). -**Call Chain to `TokenBalancesController`:** +**Call Chain to `TokenBalancesController` and `AccountTrackerController`:** ``` TransactionPayController (constructor) │ - └─► pollTransactionChanges() // subscribes to TransactionController events + └─► pollTransactionChanges() // subscribes to TransactionController events │ - └─► onTransactionChange() // triggered when tx is new/updated + └─► onTransactionChange() // triggered when tx is new/updated │ - └─► parseRequiredTokens() // in required-tokens.ts + └─► parseRequiredTokens() // in required-tokens.ts │ - └─► getTokenBalance() // in token.ts + └─► buildRequiredToken() │ - └─► messenger.call('TokenBalancesController:getState') + └─► getTokenBalance() // in token.ts (line 29-67) + │ + ├─► messenger.call('TokenBalancesController:getState') + │ ↳ tokenBalances[account][chainId][token] → ERC-20 balance + │ + └─► messenger.call('AccountTrackerController:getState') + ↳ accountsByChainId[chainId][account].balance → native balance +``` + +**Call Chain to `TokensController`:** + +``` +TransactionPayController (constructor) + │ + └─► pollTransactionChanges() + │ + └─► onTransactionChange() + │ + └─► parseRequiredTokens() + │ + └─► buildRequiredToken() + │ + └─► getTokenInfo() // in token.ts (line 126-159) + │ + └─► messenger.call('TokensController:getState') + ↳ allTokens[chainId][*] → decimals, symbol +``` + +**Call Chain to `TokenRatesController` and `CurrencyRateController`:** + +``` +TransactionPayController (constructor) + │ + └─► pollTransactionChanges() + │ + └─► onTransactionChange() + │ + └─► parseRequiredTokens() + │ + └─► buildRequiredToken() + │ + └─► getTokenFiatRate() // in token.ts (line 169-222) + │ + ├─► messenger.call('TokenRatesController:getState') + │ ↳ marketData[chainId][token].price → token-to-native rate + │ + └─► messenger.call('CurrencyRateController:getState') + ├─► currencyRates[ticker].conversionRate → native-to-fiat + └─► currencyRates[ticker].usdConversionRate → native-to-USD ``` **State accessed:** - **`TokenBalancesController`**: `tokenBalances[account][chainId][token]` → ERC-20 balances -- **`AccountTrackerController`**: `accountsByChainId[chainId][account].balance` → native balance (ETH, MATIC) +- **`AccountTrackerController`**: `accountsByChainId[chainId][account].balance` → native balance - **`TokensController`**: `allTokens[chainId][*]` → token metadata (decimals, symbol) - **`TokenRatesController`**: `marketData[chainId][token].price` → token-to-native price - **`CurrencyRateController`**: `currencyRates[ticker].conversionRate` → native-to-fiat rate @@ -61,19 +104,85 @@ TransactionPayController (constructor) Handles cross-chain token bridging and swapping, fetching quotes from bridge providers. -- **`CurrencyRateController`**: Gets native currency rates for EVM chains and user's preferred currency via `currencyRates` and `currentCurrency` -- **`TokenRatesController`**: Gets EVM token prices relative to native currency via `marketData[chainId][tokenAddress]` -- **`MultichainAssetsRatesController`**: Gets non-EVM asset prices (Solana, Bitcoin, etc.) via `conversionRates[assetId]` for cross-chain quote calculations +**Call Chain to `CurrencyRateController`, `TokenRatesController`, and `MultichainAssetsRatesController`:** -### `subscription-controller` +``` +BridgeController + │ + ├─► #getExchangeRateSources() // in bridge-controller.ts (line 394-401) + │ │ + │ ├─► messenger.call('MultichainAssetsRatesController:getState') + │ │ ↳ conversionRates[assetId].rate → non-EVM asset prices (Solana, Bitcoin) + │ │ + │ ├─► messenger.call('CurrencyRateController:getState') + │ │ ↳ currencyRates[ticker].conversionRate → native-to-fiat rate + │ │ ↳ currencyRates[ticker].usdConversionRate → native-to-USD rate + │ │ + │ └─► messenger.call('TokenRatesController:getState') + │ ↳ marketData[chainId][token].price → EVM token-to-native price + │ + └─► #fetchAssetExchangeRates() // in bridge-controller.ts (line 413-464) + │ + └─► messenger.call('CurrencyRateController:getState') + ↳ currentCurrency → user's selected fiat currency +``` -Handles MetaMask subscription management, including crypto-based payments. +**How Clients Use Bridge Selectors:** + +The exchange rate logic is consumed by UI code via exported selectors: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT CODE │ +│ │ +│ useSelector(state => selectBridgeQuotes(state, { sortOrder, selectedQuote })) +└───────────────────────────────────┬─────────────────────────────────────────┘ + │ + ▼ +selectBridgeQuotes (exported) + └─► selectSortedBridgeQuotes + └─► selectBridgeQuotesWithMetadata + │ + ├─► selectExchangeRateByChainIdAndAddress(srcChainId, srcTokenAddress) + │ └─► getExchangeRateByChainIdAndAddress(...) + │ + ├─► selectExchangeRateByChainIdAndAddress(destChainId, destTokenAddress) + │ └─► getExchangeRateByChainIdAndAddress(...) + │ + └─► selectExchangeRateByChainIdAndAddress(srcChainId, AddressZero) + └─► getExchangeRateByChainIdAndAddress(...) // for gas fees +``` -- **`MultichainBalancesController`**: Queries full state to check user's crypto balances across all chains to determine available payment options. Subscribes to `AccountBalancesUpdatesEvent` to update payment options in real-time. +**Exchange Rate Resolution Logic (getExchangeRateByChainIdAndAddress):** -### `core-backend` +``` +getExchangeRateByChainIdAndAddress() // in selectors.ts (line 119-189) + │ + ├─► Check BridgeController.assetExchangeRates[assetId] + │ ↳ Use if available (fetched when not in assets controllers) + │ + ├─► If non-EVM chain (Solana, Bitcoin, etc.): + │ └─► MultichainAssetsRatesController.conversionRates[assetId].rate + │ + ├─► If EVM native token: + │ └─► CurrencyRateController.currencyRates[symbol].conversionRate + │ ↳ Also uses .usdConversionRate for USD values + │ + └─► If EVM ERC-20 token: + ├─► TokenRatesController.marketData[chainId][token].price + │ ↳ Gets token-to-native rate + │ + └─► CurrencyRateController.currencyRates[currency].conversionRate + ↳ Multiplies to get fiat value +``` + +**State accessed:** -Provides real-time data delivery via WebSocket for account activity and balance updates. +- **`MultichainAssetsRatesController`**: `conversionRates[assetId].rate` → Non-EVM asset prices (Solana SOL, Bitcoin BTC, etc.) +- **`CurrencyRateController`**: `currencyRates[ticker].conversionRate` → Native currency to fiat rates +- **`CurrencyRateController`**: `currencyRates[ticker].usdConversionRate` → Native currency to USD rates +- **`CurrencyRateController`**: `currentCurrency` → User's selected fiat currency +- **`TokenRatesController`**: `marketData[chainId][token].price` → EVM token prices relative to native currency +- **`TokenRatesController`**: `marketData[chainId][token].currency` → Currency denomination of the price -- **`TokenBalancesController`**: Calls `updateChainPollingConfigs` to coordinate polling intervals. When WebSocket is connected, reduces polling (10 min backup). When disconnected, increases polling frequency (30s) for HTTP fallback. diff --git a/packages/assets-controllers/MIGRATION_STRATEGY.md b/packages/assets-controllers/MIGRATION_STRATEGY.md index 142a44dab08..42ccaf1952a 100644 --- a/packages/assets-controllers/MIGRATION_STRATEGY.md +++ b/packages/assets-controllers/MIGRATION_STRATEGY.md @@ -1,102 +1,101 @@ -# Assets Controller State Migration Strategy: Balances +# Assets Controller State Migration Strategy ## Overview -This document outlines the migration strategy for consolidating **balance state** from `TokenBalancesController` and `AccountTrackerController` into the unified `AssetsController.assetsBalance` structure. - -> **Note:** This same migration pattern (dual-write → dual-read → gradual rollout → confidence period → cleanup) should be followed for migrating: -> - **Metadata** (`TokensController`, `TokenListController` → `assetsMetadata`) -> - **Prices** (`TokenRatesController`, `CurrencyRateController` → `assetsPrice`) -> -> Each migration should can be done **sequentially**, not in parallel, to reduce risk and simplify debugging. +This document outlines the migration strategy for consolidating asset state from multiple legacy controllers into a unified `AssetsController` structure. ### Target State Structure ```typescript -// assetsController.ts export type AssetsControllerState = { - /** Shared metadata for all assets (stored once per asset) */ assetsMetadata: { [assetId: string]: Json }; - /** Price data for assets (stored once per asset) */ assetsPrice: { [assetId: string]: Json }; - /** Per-account balance data */ assetsBalance: { [accountId: string]: { [assetId: string]: Json } }; }; ``` -### Current State Sources Being Migrated (This Document: Balances) +### Migrations -| Current Controller | Current State Property | Target Property | -|-------------------|----------------------|-----------------| -| `TokenBalancesController` | `tokenBalances[account][chainId][token]` | `assetsBalance[accountId][assetId]` | -| `AccountTrackerController` | `accountsByChainId[chainId][account].balance` | `assetsBalance[accountId][assetId]` (native) | +| Migration | Legacy Controllers | Target Property | +|-----------|-------------------|-----------------| +| **Balances** | `TokenBalancesController`, `AccountTrackerController`, `MultichainBalancesController` | `assetsBalance` | +| **Metadata** | `TokensController`, `TokenListController` | `assetsMetadata` | +| **Prices** | `TokenRatesController`, `CurrencyRateController`, `MultichainAssetsRatesController` | `assetsPrice` | -### Future Migrations (Same Pattern) +Each migration follows the same pattern: **shadow write → dual-read → gradual rollout → confidence period → cleanup**. -| Current Controller | Current State Property | Target Property | -|-------------------|----------------------|-----------------| -| `TokensController` | `allTokens[chainId][account]` | `assetsMetadata[assetId]` | -| `TokenListController` | `tokenList`, `tokensChainsCache` | `assetsMetadata[assetId]` | -| `TokenRatesController` | `marketData[chainId][token].price` | `assetsPrice[assetId]` | -| `CurrencyRateController` | `currencyRates[ticker]` | `assetsPrice[assetId]` (native assets) | +> **Note:** Examples in this document use balances migration, but the pattern applies to all migrations. --- ## Feature Flags -Two feature flags control the entire migration: +Two remote feature flags (LaunchDarkly) control the entire migration: | Flag | Type | Purpose | |------|------|---------| -| `assets_controller_dual_write` | `boolean` | When `true`, balance updates write to both legacy and new state. Keep enabled through Phase 4 to ensure rollback always has fresh data. | -| `assets_controller_read_percentage` | `number (0-100)` | Percentage of users reading from new state. `0` = all legacy, `100` = all new. | +| `assets_controller_enabled` | `boolean` | When `true`, AssetsController is instantiated and writes to its own state. Acts as a kill switch — can be disabled remotely without a deploy. | +| `assets_controller_use_new_state` | `boolean` (with % rollout) | LaunchDarkly returns `true` or `false` per user based on configured percentage. Controls which state source to read from. | **Flag states by phase:** -| Phase | `dual_write` | `read_percentage` | -|-------|--------------|-------------------| -| Phase 1: Dual-Write | `true` | `0` | -| Phase 2: Dual-Read (comparison) | `true` | `0` (logging enabled in code) | -| Phase 3: Gradual Rollout | `true` | `10 → 25 → 50 → 75 → 100` | -| Phase 4: Confidence Period | `true` | `100` | +| Phase | `enabled` | `use_new_state` | +|-------|-----------|-----------------| +| Phase 1: Shadow Write | `true` | `false` (0%) | +| Phase 2: Dual-Read (comparison) | `true` | `false` (0%, logging enabled in code) | +| Phase 3: Gradual Rollout | `true` | % rollout: 10% → 25% → 50% → 75% → 100% | +| Phase 4: Confidence Period | `true` | `true` (100%) | | Phase 5: Cleanup | removed | removed | --- ## Migration Phases (TokenBalancesController) -### Phase 1: Dual-Write +### Phase 1: Shadow Write -**Goal:** Write to both old and new state structures simultaneously. +**Goal:** New controller writes to its own state independently. Legacy continues unchanged. No one reads from new state yet. ``` ┌─────────────────────────────────────────────────────────────────────────────┐ -│ Phase 1: Dual-Write │ +│ Phase 1: Shadow Write │ │ │ -│ Balance Update │ -│ │ │ -│ ├──► TokenBalancesController.state.tokenBalances (WRITE) │ +│ TokenBalancesController (unchanged) │ │ │ │ -│ └──► AssetsController.state.assetsBalance (WRITE) │ +│ └──► tokenBalances state (WRITE + READ by external controllers) │ │ │ -│ External Controllers │ +│ AssetsController (new, independent) │ │ │ │ -│ └──► TokenBalancesController.state.tokenBalances (READ) ◄── still │ +│ └──► assetsBalance state (WRITE only, no readers yet) │ +│ │ +│ Kill switch: Set assets_controller_enabled=false in LaunchDarkly │ +│ → AssetsController not instantiated, zero impact │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` +**Key points:** +- Both controllers write to their own state +- Feature flag acts as a **kill switch** — if performance degrades, disable remotely without deploy +- No user impact if new controller has bugs (no one reads from it yet) + **Pseudo code:** -``` -ON balance_update(account, chainId, token, balance): - - 1. WRITE to TokenBalancesController.state (legacy) - - 2. IF feature_flag("assets_controller_dual_write") THEN - WRITE to AssetsController.state (new) +```typescript +// Legacy controller — always instantiated +new TokenBalancesController({ messenger, ... }); + +// New controller — instantiated only if flag is enabled +if (remoteConfig.get('assets_controller_enabled')) { + new AssetsController({ messenger, ... }); +} ``` +**Risks mitigated by the flag:** +- Performance degradation (CPU, memory) +- Excessive network calls +- Storage bloat from persisted state +- Crashes during instantiation or event handling + --- ### Phase 2: Dual-Read with Comparison @@ -107,36 +106,45 @@ ON balance_update(account, chainId, token, balance): ┌─────────────────────────────────────────────────────────────────────────────┐ │ Phase 2: Dual-Read + Compare │ │ │ -│ External Controller Request │ -│ │ │ -│ ├──► TokenBalancesController.state (PRIMARY READ) │ -│ │ │ │ -│ │ └──► Return to caller │ +│ External Controller (e.g., transaction-pay-controller) │ │ │ │ -│ └──► AssetsController.state (SHADOW READ) │ +│ └──► getTokenBalance() ◄── shared selector in assets-controllers │ │ │ │ -│ └──► Compare with primary, log discrepancies │ +│ ├──► TokenBalancesController.state (PRIMARY READ) │ +│ │ └──► Return to caller │ +│ │ │ +│ └──► AssetsController.state (SHADOW READ) │ +│ └──► Compare with primary, log discrepancies │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` -**Pseudo code:** +**Where does the dual-read logic live?** + +The logic is **centralized in a shared selector/utility** within `assets-controllers`, not in each external consumer. This keeps migration logic in one place and minimizes changes to external controllers. + +**Pseudo code (shared selector in assets-controllers):** ``` -ON get_balance(account, chainId, token): +FUNCTION getTokenBalance(messenger, account, chainId, token): 1. legacyBalance = READ from TokenBalancesController.state - 2. IF feature_flag("assets_controller_dual_write") AND read_percentage == 0 THEN - // Phase 2: Shadow read for comparison only + 2. IF feature_flag("assets_controller_enabled") THEN + // Shadow read for comparison (Phase 2) newBalance = READ from AssetsController.state IF legacyBalance != newBalance THEN - LOG discrepancy for investigation + LOG discrepancy for investigation - 3. RETURN legacyBalance // Still return legacy + 3. RETURN legacyBalance // Still return legacy in Phase 2 ``` +**Benefits:** +- Migration logic is centralized (easy to update, easy to remove later) +- External controllers make minimal changes +- Discrepancy logging happens automatically + --- ### Phase 3: Gradual Read Migration @@ -147,29 +155,46 @@ ON get_balance(account, chainId, token): ┌─────────────────────────────────────────────────────────────────────────────┐ │ Phase 3: Percentage-Based Rollout │ │ │ -│ Feature Flag: assets_controller_read_percentage = 10 │ +│ Feature Flag: assets_controller_use_new_state (boolean, % rollout) │ │ │ -│ Request 1-10: Read from AssetsController ────────┐ │ -│ Request 11-100: Read from TokenBalancesController ─┴──► Return │ +│ LaunchDarkly handles user bucketing: │ +│ │ │ +│ ├──► 10% of users: flag returns TRUE → use AssetsController │ +│ └──► 90% of users: flag returns FALSE → use TokenBalancesController │ │ │ -│ Gradually increase: 10% → 25% → 50% → 75% → 100% │ +│ Gradually increase percentage in LaunchDarkly dashboard: │ +│ 10% → 25% → 50% → 75% → 100% │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` -**Rollout:** Gradually increase `assets_controller_read_percentage`: 10% → 25% → 50% → 75% → 100% +**The same shared selector handles the rollout:** + +``` +FUNCTION getTokenBalance(messenger, account, chainId, token): + + // LaunchDarkly returns TRUE or FALSE based on user's bucket + use_new_state = feature_flag("assets_controller_use_new_state", user_key) + + IF use_new_state THEN + RETURN READ from AssetsController.state + ELSE + RETURN READ from TokenBalancesController.state +``` + +**Rollout:** Increase percentage in LaunchDarkly dashboard — flag returns `true` for more users as percentage increases. --- -### Phase 4: Confidence Period (Keep Dual-Write Active) +### Phase 4: Confidence Period (Both Controllers Still Active) -**Goal:** Maintain dual-write while 100% of reads use new state. This ensures rollback is always to fresh data. +**Goal:** Keep both controllers writing while 100% of reads use new state. This ensures rollback is always to fresh data. ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ Phase 4: Confidence Period │ │ │ -│ Balance Update (DUAL-WRITE CONTINUES) │ +│ Both controllers still active (assets_controller_enabled=true) │ │ │ │ │ ├──► TokenBalancesController.state.tokenBalances (WRITE) ◄── fresh! │ │ │ │ @@ -185,7 +210,7 @@ ON get_balance(account, chainId, token): └─────────────────────────────────────────────────────────────────────────────┘ ``` -**Why keep dual-write?** +**Why keep both controllers active?** - Rollback to stale data is worse than the original problem - Storage cost is temporary - Peace of mind during high-risk period @@ -205,8 +230,7 @@ ON get_balance(account, chainId, token): │ AFTER: Legacy controllers removed, no rollback to old state │ │ │ │ Rollback strategy changes to: │ -│ - Fix forward (patch the new controller) │ -│ - Restore from backup (if catastrophic) │ +│ - Fix forward │ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -221,19 +245,128 @@ ON get_balance(account, chainId, token): ## Rollback Strategy -### During Phases 1-4: Instant Rollback +### During Phases 1-4: Instant Rollback (via LaunchDarkly) -Because dual-write is active, legacy state is always fresh. +Because both controllers are active, legacy state is always fresh. +**Phase 1 rollback (if performance issues):** ```typescript -// Set feature flag to disable new state reads +// Disable new controller entirely — not instantiated on next app launch { - "assets_controller_read_percentage": 0 + "assets_controller_enabled": false } ``` -**Result:** All reads immediately use legacy state with fresh data (dual-write keeps it current). +**Phase 2-4 rollback (if data issues):** +``` +// Keep new controller active but switch reads back to legacy +Set assets_controller_use_new_state to 0% in LaunchDarkly +→ All users get FALSE → reads from legacy +``` + +**Result:** All reads immediately use legacy state with fresh data (both controllers keep writing). + + +--- + +## Format Compatibility Layer + +Legacy state and new state use different formats. To minimize risk, the shared selector converts new state → legacy format before returning to external controllers. + +### Format Differences + +| Data | Legacy Format | New Format | +|------|---------------|------------| +| Account identifier | `0xabc...` (hex address) | `uuid-1234` (accountId) | +| Chain identifier | `0x1` (hex) | `eip155:1` (CAIP-2) | +| Asset identifier | `0x123...` (hex token address) | `eip155:1/erc20:0x123...` (CAIP-19) | +| Balance | `0x...` (hex) | `0x...` (hex) — no change | + +### Selector with Compatibility Layer + +``` +FUNCTION getTokenBalance(hexAddress, chainId, tokenAddress): + + use_new_state = feature_flag("assets_controller_use_new_state", user_key) + + IF use_new_state THEN + // Convert legacy params → new format for lookup + accountId = hexAddressToAccountId(hexAddress) + assetId = toCAIP19(chainId, tokenAddress) + + // Read from new state + balance = READ AssetsController.state.assetsBalance[accountId][assetId] + + // Return in legacy format (balance is already hex) + RETURN balance + ELSE + // Read from legacy state (no conversion needed) + RETURN READ TokenBalancesController.state.tokenBalances[hexAddress][chainId][tokenAddress] +``` + +### Why This Approach? + +1. **External controllers don't change** — they keep calling with legacy params, get legacy format back +2. **Risk is isolated** — if conversion has bugs, rollback to legacy instantly +3. **Gradual migration** — later, update external controllers to use new format directly +4. **Easy cleanup** — remove conversion layer once all consumers use new format + +--- + +## UI Selector Migration + +The strategy above is designed for **controller-to-controller** reads (via messenger calls). UI selectors are different — they read state directly from Redux/background state, not via messenger. + +### Current State + +Today, asset selectors are split between: +- **Some in `core`** (`assets-controllers/src/selectors/`) +- **Most in UI** (extension/mobile repos) + +### Goal + +Move all asset selectors to `core`. This enables: +- Centralized compatibility layer (same pattern as controller migration) +- Single source of truth for asset data access + +### Recommended Approach: Selectors in Core + +Create compatibility selectors in `assets-controllers` that UI imports: + +``` +// In assets-controllers/src/selectors/ + +FUNCTION selectTokenBalance(state, hexAddress, chainId, token): + + use_new_state = feature_flag("assets_controller_use_new_state", user_key) + + IF use_new_state THEN + // Convert legacy params → new format for lookup + accountId = lookupAccountId(hexAddress) + assetId = toCAIP19(chainId, token) + RETURN state.AssetsController.assetsBalance[accountId][assetId] + ELSE + RETURN state.TokenBalancesController.tokenBalances[hexAddress][chainId][token] +``` + +### UI Migration Timeline + +UI migration can follow the same phases as controller migration, but may run on a **separate timeline**: + +| Phase | Controllers | UI | +|-------|-------------|-----| +| Phase 1 | Shadow write | No changes (still reads legacy) | +| Phase 2 | Dual-read comparison | Add compatibility selectors, enable comparison logging | +| Phase 3 | Gradual rollout | Same — rollout via feature flag | +| Phase 4 | Confidence period | Same | +| Phase 5 | Cleanup | Remove legacy selectors | + +### Key Considerations +- UI and controller migrations can be **decoupled** — UI can stay on legacy longer if needed +- Same feature flag (`assets_controller_use_new_state`) can control both +- Goal: All asset selectors in `core` — UI imports from `@metamask/assets-controllers` +- Migration may require moving existing UI selectors to `core` first --- @@ -247,6 +380,7 @@ interface DiscrepancyLog { timestamp: number; account: string; assetId: string; + legacySource: string; // e.g., 'TokenBalancesController', 'TokenRatesController', etc. legacyValue: string; newValue: string; phase: 'dual_read' | 'percentage_rollout'; @@ -269,12 +403,10 @@ interface DiscrepancyLog { ### During Migration - [ ] Dual-write enabled and verified - [ ] Discrepancy logging active -- [ ] Performance baseline established -- [ ] External team communication (if applicable) +- [ ] External team communication ### Post-Migration - [ ] Legacy state writes disabled - [ ] Legacy controller deprecated -- [ ] Documentation updated - [ ] Storage cleanup verified From 4f04d9b6499b5a6ee0d8da718c57eaf854581258 Mon Sep 17 00:00:00 2001 From: Bernardo Garces Chapero Date: Mon, 12 Jan 2026 15:02:21 +0000 Subject: [PATCH 6/6] UI migration strategy --- .../assets-controllers/MIGRATION_STRATEGY.md | 79 +++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/packages/assets-controllers/MIGRATION_STRATEGY.md b/packages/assets-controllers/MIGRATION_STRATEGY.md index 42ccaf1952a..0f8b776d9f3 100644 --- a/packages/assets-controllers/MIGRATION_STRATEGY.md +++ b/packages/assets-controllers/MIGRATION_STRATEGY.md @@ -322,51 +322,64 @@ The strategy above is designed for **controller-to-controller** reads (via messe Today, asset selectors are split between: - **Some in `core`** (`assets-controllers/src/selectors/`) - **Most in UI** (extension/mobile repos) + - Mobile selectors and references: https://github.com/MetaMask/metamask-mobile/pull/24320 + - Extension selectors and references: https://github.com/MetaMask/metamask-extension/pull/39039 -### Goal +### Challenge -Move all asset selectors to `core`. This enables: -- Centralized compatibility layer (same pattern as controller migration) -- Single source of truth for asset data access +State in the UI is referenced very broadly, not only directly, but indirectly through other selectors, hooks and components. Many of them across multiple teams. -### Recommended Approach: Selectors in Core +It would be hard to expect all those references to be dealt as part of a single release. Therefore, the approach suggested here is to somehow maintain access to both old and new state, whilst we progressively mark selectors as deprecated and move them to the new state. -Create compatibility selectors in `assets-controllers` that UI imports: +### Approaches Considered -``` -// In assets-controllers/src/selectors/ +#### (1) Dummy Controllers -FUNCTION selectTokenBalance(state, hexAddress, chainId, token): - - use_new_state = feature_flag("assets_controller_use_new_state", user_key) - - IF use_new_state THEN - // Convert legacy params → new format for lookup - accountId = lookupAccountId(hexAddress) - assetId = toCAIP19(chainId, token) - RETURN state.AssetsController.assetsBalance[accountId][assetId] - ELSE - RETURN state.TokenBalancesController.tokenBalances[hexAddress][chainId][token] -``` +This approach leaves old controllers as they are, disables any fetching logic and subscriptions, and just subscribes them to the new assets controller state change in order to transform and maintain the state as it currently is. Selectors can be updated progressively. + +The advantage is that this requires no changes at all to state references from the UI. However, public functions for those controllers that update the data or trigger refetches need to be removed or delagated to the new assets controller. + +This approach can take part after phase 4 is completed, as it involves disabling the old controllers logic. + +This approach **does not** require updating all state mock data. + +#### (2) Legacy State in new Assets Controller + +Similar to the previous approach, but instead of keeping old controllers, the new Assets Controllers keeps a property as part of its state to encapsulate legacy data (e.g. `state.metamask.legacyAssetsData.accountsByChainId`). + +This approach means we can completely remove old controllers during cleanup, but references to legacy assets data (including mocks) need to be updated. + +This approach can take part after phase 4 is completed, as it involves disabling the old controllers logic. + +This approach will require updating all state mock data. + +This approach potentially involved a migration as well. + +#### (3) Make the transformations at selector level + +This approach creates base selectors on each client for each state property used by legacy controllers. + +These selectors transform the data from the new controller to the format of the legacy controllers. + +Every reference to that data then needs to be replaced in existing selectors. + +This approach does not use any additional space for state, but it might involve transforming data more frequently. + +This approach will require updating all state mock data. ### UI Migration Timeline -UI migration can follow the same phases as controller migration, but may run on a **separate timeline**: +Trying to make selectors dependant of the feature flag is technically viable, but tedious and, potentially, unperformant. It could also lead to significant amount of branching logic over selectors, components and hooks that will need to be cleaned up afterwards. + +Which means that selectors can only be updated once we are confident that the state of the new assets controller is adequate to be used for all users (End of phase 4). + +### Missing Details and Future Decisions -| Phase | Controllers | UI | -|-------|-------------|-----| -| Phase 1 | Shadow write | No changes (still reads legacy) | -| Phase 2 | Dual-read comparison | Add compatibility selectors, enable comparison logging | -| Phase 3 | Gradual rollout | Same — rollout via feature flag | -| Phase 4 | Confidence period | Same | -| Phase 5 | Cleanup | Remove legacy selectors | +It has been established that we should decide on the best approach once we have a working assets controller that can be enabled merged, as it is difficult to decide on the best approach without the information that will provide. -### Key Considerations +Approaches 1 and 3 seem to provide a reasonable amount of backwards compatibility and give the team time to update assets components and hooks to use the new state, whilst finding out if any additional changes to the new controller and state are needed. -- UI and controller migrations can be **decoupled** — UI can stay on legacy longer if needed -- Same feature flag (`assets_controller_use_new_state`) can control both -- Goal: All asset selectors in `core` — UI imports from `@metamask/assets-controllers` -- Migration may require moving existing UI selectors to `core` first +The team can then provide advice and guidance to other teams to update their own components and hooks, whilst deprecating selectors that are no longer being used. ---