Skip to content
Draft
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
79 changes: 79 additions & 0 deletions packages/assets-controllers/EXTERNAL_USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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 |
Copy link
Contributor Author

Choose a reason for hiding this comment

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

core-backend will be part of the new unified logic; just here to highlight that this is something outside of our assets-controllers package using the tokenBalancesController


## 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).

**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
Copy link
Contributor

Choose a reason for hiding this comment

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

TransactionPayController currently expects balances and addresses in hex format, whereas we plan to provide them in CAIP-19 and numeric formats. Do you think it would be better to expose a helper function that converts to the expected format, or should we ask the Confirmations team to migrate to the new CAIP-19 standard ?

└─► 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`

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.

280 changes: 280 additions & 0 deletions packages/assets-controllers/MIGRATION_STRATEGY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
# 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) |
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this an example or an exhaustive list?

If it's meant to be exhaustive, it is missing MultichainBalancesController.

Copy link
Contributor

Choose a reason for hiding this comment

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

Unless we want to do it separately for evm and non-evm.


### 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

---

### 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
Copy link
Contributor

Choose a reason for hiding this comment

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

does the ETA here include both UI as well ( Mobile and extension ) ?

- [ ] Zero rollbacks triggered during confidence period
- [ ] All external controllers migrated and tested
- [ ] Performance metrics stable

---

## Rollback Strategy

### During Phases 1-4: Instant Rollback

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

### 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

Loading