Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ linkStyle default opacity:0.5
money_account_balance_service --> base_data_service;
money_account_balance_service --> controller_utils;
money_account_balance_service --> messenger;
money_account_balance_service --> money_account_api_data_service;
money_account_balance_service --> network_controller;
money_account_balance_service --> remote_feature_flag_controller;
money_account_controller --> accounts_controller;
Expand Down
4 changes: 4 additions & 0 deletions packages/money-account-api-data-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional nullable `balance` field to the positions response (`musd_balance`, `vmusd_value_in_musd`, `total_balance`), matching the Money Account API contract. Export `PositionBalance` type. ([#9554](https://github.com/MetaMask/core/pull/9554))

## [0.1.0]

### Added
Expand Down
1 change: 1 addition & 0 deletions packages/money-account-api-data-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
} from './money-account-api-data-service-method-action-types';
export type {
PositionResponse,
PositionBalance,
InterestResponse,
HistoryResponse,
RateHistoryResponse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type { MoneyAccountApiDataService } from './money-account-api-data-servic
* Fetches the current vault positions for a given user address.
*
* @param address - The user's Ethereum address.
* @returns The position response containing vault positions.
* @returns The position response containing vault positions and an optional
* `balance` summary (`null` when the API balance path is unavailable).
*/
export type MoneyAccountApiDataServiceFetchPositionsAction = {
type: `MoneyAccountApiDataService:fetchPositions`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@ import {
const MOCK_ADDRESS = '0x1111111111111111111111111111111111111111';
const MOCK_VAULT_ADDRESS = '0x2222222222222222222222222222222222222222';

const MOCK_POSITION_BALANCE = {
musd_balance: '2',
vmusd_value_in_musd: '1513527',
total_balance: '1513529',
};

const MOCK_POSITION_RESPONSE = {
address: MOCK_ADDRESS,
as_of_block: 12345,
as_of_timestamp: '2026-06-01T12:00:00Z',
data_freshness: 'live' as const,
indexer_lag_seconds: 5,
balance: MOCK_POSITION_BALANCE,
positions: [
{
vault_address: MOCK_VAULT_ADDRESS,
Expand Down Expand Up @@ -232,6 +239,52 @@ describe('MoneyAccountApiDataService', () => {
service.destroy();
});

it('accepts a null balance when the API balance path is unavailable', async () => {
const { service } = createService(Env.DEV);
const responseWithNullBalance = {
...MOCK_POSITION_RESPONSE,
balance: null,
};

nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.reply(200, responseWithNullBalance);

const result = await service.fetchPositions(MOCK_ADDRESS);
expect(result).toStrictEqual(responseWithNullBalance);
service.destroy();
});

it('accepts a response that omits the balance field', async () => {
const { service } = createService(Env.DEV);
const { balance: _balance, ...responseWithoutBalance } =
MOCK_POSITION_RESPONSE;

nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.reply(200, responseWithoutBalance);

const result = await service.fetchPositions(MOCK_ADDRESS);
expect(result).toStrictEqual(responseWithoutBalance);
service.destroy();
});

it('throws MoneyAccountApiResponseValidationError on malformed balance', async () => {
const { service } = createService(Env.DEV);

nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.reply(200, {
...MOCK_POSITION_RESPONSE,
balance: { musd_balance: '1' },
});

await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow(
MoneyAccountApiResponseValidationError,
);
service.destroy();
});

it('caches responses via TanStack Query', async () => {
const { service } = createService(Env.DEV);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ export class MoneyAccountApiDataService extends BaseDataService<
* Fetches the current vault positions for a given user address.
*
* @param address - The user's Ethereum address.
* @returns The position response containing vault positions.
* @returns The position response containing vault positions and an optional
* `balance` summary (`null` when the API balance path is unavailable).
*/
async fetchPositions(address: string): Promise<PositionResponse> {
const url = new URL(
Expand Down
10 changes: 10 additions & 0 deletions packages/money-account-api-data-service/src/response.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export type VaultPosition = {
effective_apy: string;
};

/**
* Balance summary on the positions response.
* `null` when the API's wallet-balance path is disabled or unavailable.
*/
export type PositionBalance = {
musd_balance: string;
vmusd_value_in_musd: string;
total_balance: string;
};

/**
* Response from `GET /v1/positions/:address`.
* Derived from {@link PositionResponseStruct} to ensure type/struct parity.
Expand Down
16 changes: 15 additions & 1 deletion packages/money-account-api-data-service/src/structs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import {
array,
boolean,
enums,
nullable,
number,
object,
optional,
string,
nullable,
} from '@metamask/superstruct';

const DataFreshnessStruct = enums(['live', 'degraded']);
Expand All @@ -25,12 +26,25 @@ const VaultPositionStruct = object({
effective_apy: string(),
});

/**
* Wallet + vault balance summary on the positions response.
* `null` when the API's wallet-balance path is disabled or unavailable.
*/
const PositionBalanceStruct = object({
musd_balance: string(),
vmusd_value_in_musd: string(),
total_balance: string(),
});

export const PositionResponseStruct = object({
address: string(),
as_of_block: number(),
as_of_timestamp: string(),
data_freshness: DataFreshnessStruct,
indexer_lag_seconds: number(),
// Optional for backwards compatibility with responses that omit the field;
// when present, may be `null` if the API balance flag is off.
balance: optional(nullable(PositionBalanceStruct)),
positions: array(VaultPositionStruct),
});

Expand Down
6 changes: 6 additions & 0 deletions packages/money-account-balance-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `fetchBalanceWithFallback` facade that selects Money API or RPC balance sources from the `moneyAccountBalanceSource` remote feature flag (`api` | `rpc` | `api-only` | `rpc-only`; default `rpc` = RPC primary with Money API fallback). Returns canonical amounts plus `source` and `usedFallback` provenance; reports validation/unavailable source defects via messenger `captureException`; throws `MoneyAccountBalanceFetchError` when all eligible sources fail. ([#9554](https://github.com/MetaMask/core/pull/9554))
- Permit `MoneyAccountApiDataService:fetchPositions` on the balance service messenger so the facade can read Money API balances. ([#9554](https://github.com/MetaMask/core/pull/9554))
- Export `CanonicalMoneyAccountBalanceResponse`, balance-source constants/types, and `MoneyAccountBalanceFetchError` / `MoneyAccountBalanceUnavailableError` / `MoneyAccountBalanceValidationError`. ([#9554](https://github.com/MetaMask/core/pull/9554))

## [2.2.0]

### Added
Expand Down
52 changes: 51 additions & 1 deletion packages/money-account-balance-service/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,56 @@
# `@metamask/money-account-balance-service`

Data service for fetching Money account balances via on-chain RPC reads, the Veda Accountant exchange rate, and the Veda vault APY from Veda's REST API.
Data service for Money account balances. For presentation, prefer
`fetchBalanceWithFallback`, which selects between the Money Account API and
on-chain Multicall3 RPC according to the `moneyAccountBalanceSource` remote
feature flag.

Also provides lower-level RPC helpers (mUSD / vmUSD / exchange rate), plus
vault APY from Veda's REST API.

## Canonical balance facade

```ts
const result = await service.fetchBalanceWithFallback(accountAddress);
// {
// musdBalance, vmusdValueInMusd, totalBalance,
// source: 'api' | 'rpc',
// usedFallback: boolean,
// }
```

### Feature flag: `moneyAccountBalanceSource`

| Value | Behavior |
| ------------------------------------- | ------------------------------- |
| `rpc` (default when absent/malformed) | RPC primary, Money API fallback |
| `api` | Money API primary, RPC fallback |
| `rpc-only` | RPC only (no fallback) |
| `api-only` | Money API only (no fallback) |

Callers must not select a source. Provenance (`source`, `usedFallback`) is
always returned so fallback is never silent. When both eligible sources fail,
the service throws `MoneyAccountBalanceFetchError` (with `causes`) and never
invents a zero balance.

Malformed or unavailable source balances
(`MoneyAccountBalanceValidationError` / `MoneyAccountBalanceUnavailableError`)
are reported via the messenger's `captureException` before fallback.

### Messenger wiring

The facade calls `MoneyAccountApiDataService:fetchPositions`. Client
composition must permit that action on the balance-service messenger (same
pattern as NetworkController / RemoteFeatureFlagController actions).

### POC resilience note

Balance RPC and third-party vault APY currently share one `BaseDataService`
retry / circuit-breaker policy. A Veda APY outage can affect RPC balance
availability (including facade fallback). Splitting those failure domains is
planned before production reliance on the facade. Source equivalence between
Money API and RPC totals is also not yet proven — keep the default `rpc`
policy until shadow comparison and rollout gates from the ADR are met.

## Installation

Expand Down
1 change: 1 addition & 0 deletions packages/money-account-balance-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@metamask/controller-utils": "^12.3.0",
"@metamask/messenger": "^2.0.0",
"@metamask/metamask-eth-abis": "^3.1.1",
"@metamask/money-account-api-data-service": "^0.1.0",
"@metamask/network-controller": "^34.0.0",
"@metamask/remote-feature-flag-controller": "^4.2.2",
"@metamask/superstruct": "^3.1.0",
Expand Down
37 changes: 37 additions & 0 deletions packages/money-account-balance-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,43 @@ export const MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY =
*/
export const DEFAULT_BALANCE_STALE_TIME = inMilliseconds(1, Duration.Minute);

/**
* The key under which the Money account balance source routing policy is
* stored in `RemoteFeatureFlagController` state's `remoteFeatureFlags` map.
* Falls back to {@link DEFAULT_BALANCE_SOURCE_POLICY} when absent or malformed.
*/
export const MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY =
'moneyAccountBalanceSource';

/**
* Supported values for {@link MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY}.
*
* - `api` — Money API primary, RPC fallback
* - `rpc` — RPC primary, Money API fallback
* - `api-only` — Money API only (incident kill switch)
* - `rpc-only` — RPC only (incident kill switch)
*/
export const BALANCE_SOURCE_POLICIES = [
'api',
'rpc',
'api-only',
'rpc-only',
] as const;

export type BalanceSourcePolicy = (typeof BALANCE_SOURCE_POLICIES)[number];

/**
* Hard default when the routing flag is absent or malformed: RPC primary
* with Money API fallback. API-primary remains opt-in via the feature flag
* until source equivalence is proven.
*/
export const DEFAULT_BALANCE_SOURCE_POLICY: BalanceSourcePolicy = 'rpc';

/**
* Balance source used in the canonical facade result.
*/
export type BalanceSource = 'api' | 'rpc';

export const VEDA_API_NETWORK_NAMES: Record<Hex, string> = {
'0xa4b1': 'arbitrum',
'0x8f': 'monad',
Expand Down
42 changes: 42 additions & 0 deletions packages/money-account-balance-service/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ export class VedaResponseValidationError extends Error {
}
}

/**
* Thrown when a balance source returns data that fails semantic validation
* (e.g. non-integer amounts, or `totalBalance !== musdBalance + vmusdValueInMusd`).
* Reported via the messenger's `captureException` when encountered by
* {@link MoneyAccountBalanceService.fetchBalanceWithFallback}.
*/
export class MoneyAccountBalanceValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoneyAccountBalanceValidationError';
}
}

/**
* Thrown when a balance source is transport-successful but has no usable
* balance (e.g. Money API `balance: null`). Reported via the messenger's
* `captureException` when encountered by
* {@link MoneyAccountBalanceService.fetchBalanceWithFallback}.
*/
export class MoneyAccountBalanceUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoneyAccountBalanceUnavailableError';
}
}

/**
* Thrown when every eligible balance source fails. Preserves each cause for
* diagnostics; never substitutes a zero balance.
*/
export class MoneyAccountBalanceFetchError extends Error {
readonly causes: unknown[];

constructor(causes: unknown[]) {
super(
'MoneyAccountBalanceService: failed to fetch balance from all eligible sources',
);
this.name = 'MoneyAccountBalanceFetchError';
this.causes = causes;
}
}

/**
* Thrown when a public method is called but vault config has not yet been
* loaded from RemoteFeatureFlagController, or the flag key is absent.
Expand Down
11 changes: 11 additions & 0 deletions packages/money-account-balance-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
MoneyAccountBalanceServiceTraceRequest,
} from './money-account-balance-service';
export type {
MoneyAccountBalanceServiceFetchBalanceWithFallbackAction,
MoneyAccountBalanceServiceGetMoneyAccountBalanceAction,
MoneyAccountBalanceServiceGetMusdBalanceAction,
MoneyAccountBalanceServiceGetVmusdBalanceAction,
Expand All @@ -16,12 +17,22 @@ export type {
MoneyAccountBalanceServiceGetVaultApyAction,
} from './money-account-balance-service-method-action-types';
export type {
CanonicalMoneyAccountBalanceResponse,
ExchangeRateResponse,
MoneyAccountBalanceResponse,
MusdEquivalentValueResponse,
NormalizedVaultApyResponse,
} from './response.types';
export type { BalanceSource, BalanceSourcePolicy } from './constants';
export {
BALANCE_SOURCE_POLICIES,
DEFAULT_BALANCE_SOURCE_POLICY,
MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY,
} from './constants';
export {
MoneyAccountBalanceFetchError,
MoneyAccountBalanceUnavailableError,
MoneyAccountBalanceValidationError,
VaultConfigNotAvailableError,
VaultConfigValidationError,
} from './errors';
Expand Down
Loading