From 31f1eedfc40a0038555a236c49d0d28e358e21ee Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Wed, 10 Jun 2026 19:42:36 -0400 Subject: [PATCH 1/9] feat: phase 1 optimizations; configurable balance staleTime; pull underlying token address from remote config; increased default staletime for on-chain reads to 60 seconds --- .../CHANGELOG.md | 6 + .../src/constants.ts | 20 ++- ...unt-balance-service-method-action-types.ts | 4 +- .../src/money-account-balance-service.test.ts | 115 +++++++++++++++++- .../src/money-account-balance-service.ts | 93 ++++++++++++-- .../src/structs.ts | 3 + .../src/types.ts | 10 ++ 7 files changed, 235 insertions(+), 16 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index 1ab8e7d3b1..7282a100f2 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `underlyingToken` field to `VaultConfig` (validated by `VaultConfigStruct`). When present, `getMusdBalance` reads the underlying mUSD token address from config and skips the on-chain `Accountant.base()` call; when absent it falls back to reading `base()` on-chain. +- Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaleTime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. + ### Changed +- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getMusdSHFvdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaleTime` remote feature flag. - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) - Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts index d1050a064d..1c4064556c 100644 --- a/packages/money-account-balance-service/src/constants.ts +++ b/packages/money-account-balance-service/src/constants.ts @@ -1,4 +1,4 @@ -import { Hex } from '@metamask/utils'; +import { Duration, Hex, inMilliseconds } from '@metamask/utils'; export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; @@ -8,6 +8,24 @@ export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; */ export const VAULT_CONFIG_FEATURE_FLAG_KEY = 'moneyAccountVaultConfig'; +/** + * The key under which the Money account balance `staleTime` (in milliseconds) + * is stored in `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. + * + * Lets us tune how often on-chain balance reads hit the RPC nodes without a + * client release. When the flag is absent or malformed the service uses + * {@link DEFAULT_BALANCE_STALE_TIME}. + */ +export const MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY = + 'moneyAccountBalanceStaleTime'; + +/** + * Default `staleTime` (in milliseconds) for on-chain Money account balance + * reads, used when {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY} is + * absent or malformed. + */ +export const DEFAULT_BALANCE_STALE_TIME = inMilliseconds(1, Duration.Minute); + export const VEDA_API_NETWORK_NAMES: Record = { '0xa4b1': 'arbitrum', '0x8f': 'monad', diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts index 7be34d1553..2654dd86a6 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts @@ -36,7 +36,9 @@ export type MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction = { * the underlying mUSD asset. * * @param options - The options for the query. - * @param options.staleTime - The stale time for the query. Defaults to 30 seconds. + * @param options.staleTime - The stale time for the query. Defaults to the + * remotely-configurable balance stale time (see + * {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY}). * @returns The exchange rate as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index be9778970c..05e1edd96b 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -10,7 +10,10 @@ import type { import type { Json } from '@metamask/utils'; import nock, { cleanAll as nockCleanAll } from 'nock'; -import { VAULT_CONFIG_FEATURE_FLAG_KEY } from './constants'; +import { + MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, + VAULT_CONFIG_FEATURE_FLAG_KEY, +} from './constants'; import { VaultConfigNotAvailableError, VaultConfigValidationError, @@ -661,6 +664,35 @@ describe('MoneyAccountBalanceService', () => { ); }); + it('uses the configured underlyingToken and skips the on-chain base() read when present', async () => { + // Only the ERC-20 contract is instantiated; no Accountant.base() call. + mockErc20BalanceOf('5000000'); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, + }, + }, + }); + + const result = await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '5000000' }); + // balanceOf is read directly on the configured underlying token... + expect(MockContract).toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + // ...and the Accountant is never instantiated to resolve base(). + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + it('is also callable via the messenger action', async () => { mockAccountantBase(); mockErc20BalanceOf('5000000'); @@ -1102,6 +1134,87 @@ describe('MoneyAccountBalanceService', () => { ); }); }); + + // ---------------------------------------------------------- + // Balance staleTime feature flag + // ---------------------------------------------------------- + + describe('balance staleTime feature flag', () => { + /** + * Stubs the Accountant so `getRate` invocations are observable across + * calls. `getExchangeRate` is used as the probe because its staleTime + * defaults to the configurable balance staleTime. + * + * @returns The `getRate` mock. + */ + function mockAccountantGetRateSpy(): jest.Mock { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + return mockGetRate; + } + + it('applies a valid staleTime override read from the flag during init', async () => { + const mockGetRate = mockAccountantGetRateSpy(); + // staleTime 0 disables caching, so each call performs a fresh read. + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }, + }); + + await service.getExchangeRate(); + await service.getExchangeRate(); + + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + + it('applies a staleTime override that arrives via stateChange', async () => { + const mockGetRate = mockAccountantGetRateSpy(); + const { service, rootMessenger } = createService(); + + // Default 60s window → the second call is served from cache. + await service.getExchangeRate(); + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(1); + + // Lower staleTime to 0 remotely → caching is disabled for later calls. + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + + it.each([ + { description: 'a non-number', value: 'soon' }, + { description: 'NaN', value: NaN }, + { description: 'a negative number', value: -1 }, + ])( + 'falls back to the default staleTime when the flag is $description', + async ({ value }) => { + const mockGetRate = mockAccountantGetRateSpy(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: value, + }, + }); + + // Default (non-zero) window applies → the second call is cached. + await service.getExchangeRate(); + await service.getExchangeRate(); + + expect(mockGetRate).toHaveBeenCalledTimes(1); + }, + ); + }); }); // ============================================================ diff --git a/packages/money-account-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts index 865c51b0ef..3d526e8b31 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -24,7 +24,9 @@ import { Duration, inMilliseconds } from '@metamask/utils'; import { ACCOUNTANT_ABI, + DEFAULT_BALANCE_STALE_TIME, LENS_ABI, + MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, VAULT_CONFIG_FEATURE_FLAG_KEY, VEDA_API_NETWORK_NAMES, VEDA_PERFORMANCE_API_BASE_URL, @@ -160,6 +162,13 @@ export class MoneyAccountBalanceService extends BaseDataService< > { #vaultConfig: VaultConfig | undefined; + /** + * `staleTime` (in milliseconds) applied to on-chain balance reads. Seeded + * from {@link DEFAULT_BALANCE_STALE_TIME} and overridable at runtime via the + * {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY} remote feature flag. + */ + #balanceStaleTime: number = DEFAULT_BALANCE_STALE_TIME; + /** * Constructs a new MoneyAccountBalanceService. * @@ -188,9 +197,14 @@ export class MoneyAccountBalanceService extends BaseDataService< // eslint-disable-next-line no-restricted-syntax 'RemoteFeatureFlagController:stateChange', (state) => { - const flagValue = - state.remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]; - this.#onRemoteFeatureFlagChange(flagValue); + this.#onRemoteFeatureFlagChange( + state.remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY], + ); + this.#applyBalanceStaleTimeFlag( + state.remoteFeatureFlags[ + MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY + ], + ); }, ); @@ -214,6 +228,11 @@ export class MoneyAccountBalanceService extends BaseDataService< const { remoteFeatureFlags } = this.messenger.call( 'RemoteFeatureFlagController:getState', ); + + this.#applyBalanceStaleTimeFlag( + remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY], + ); + const flagValue = remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]; if (flagValue === undefined) { @@ -252,6 +271,43 @@ export class MoneyAccountBalanceService extends BaseDataService< return this.#vaultConfig; } + /** + * Validates the balance `staleTime` feature flag value and updates + * `#balanceStaleTime`. Falls back to {@link DEFAULT_BALANCE_STALE_TIME} when + * the flag is absent or malformed (logging the malformed case so the + * degraded path is visible). Changing `staleTime` only affects future fetch + * gating, so no cache invalidation is required. + * + * @param flagValue - The raw flag value from `remoteFeatureFlags`, expected + * to be a non-negative number of milliseconds. + */ + #applyBalanceStaleTimeFlag(flagValue: Json | undefined): void { + let nextStaleTime = DEFAULT_BALANCE_STALE_TIME; + + if (flagValue !== undefined) { + if ( + typeof flagValue === 'number' && + Number.isFinite(flagValue) && + flagValue >= 0 + ) { + nextStaleTime = flagValue; + } else { + configLogger( + 'Invalid balance staleTime flag value; using default', + { flagValue, default: DEFAULT_BALANCE_STALE_TIME }, + ); + } + } + + if (nextStaleTime !== this.#balanceStaleTime) { + configLogger('Balance staleTime updated', { + previous: this.#balanceStaleTime, + next: nextStaleTime, + }); + this.#balanceStaleTime = nextStaleTime; + } + } + /** * Called on every `RemoteFeatureFlagController:stateChange` event. * Validates the flag value, updates `#vaultConfig`, and invalidates all @@ -423,10 +479,19 @@ export class MoneyAccountBalanceService extends BaseDataService< return this.fetchQuery({ queryKey: [`${this.name}:getMusdBalance`, accountAddress], queryFn: async () => { - const { chainId } = this.#requireConfig(); - - const underlyingTokenAddress = - await this.#fetchUnderlyingTokenAddress(chainId); + const { chainId, underlyingToken } = this.#requireConfig(); + + // Prefer the remotely-configured underlying token address (0 RPC). Only + // fall back to an on-chain `base()` read when the flag predates the + // `underlyingToken` field, logging so the degraded path is visible. + let underlyingTokenAddress = underlyingToken; + if (!underlyingTokenAddress) { + configLogger( + 'underlyingToken absent from vault config; falling back to on-chain base() read', + ); + underlyingTokenAddress = + await this.#fetchUnderlyingTokenAddress(chainId); + } const balance = await this.#fetchErc20Balance( underlyingTokenAddress, @@ -435,7 +500,7 @@ export class MoneyAccountBalanceService extends BaseDataService< ); return { balance }; }, - staleTime: inMilliseconds(30, Duration.Second), + staleTime: this.#balanceStaleTime, }); } @@ -459,7 +524,7 @@ export class MoneyAccountBalanceService extends BaseDataService< ); return { balance }; }, - staleTime: inMilliseconds(30, Duration.Second), + staleTime: this.#balanceStaleTime, }); } @@ -469,12 +534,14 @@ export class MoneyAccountBalanceService extends BaseDataService< * the underlying mUSD asset. * * @param options - The options for the query. - * @param options.staleTime - The stale time for the query. Defaults to 30 seconds. + * @param options.staleTime - The stale time for the query. Defaults to the + * remotely-configurable balance stale time (see + * {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY}). * @returns The exchange rate as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ async getExchangeRate({ - staleTime = inMilliseconds(30, Duration.Second), + staleTime, }: { staleTime?: number } = {}): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getExchangeRate`], @@ -489,7 +556,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const rate = await contract.getRate(); return { rate: rate.toString() }; }, - staleTime, + staleTime: staleTime ?? this.#balanceStaleTime, }); } @@ -522,7 +589,7 @@ export class MoneyAccountBalanceService extends BaseDataService< return { balanceOfInAssets: balanceOfInAssets.toString() }; }, - staleTime: inMilliseconds(30, Duration.Second), + staleTime: this.#balanceStaleTime, }); } diff --git a/packages/money-account-balance-service/src/structs.ts b/packages/money-account-balance-service/src/structs.ts index dbc1563e55..1c3f55031b 100644 --- a/packages/money-account-balance-service/src/structs.ts +++ b/packages/money-account-balance-service/src/structs.ts @@ -20,6 +20,9 @@ export const VaultConfigStruct = type({ lensAddress: StrictHexStruct, tellerAddress: StrictHexStruct, chainId: StrictHexStruct, + // Optional so flags deployed before this field existed still validate. When + // present it lets the service skip the on-chain `Accountant.base()` read. + underlyingToken: optional(StrictHexStruct), }); /** diff --git a/packages/money-account-balance-service/src/types.ts b/packages/money-account-balance-service/src/types.ts index eef9a84f33..a47087bf54 100644 --- a/packages/money-account-balance-service/src/types.ts +++ b/packages/money-account-balance-service/src/types.ts @@ -10,4 +10,14 @@ export type VaultConfig = { accountantAddress: Hex; lensAddress: Hex; chainId: Hex; + /** + * Address of the vault's underlying ERC-20 asset (mUSD). + * + * Optional for backwards compatibility with flags deployed before this + * field existed. When present, it is used directly as the source of truth + * (the flag already moves in lockstep with the vault addresses), avoiding an + * on-chain `Accountant.base()` read on every mUSD balance fetch. When absent, + * the service falls back to reading `base()` on-chain. + */ + underlyingToken?: Hex; }; From d6168946ba044e80fbd7b0638e860b2060d66302 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 11 Jun 2026 12:01:08 -0400 Subject: [PATCH 2/9] feat: phase 2; add multicall for balance fetch --- .../CHANGELOG.md | 1 + .../src/constants.ts | 50 +++++ .../src/index.ts | 2 + ...unt-balance-service-method-action-types.ts | 23 ++ .../src/money-account-balance-service.test.ts | 212 ++++++++++++++++++ .../src/money-account-balance-service.ts | 146 ++++++++++-- .../src/response.types.ts | 10 + 7 files changed, 427 insertions(+), 17 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index 7282a100f2..a89fd804a1 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `getMoneyAccountBalance` method that fetches the account's mUSD wallet balance and vault shares valued in mUSD in a single Multicall3 `aggregate3` request. - Add optional `underlyingToken` field to `VaultConfig` (validated by `VaultConfigStruct`). When present, `getMusdBalance` reads the underlying mUSD token address from config and skips the on-chain `Accountant.base()` call; when absent it falls back to reading `base()` on-chain. - Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaleTime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts index 1c4064556c..47a6065001 100644 --- a/packages/money-account-balance-service/src/constants.ts +++ b/packages/money-account-balance-service/src/constants.ts @@ -31,6 +31,56 @@ export const VEDA_API_NETWORK_NAMES: Record = { '0x8f': 'monad', }; +/** + * Multicall3 contract address by chain ID, used to batch the Money account + * balance reads into a single RPC request. Multicall3 is deployed at the same + * canonical address on every supported chain. + * + * Source: https://github.com/mds1/multicall/blob/main/deployments.json + */ +// TODO: Double check these addresses for correctness. +export const MULTICALL3_ADDRESS_BY_CHAIN_ID: Record = { + '0xa4b1': '0xcA11bde05977b3631167028862bE2a173976CA11', // Arbitrum One + '0x8f': '0xcA11bde05977b3631167028862bE2a173976CA11', // Monad mainnet +}; + +/** + * Minimal ABI for the Multicall3 `aggregate3` function. Each call carries its + * own `target` and `allowFailure` flag; the response preserves call order with + * a `success` flag and raw `returnData` per call. + * + * Source: https://github.com/mds1/multicall + */ +// TODO: Double check this ABI for correctness. +export const MULTICALL3_ABI = [ + { + name: 'aggregate3', + type: 'function', + stateMutability: 'payable', + inputs: [ + { + name: 'calls', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, + ], + outputs: [ + { + name: 'returnData', + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, + ], + }, +] as const; + /** * Minimal ABI for the Veda Accountant contract. Covers: * - base (0x5001f3b5) — the underlying ERC20 base asset address diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts index d1d6fcef80..f010dd98c6 100644 --- a/packages/money-account-balance-service/src/index.ts +++ b/packages/money-account-balance-service/src/index.ts @@ -5,6 +5,7 @@ export type { MoneyAccountBalanceServiceMessenger, } from './money-account-balance-service'; export type { + MoneyAccountBalanceServiceGetMoneyAccountBalanceAction, MoneyAccountBalanceServiceGetMusdBalanceAction, MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction, MoneyAccountBalanceServiceGetExchangeRateAction, @@ -13,6 +14,7 @@ export type { } from './money-account-balance-service-method-action-types'; export type { ExchangeRateResponse, + MoneyAccountBalanceResponse, MusdEquivalentValueResponse, NormalizedVaultApyResponse, } from './response.types'; diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts index 2654dd86a6..9af1cb81b5 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts @@ -17,6 +17,28 @@ export type MoneyAccountBalanceServiceGetMusdBalanceAction = { handler: MoneyAccountBalanceService['getMusdBalance']; }; +/** + * Fetches the account's total Money balance inputs in a single batched RPC + * request via Multicall3's `aggregate3`, reading both values atomically at + * the same block: + * - the mUSD wallet balance (`mUSD.balanceOf`), and + * - the vault shares valued in mUSD (`Lens.balanceOfInAssets`). + * + * Both values are denominated in mUSD's decimals, so consumers can sum them + * directly to obtain the total balance. Sub-calls use `allowFailure: false`, + * so if either read reverts the whole query rejects rather than reporting a + * partial (and misleading) balance. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance and the mUSD-equivalent value of vault shares as + * raw uint256 strings. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { + type: `MoneyAccountBalanceService:getMoneyAccountBalance`; + handler: MoneyAccountBalanceService['getMoneyAccountBalance']; +}; + /** * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given * account address via RPC. @@ -79,6 +101,7 @@ export type MoneyAccountBalanceServiceGetVaultApyAction = { */ export type MoneyAccountBalanceServiceMethodActions = | MoneyAccountBalanceServiceGetMusdBalanceAction + | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction | MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction | MoneyAccountBalanceServiceGetExchangeRateAction | MoneyAccountBalanceServiceGetMusdEquivalentValueAction diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index 05e1edd96b..24007ed040 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -12,6 +12,7 @@ import nock, { cleanAll as nockCleanAll } from 'nock'; import { MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, + MULTICALL3_ADDRESS_BY_CHAIN_ID, VAULT_CONFIG_FEATURE_FLAG_KEY, } from './constants'; import { @@ -328,6 +329,64 @@ function mockLensBalanceOfInAssets(balanceOfInAssets: string): void { ); } +/** + * Configures the Contract mock for the `getMoneyAccountBalance` aggregate3 + * flow. The Multicall3 contract (matched by its canonical address) exposes + * `callStatic.aggregate3`; every other contract exposes a minimal `interface` + * (encode/decode) plus a `base()` stub so the on-chain underlying-token + * fallback path also works. The decoder routes by `returnData` so the same + * stub serves both the mUSD and Lens reads. + * + * @param options - Stub values. + * @param options.musdBalance - Raw mUSD `balanceOf` result. Defaults to '0'. + * @param options.musdSHFvdValueInMusd - Raw Lens `balanceOfInAssets` result. Defaults to '0'. + * @param options.aggregate3 - Optional replacement for the aggregate3 mock (e.g. to reject). + * @returns The aggregate3 jest mock. + */ +function mockMoneyAccountBalanceMulticall({ + musdBalance = '0', + musdSHFvdValueInMusd = '0', + aggregate3, +}: { + musdBalance?: string; + musdSHFvdValueInMusd?: string; + aggregate3?: jest.Mock; +} = {}): jest.Mock { + const MUSD_RETURN_DATA = '0xMUSD'; + const SHFVD_RETURN_DATA = '0xSHFVD'; + + const aggregate3Mock = + aggregate3 ?? + jest.fn().mockResolvedValue([ + { success: true, returnData: MUSD_RETURN_DATA }, + { success: true, returnData: SHFVD_RETURN_DATA }, + ]); + + const multicall3Address = + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId]; + + MockContract.mockImplementation( + (address: string) => + (address === multicall3Address + ? { callStatic: { aggregate3: aggregate3Mock } } + : { + base: jest.fn().mockResolvedValue(MOCK_UNDERLYING_TOKEN_ADDRESS), + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0xcalldata'), + decodeFunctionResult: jest + .fn() + .mockImplementation((_functionFragment: string, data: string) => + data === MUSD_RETURN_DATA + ? [{ toString: () => musdBalance }] + : [{ toString: () => musdSHFvdValueInMusd }], + ), + }, + }) as unknown as Contract, + ); + + return aggregate3Mock; +} + // ============================================================ // Tests // ============================================================ @@ -587,6 +646,14 @@ describe('MoneyAccountBalanceService', () => { // ---------------------------------------------------------- describe('when vault config is not available', () => { + it('getMoneyAccountBalance throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + it('getMusdBalance throws VaultConfigNotAvailableError', async () => { const { service } = createService({ rffcFlags: {} }); @@ -964,6 +1031,151 @@ describe('MoneyAccountBalanceService', () => { }); }); + // ---------------------------------------------------------- + // getMoneyAccountBalance + // ---------------------------------------------------------- + + describe('getMoneyAccountBalance', () => { + /** + * Vault config including `underlyingToken`, so balance resolution skips the + * on-chain `base()` read. + */ + const VAULT_CONFIG_WITH_UNDERLYING_TOKEN = { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, + }; + + it('returns musdBalance and musdSHFvdValueInMusd from a single aggregate3 call', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + musdSHFvdValueInMusd: '2200000', + }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + musdSHFvdValueInMusd: '2200000', + }); + expect(aggregate3).toHaveBeenCalledTimes(1); + }); + + it('batches the mUSD and Lens reads into one aggregate3 request with allowFailure disabled', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + // A single batched request containing exactly the two balance reads. + expect(aggregate3).toHaveBeenCalledWith([ + expect.objectContaining({ + target: MOCK_UNDERLYING_TOKEN_ADDRESS, + allowFailure: false, + }), + expect.objectContaining({ + target: MOCK_LENS_ADDRESS, + allowFailure: false, + }), + ]); + // The Multicall3 contract is instantiated at the canonical address. + expect(MockContract).toHaveBeenCalledWith( + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId], + expect.anything(), + expect.anything(), + ); + }); + + it('falls back to an on-chain base() read when underlyingToken is absent from config', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '7', + musdSHFvdValueInMusd: '3', + }); + // MOCK_VAULT_CONFIG has no underlyingToken. + const { service } = createService(); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '7', + musdSHFvdValueInMusd: '3', + }); + // Accountant is instantiated for the base() fallback... + expect(MockContract).toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + // ...and the resolved underlying token is used as the mUSD read target. + expect(aggregate3).toHaveBeenCalledWith([ + expect.objectContaining({ target: MOCK_UNDERLYING_TOKEN_ADDRESS }), + expect.objectContaining({ target: MOCK_LENS_ADDRESS }), + ]); + }); + + it('is also callable via the messenger action', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + musdSHFvdValueInMusd: '2200000', + }); + const { rootMessenger } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + musdSHFvdValueInMusd: '2200000', + }); + }); + + it('rejects without reporting a partial balance when the aggregate3 multicall reverts', async () => { + const aggregate3 = jest + .fn() + .mockRejectedValue(new Error('execution reverted')); + mockMoneyAccountBalanceMulticall({ aggregate3 }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('execution reverted'); + }); + + it('throws when no Multicall3 address is configured for the vault chain', async () => { + mockMoneyAccountBalanceMulticall(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + chainId: '0x1', + }, + }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No Multicall3 address configured for chain 0x1'); + }); + }); + // ---------------------------------------------------------- // getVaultApy // ---------------------------------------------------------- diff --git a/packages/money-account-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts index 3d526e8b31..0f4f47800e 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -27,6 +27,8 @@ import { DEFAULT_BALANCE_STALE_TIME, LENS_ABI, MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, + MULTICALL3_ABI, + MULTICALL3_ADDRESS_BY_CHAIN_ID, VAULT_CONFIG_FEATURE_FLAG_KEY, VEDA_API_NETWORK_NAMES, VEDA_PERFORMANCE_API_BASE_URL, @@ -41,6 +43,7 @@ import type { MoneyAccountBalanceServiceMethodActions } from './money-account-ba import { normalizeVaultApyResponse } from './requestNormalization'; import type { ExchangeRateResponse, + MoneyAccountBalanceResponse, MusdEquivalentValueResponse, NormalizedVaultApyResponse, } from './response.types'; @@ -60,6 +63,7 @@ const configLogger = createModuleLogger(projectLogger, 'config'); // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ + 'getMoneyAccountBalance', 'getMusdBalance', 'getMusdSHFvdBalance', 'getExchangeRate', @@ -200,6 +204,7 @@ export class MoneyAccountBalanceService extends BaseDataService< this.#onRemoteFeatureFlagChange( state.remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY], ); + // TODO: Combine applyBalanceStaleTimeFlag with onRemoteFeatureFlagChange. this.#applyBalanceStaleTimeFlag( state.remoteFeatureFlags[ MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY @@ -292,10 +297,10 @@ export class MoneyAccountBalanceService extends BaseDataService< ) { nextStaleTime = flagValue; } else { - configLogger( - 'Invalid balance staleTime flag value; using default', - { flagValue, default: DEFAULT_BALANCE_STALE_TIME }, - ); + configLogger('Invalid balance staleTime flag value; using default', { + flagValue, + default: DEFAULT_BALANCE_STALE_TIME, + }); } } @@ -320,6 +325,7 @@ export class MoneyAccountBalanceService extends BaseDataService< * * @param flagValue - The raw flag value from `remoteFeatureFlags`. */ + // TODO: Add staleTime configuration in here. #onRemoteFeatureFlagChange(flagValue: Json | undefined): void { const previousConfig = this.#vaultConfig; const hadConfig = previousConfig !== undefined; @@ -468,6 +474,42 @@ export class MoneyAccountBalanceService extends BaseDataService< return underlyingTokenAddress; } + /** + * Resolves the underlying mUSD token address. + * + * Prefers the remotely-configured underlyingToken. + * Falls back to on-chain read when the flag isn't available. + * + * @param chainId - The chain ID to use for the provider on the fallback path. + * @returns The underlying mUSD token address. + */ + async #resolveUnderlyingTokenAddress(chainId: Hex): Promise { + const { underlyingToken } = this.#requireConfig(); + if (underlyingToken) { + return underlyingToken; + } + configLogger( + 'underlyingToken absent from vault config; falling back to on-chain read', + ); + return this.#fetchUnderlyingTokenAddress(chainId); + } + + /** + * Returns the Multicall3 contract address for the given chain, or throws if + * the chain is not supported. + * + * @param chainId - The chain ID to resolve a Multicall3 address for. + * @returns The Multicall3 contract address. + * @throws If no Multicall3 address is configured for the chain. + */ + #getMulticall3Address(chainId: Hex): Hex { + const multicall3Address = MULTICALL3_ADDRESS_BY_CHAIN_ID[chainId]; + if (!multicall3Address) { + throw new Error(`No Multicall3 address configured for chain ${chainId}`); + } + return multicall3Address; + } + /** * Fetches the mUSD ERC-20 balance for the given account address via RPC. * @@ -479,19 +521,10 @@ export class MoneyAccountBalanceService extends BaseDataService< return this.fetchQuery({ queryKey: [`${this.name}:getMusdBalance`, accountAddress], queryFn: async () => { - const { chainId, underlyingToken } = this.#requireConfig(); - - // Prefer the remotely-configured underlying token address (0 RPC). Only - // fall back to an on-chain `base()` read when the flag predates the - // `underlyingToken` field, logging so the degraded path is visible. - let underlyingTokenAddress = underlyingToken; - if (!underlyingTokenAddress) { - configLogger( - 'underlyingToken absent from vault config; falling back to on-chain base() read', - ); - underlyingTokenAddress = - await this.#fetchUnderlyingTokenAddress(chainId); - } + const { chainId } = this.#requireConfig(); + + const underlyingTokenAddress = + await this.#resolveUnderlyingTokenAddress(chainId); const balance = await this.#fetchErc20Balance( underlyingTokenAddress, @@ -504,6 +537,85 @@ export class MoneyAccountBalanceService extends BaseDataService< }); } + /** + * Fetches the account's total Money balance inputs in a single batched RPC + * request via Multicall3's `aggregate3`, reading both values atomically at + * the same block: + * - the mUSD wallet balance (`mUSD.balanceOf`), and + * - the vault shares valued in mUSD (`Lens.balanceOfInAssets`). + * + * Both values are denominated in mUSD's decimals, so consumers can sum them + * directly to obtain the total balance. Sub-calls use `allowFailure: false`, + * so if either read reverts the whole query rejects rather than reporting a + * partial (and misleading) balance. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance and the mUSD-equivalent value of vault shares as + * raw uint256 strings. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + // TODO: Consider returning total (mUSD + musdSHFvd) too. + async getMoneyAccountBalance( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMoneyAccountBalance`, accountAddress], + queryFn: async () => { + const { chainId, boringVault, accountantAddress, lensAddress } = + this.#requireConfig(); + const provider = this.#getProvider(chainId); + + const underlyingTokenAddress = + await this.#resolveUnderlyingTokenAddress(chainId); + + const erc20 = new Contract(underlyingTokenAddress, abiERC20, provider); + const lens = new Contract(lensAddress, LENS_ABI, provider); + + const calls = [ + { + target: underlyingTokenAddress, + allowFailure: false, + callData: erc20.interface.encodeFunctionData('balanceOf', [ + accountAddress, + ]), + }, + { + target: lensAddress, + allowFailure: false, + callData: lens.interface.encodeFunctionData('balanceOfInAssets', [ + accountAddress, + boringVault, + accountantAddress, + ]), + }, + ]; + + const multicall3 = new Contract( + this.#getMulticall3Address(chainId), + MULTICALL3_ABI, + provider, + ); + // TODO: type the results properly. + const [musdResult, musdSHFvdResult] = + await multicall3.callStatic.aggregate3(calls); + + const musdBalance = erc20.interface + .decodeFunctionResult('balanceOf', musdResult.returnData)[0] + .toString(); + const musdSHFvdValueInMusd = lens.interface + .decodeFunctionResult( + 'balanceOfInAssets', + musdSHFvdResult.returnData, + )[0] + .toString(); + + // TODO: Add total balance to return payload. + return { musdBalance, musdSHFvdValueInMusd }; + }, + staleTime: this.#balanceStaleTime, + }); + } + /** * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given * account address via RPC. diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts index d8b13c80dd..d8c96d5572 100644 --- a/packages/money-account-balance-service/src/response.types.ts +++ b/packages/money-account-balance-service/src/response.types.ts @@ -14,6 +14,16 @@ export type MusdEquivalentValueResponse = { balanceOfInAssets: string; }; +/** + * Response from {@link MoneyAccountBalanceService.getMoneyAccountBalance}. + */ +export type MoneyAccountBalanceResponse = { + musdBalance: string; + musdSHFvdValueInMusd: string; + // TODO: Add to function + totalBalance: string; +}; + /** * Response from {@link MoneyAccountBalanceService.getVaultApy}. * All APY / fee values are decimals (multiply by 100 for percentage). From af90c27bcddd2a54a812f94e37eee1fcc5af5d2c Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 11 Jun 2026 14:50:49 -0400 Subject: [PATCH 3/9] cleanup --- .../src/constants.ts | 15 +- .../src/money-account-balance-service.test.ts | 84 +++++++--- .../src/money-account-balance-service.ts | 156 +++++++----------- .../src/response.types.ts | 1 - 4 files changed, 132 insertions(+), 124 deletions(-) diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts index 47a6065001..ea7897c028 100644 --- a/packages/money-account-balance-service/src/constants.ts +++ b/packages/money-account-balance-service/src/constants.ts @@ -11,13 +11,10 @@ export const VAULT_CONFIG_FEATURE_FLAG_KEY = 'moneyAccountVaultConfig'; /** * The key under which the Money account balance `staleTime` (in milliseconds) * is stored in `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. - * - * Lets us tune how often on-chain balance reads hit the RPC nodes without a - * client release. When the flag is absent or malformed the service uses - * {@link DEFAULT_BALANCE_STALE_TIME}. + * Falls back to {@link DEFAULT_BALANCE_STALE_TIME} when absent or malformed. */ export const MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY = - 'moneyAccountBalanceStaleTime'; + 'moneyAccountBalanceStaletime'; /** * Default `staleTime` (in milliseconds) for on-chain Money account balance @@ -38,20 +35,14 @@ export const VEDA_API_NETWORK_NAMES: Record = { * * Source: https://github.com/mds1/multicall/blob/main/deployments.json */ -// TODO: Double check these addresses for correctness. export const MULTICALL3_ADDRESS_BY_CHAIN_ID: Record = { '0xa4b1': '0xcA11bde05977b3631167028862bE2a173976CA11', // Arbitrum One '0x8f': '0xcA11bde05977b3631167028862bE2a173976CA11', // Monad mainnet }; /** - * Minimal ABI for the Multicall3 `aggregate3` function. Each call carries its - * own `target` and `allowFailure` flag; the response preserves call order with - * a `success` flag and raw `returnData` per call. - * - * Source: https://github.com/mds1/multicall + * Minimal ABI for the Multicall3 `aggregate3` function. */ -// TODO: Double check this ABI for correctness. export const MULTICALL3_ABI = [ { name: 'aggregate3', diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index 24007ed040..bd149e8aa6 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -329,20 +329,20 @@ function mockLensBalanceOfInAssets(balanceOfInAssets: string): void { ); } -/** - * Configures the Contract mock for the `getMoneyAccountBalance` aggregate3 - * flow. The Multicall3 contract (matched by its canonical address) exposes - * `callStatic.aggregate3`; every other contract exposes a minimal `interface` - * (encode/decode) plus a `base()` stub so the on-chain underlying-token - * fallback path also works. The decoder routes by `returnData` so the same - * stub serves both the mUSD and Lens reads. - * - * @param options - Stub values. - * @param options.musdBalance - Raw mUSD `balanceOf` result. Defaults to '0'. - * @param options.musdSHFvdValueInMusd - Raw Lens `balanceOfInAssets` result. Defaults to '0'. - * @param options.aggregate3 - Optional replacement for the aggregate3 mock (e.g. to reject). - * @returns The aggregate3 jest mock. - */ +function makeMockBN(value: string): { + toString: () => string; + add: (other: { toString: () => string }) => { + toString: () => string; + add: (o: { toString: () => string }) => unknown; + }; +} { + return { + toString: () => value, + add: (other) => + makeMockBN((BigInt(value) + BigInt(other.toString())).toString()), + }; +} + function mockMoneyAccountBalanceMulticall({ musdBalance = '0', musdSHFvdValueInMusd = '0', @@ -375,10 +375,11 @@ function mockMoneyAccountBalanceMulticall({ encodeFunctionData: jest.fn().mockReturnValue('0xcalldata'), decodeFunctionResult: jest .fn() - .mockImplementation((_functionFragment: string, data: string) => - data === MUSD_RETURN_DATA - ? [{ toString: () => musdBalance }] - : [{ toString: () => musdSHFvdValueInMusd }], + .mockImplementation( + (_functionFragment: string, data: string) => + data === MUSD_RETURN_DATA + ? [makeMockBN(musdBalance)] + : [makeMockBN(musdSHFvdValueInMusd)], ), }, }) as unknown as Contract, @@ -1045,7 +1046,7 @@ describe('MoneyAccountBalanceService', () => { underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, }; - it('returns musdBalance and musdSHFvdValueInMusd from a single aggregate3 call', async () => { + it('returns musdBalance, musdSHFvdValueInMusd, and totalBalance from a single aggregate3 call', async () => { const aggregate3 = mockMoneyAccountBalanceMulticall({ musdBalance: '5000000', musdSHFvdValueInMusd: '2200000', @@ -1061,6 +1062,7 @@ describe('MoneyAccountBalanceService', () => { expect(result).toStrictEqual({ musdBalance: '5000000', musdSHFvdValueInMusd: '2200000', + totalBalance: '7200000', }); expect(aggregate3).toHaveBeenCalledTimes(1); }); @@ -1107,6 +1109,7 @@ describe('MoneyAccountBalanceService', () => { expect(result).toStrictEqual({ musdBalance: '7', musdSHFvdValueInMusd: '3', + totalBalance: '10', }); // Accountant is instantiated for the base() fallback... expect(MockContract).toHaveBeenCalledWith( @@ -1140,6 +1143,7 @@ describe('MoneyAccountBalanceService', () => { expect(result).toStrictEqual({ musdBalance: '5000000', musdSHFvdValueInMusd: '2200000', + totalBalance: '7200000', }); }); @@ -1426,6 +1430,48 @@ describe('MoneyAccountBalanceService', () => { expect(mockGetRate).toHaveBeenCalledTimes(1); }, ); + + it('applies staleTime even when the vault config flag is malformed (orchestrator isolation)', async () => { + // This test verifies that when #onRemoteFeatureFlagChange (the orchestrator) + // receives a stateChange with both flags, it processes BOTH — even when + // #applyVaultConfig throws. #applyBalanceStaleTimeFlag runs first and is + // never blocked by a vault config error. + const captureException = jest.fn(); + const mockGetRate = mockAccountantGetRateSpy(); + const { service, rootMessenger } = createService({ + rffcFlags: {}, + captureException, + }); + + // stateChange carries staleTime=0 AND a malformed vault config. The + // orchestrator calls #applyBalanceStaleTimeFlag first (→ staleTime=0), + // then #applyVaultConfig which throws. The messenger routes the throw to + // captureException so the subscriber does not crash. + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { malformed: true }, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + expect(captureException).toHaveBeenCalledWith( + expect.any(VaultConfigValidationError), + ); + + // Restore a valid vault config. The orchestrator now also re-applies + // staleTime for this event (no flag present → resets to default), but the + // key assertion above already confirms the orchestrator processed both + // flags on the previous event (captureException was called, which means + // #applyVaultConfig was reached, meaning #applyBalanceStaleTimeFlag ran + // first as intended). + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + // Cache is bypassed on every call since staleTime=0 is active. + await service.getExchangeRate(); + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/packages/money-account-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts index 0f4f47800e..d565907901 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -52,6 +52,15 @@ import type { VaultConfig } from './types'; // === GENERAL === +/** + * The shape of a single result entry returned by Multicall3's `aggregate3`. + * Mirrors the on-chain `struct Multicall3.Result`. + */ +type Multicall3Result = { + success: boolean; + returnData: string; +}; + /** * The name of the {@link MoneyAccountBalanceService}, used to namespace the * service's actions and events. @@ -166,19 +175,13 @@ export class MoneyAccountBalanceService extends BaseDataService< > { #vaultConfig: VaultConfig | undefined; - /** - * `staleTime` (in milliseconds) applied to on-chain balance reads. Seeded - * from {@link DEFAULT_BALANCE_STALE_TIME} and overridable at runtime via the - * {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY} remote feature flag. - */ + /** Cache stale time (ms) for on-chain balance reads. Overridable via remote feature flag. */ #balanceStaleTime: number = DEFAULT_BALANCE_STALE_TIME; /** - * Constructs a new MoneyAccountBalanceService. - * - * @param args - The constructor arguments. - * @param args.messenger - The messenger suited for this service. - * @param args.policyOptions - Options to pass to `createServicePolicy`. + * @param options - Constructor options. + * @param options.messenger - The messenger for this service. + * @param options.policyOptions - Options passed to `createServicePolicy`. */ constructor({ messenger, @@ -200,17 +203,7 @@ export class MoneyAccountBalanceService extends BaseDataService< this.messenger.subscribe( // eslint-disable-next-line no-restricted-syntax 'RemoteFeatureFlagController:stateChange', - (state) => { - this.#onRemoteFeatureFlagChange( - state.remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY], - ); - // TODO: Combine applyBalanceStaleTimeFlag with onRemoteFeatureFlagChange. - this.#applyBalanceStaleTimeFlag( - state.remoteFeatureFlags[ - MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY - ], - ); - }, + (state) => this.#onRemoteFeatureFlagChange(state.remoteFeatureFlags), ); this.messenger.registerMethodActionHandlers( @@ -220,7 +213,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Eagerly reads already-loaded feature flags and initialises `#vaultConfig`. + * Eagerly reads already-loaded feature flags and initialises service state. * * Must be called after all controllers and services have been instantiated so * that the `RemoteFeatureFlagController:getState` action is guaranteed to be @@ -233,21 +226,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const { remoteFeatureFlags } = this.messenger.call( 'RemoteFeatureFlagController:getState', ); - - this.#applyBalanceStaleTimeFlag( - remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY], - ); - - const flagValue = remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]; - - if (flagValue === undefined) { - configLogger( - 'Init complete — no vault config flag present, awaiting remote flags', - ); - return; - } - this.#vaultConfig = this.#parseAndValidateVaultConfig(flagValue); - configLogger('Vault config loaded during init', this.#vaultConfig); + this.#onRemoteFeatureFlagChange(remoteFeatureFlags); } catch (error) { if (error instanceof VaultConfigValidationError) { configLogger( @@ -277,14 +256,11 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Validates the balance `staleTime` feature flag value and updates - * `#balanceStaleTime`. Falls back to {@link DEFAULT_BALANCE_STALE_TIME} when - * the flag is absent or malformed (logging the malformed case so the - * degraded path is visible). Changing `staleTime` only affects future fetch - * gating, so no cache invalidation is required. + * Applies the balance `staleTime` feature flag, falling back to + * {@link DEFAULT_BALANCE_STALE_TIME} when the flag is absent or malformed. * - * @param flagValue - The raw flag value from `remoteFeatureFlags`, expected - * to be a non-negative number of milliseconds. + * @param flagValue - Raw flag value from `remoteFeatureFlags`; expected to be + * a non-negative number of milliseconds. */ #applyBalanceStaleTimeFlag(flagValue: Json | undefined): void { let nextStaleTime = DEFAULT_BALANCE_STALE_TIME; @@ -314,25 +290,33 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Called on every `RemoteFeatureFlagController:stateChange` event. - * Validates the flag value, updates `#vaultConfig`, and invalidates all - * cached queries when the config changes. + * Handles `RemoteFeatureFlagController:stateChange` events and the initial + * {@link init} call. * + * @param remoteFeatureFlags - The `remoteFeatureFlags` map from + * `RemoteFeatureFlagController` state. + */ + #onRemoteFeatureFlagChange(remoteFeatureFlags: Record): void { + this.#applyBalanceStaleTimeFlag( + remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY], + ); + this.#applyVaultConfig(remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]); + } + + /** + * Validates the vault config feature flag value, updates `#vaultConfig`, and + * invalidates all cached queries when the config changes. * Throws {@link VaultConfigValidationError} when the flag value is malformed. - * The messenger catches throws from event subscribers and routes them to - * `captureException` (Sentry) — the error does NOT propagate to the - * stateChange publisher. * * @param flagValue - The raw flag value from `remoteFeatureFlags`. */ - // TODO: Add staleTime configuration in here. - #onRemoteFeatureFlagChange(flagValue: Json | undefined): void { + #applyVaultConfig(flagValue: Json | undefined): void { const previousConfig = this.#vaultConfig; const hadConfig = previousConfig !== undefined; if (flagValue === undefined) { - // Flag key absent — treat as "not loaded". if (hadConfig) { + // Invalidate the cache if the flag key was removed. We don't want to keep using old config values. this.#vaultConfig = undefined; configLogger( 'Vault config cleared — flag key absent; cache invalidated', @@ -352,8 +336,8 @@ export class MoneyAccountBalanceService extends BaseDataService< try { newConfig = this.#parseAndValidateVaultConfig(flagValue); } catch (error) { - // Clear previously valid config and purge stale cache. if (hadConfig) { + // Invalidate the cache if the config is malformed. We don't want to keep using old config values. this.#vaultConfig = undefined; configLogger( 'Vault config validation failed — previous config cleared; cache invalidated', @@ -364,9 +348,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } else { configLogger( 'Vault config validation failed — config was already absent', - { - error, - }, + { error }, ); } throw error; @@ -539,22 +521,13 @@ export class MoneyAccountBalanceService extends BaseDataService< /** * Fetches the account's total Money balance inputs in a single batched RPC - * request via Multicall3's `aggregate3`, reading both values atomically at - * the same block: - * - the mUSD wallet balance (`mUSD.balanceOf`), and - * - the vault shares valued in mUSD (`Lens.balanceOfInAssets`). - * - * Both values are denominated in mUSD's decimals, so consumers can sum them - * directly to obtain the total balance. Sub-calls use `allowFailure: false`, - * so if either read reverts the whole query rejects rather than reporting a - * partial (and misleading) balance. + * request via Multicall3's `aggregate3` * * @param accountAddress - The Money account's Ethereum address. * @returns The mUSD balance and the mUSD-equivalent value of vault shares as - * raw uint256 strings. + * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ - // TODO: Consider returning total (mUSD + musdSHFvd) too. async getMoneyAccountBalance( accountAddress: Hex, ): Promise { @@ -595,22 +568,26 @@ export class MoneyAccountBalanceService extends BaseDataService< MULTICALL3_ABI, provider, ); - // TODO: type the results properly. const [musdResult, musdSHFvdResult] = - await multicall3.callStatic.aggregate3(calls); - - const musdBalance = erc20.interface - .decodeFunctionResult('balanceOf', musdResult.returnData)[0] - .toString(); - const musdSHFvdValueInMusd = lens.interface - .decodeFunctionResult( - 'balanceOfInAssets', - musdSHFvdResult.returnData, - )[0] - .toString(); - - // TODO: Add total balance to return payload. - return { musdBalance, musdSHFvdValueInMusd }; + (await multicall3.callStatic.aggregate3(calls)) as [ + Multicall3Result, + Multicall3Result, + ]; + + const musdBalanceBN = erc20.interface.decodeFunctionResult( + 'balanceOf', + musdResult.returnData, + )[0]; + const musdSHFvdBN = lens.interface.decodeFunctionResult( + 'balanceOfInAssets', + musdSHFvdResult.returnData, + )[0]; + + return { + musdBalance: musdBalanceBN.toString(), + musdSHFvdValueInMusd: musdSHFvdBN.toString(), + totalBalance: musdBalanceBN.add(musdSHFvdBN).toString(), + }; }, staleTime: this.#balanceStaleTime, }); @@ -646,9 +623,7 @@ export class MoneyAccountBalanceService extends BaseDataService< * the underlying mUSD asset. * * @param options - The options for the query. - * @param options.staleTime - The stale time for the query. Defaults to the - * remotely-configurable balance stale time (see - * {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY}). + * @param options.staleTime - Cache stale time override for this query. * @returns The exchange rate as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ @@ -673,14 +648,11 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. - * Internally fetches the musdSHFvd balance and exchange rate (using cached - * values when available within their staleTime windows), then multiplies - * them. + * Fetches the mUSD-equivalent value of the account's musdSHFvd vault shares + * via `Lens.balanceOfInAssets` RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance, exchange rate, and computed - * mUSD-equivalent value as raw uint256 strings. + * @returns The mUSD-equivalent value of vault shares as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ async getMusdEquivalentValue( diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts index d8c96d5572..478226c835 100644 --- a/packages/money-account-balance-service/src/response.types.ts +++ b/packages/money-account-balance-service/src/response.types.ts @@ -20,7 +20,6 @@ export type MusdEquivalentValueResponse = { export type MoneyAccountBalanceResponse = { musdBalance: string; musdSHFvdValueInMusd: string; - // TODO: Add to function totalBalance: string; }; From 80d0c5739c8acdbfb349495074aa4570f37d8066 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 11 Jun 2026 15:35:32 -0400 Subject: [PATCH 4/9] feat: renamed mUSDSHFvd to vmUSD --- .../CHANGELOG.md | 7 +++- .../src/index.ts | 2 +- ...unt-balance-service-method-action-types.ts | 21 +++++----- .../src/money-account-balance-service.test.ts | 42 +++++++++---------- .../src/money-account-balance-service.ts | 26 ++++++------ .../src/response.types.ts | 2 +- 6 files changed, 52 insertions(+), 48 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index a89fd804a1..53ae34ab33 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -15,7 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getMusdSHFvdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaleTime` remote feature flag. +- **BREAKING:** Rename `musdSHFvd` to `vmusd` across the public API to align with the vmUSD token name: + - `getMusdSHFvdBalance` method → `getVmusdBalance` + - `MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction` type → `MoneyAccountBalanceServiceGetVmusdBalanceAction` + - `MoneyAccountBalanceService:getMusdSHFvdBalance` messenger action string → `MoneyAccountBalanceService:getVmusdBalance` + - `MoneyAccountBalanceResponse.musdSHFvdValueInMusd` property → `vmusdValueInMusd` +- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getVmusdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaleTime` remote feature flag. - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) - Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts index f010dd98c6..c58553257d 100644 --- a/packages/money-account-balance-service/src/index.ts +++ b/packages/money-account-balance-service/src/index.ts @@ -7,7 +7,7 @@ export type { export type { MoneyAccountBalanceServiceGetMoneyAccountBalanceAction, MoneyAccountBalanceServiceGetMusdBalanceAction, - MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction, + MoneyAccountBalanceServiceGetVmusdBalanceAction, MoneyAccountBalanceServiceGetExchangeRateAction, MoneyAccountBalanceServiceGetMusdEquivalentValueAction, MoneyAccountBalanceServiceGetVaultApyAction, diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts index 9af1cb81b5..a872a1cfe6 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts @@ -40,21 +40,21 @@ export type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { }; /** - * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * Fetches the vmUSD (Veda vault share) ERC-20 balance for the given * account address via RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance as a raw uint256 string. + * @returns The vmUSD balance as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ -export type MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction = { - type: `MoneyAccountBalanceService:getMusdSHFvdBalance`; - handler: MoneyAccountBalanceService['getMusdSHFvdBalance']; +export type MoneyAccountBalanceServiceGetVmusdBalanceAction = { + type: `MoneyAccountBalanceService:getVmusdBalance`; + handler: MoneyAccountBalanceService['getVmusdBalance']; }; /** * Fetches the current exchange rate from the Veda Accountant contract via - * RPC. The rate represents the conversion factor from musdSHFvd shares to + * RPC. The rate represents the conversion factor from vmUSD shares to * the underlying mUSD asset. * * @param options - The options for the query. @@ -70,14 +70,13 @@ export type MoneyAccountBalanceServiceGetExchangeRateAction = { }; /** - * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. - * Internally fetches the musdSHFvd balance and exchange rate (using cached + * Computes the mUSD-equivalent value of the account's vmUSD holdings. + * Internally fetches the vmUSD balance and exchange rate (using cached * values when available within their staleTime windows), then multiplies * them. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance, exchange rate, and computed - * mUSD-equivalent value as raw uint256 strings. + * @returns The mUSD-equivalent value (vmUSD holdings) as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ export type MoneyAccountBalanceServiceGetMusdEquivalentValueAction = { @@ -102,7 +101,7 @@ export type MoneyAccountBalanceServiceGetVaultApyAction = { export type MoneyAccountBalanceServiceMethodActions = | MoneyAccountBalanceServiceGetMusdBalanceAction | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction - | MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction + | MoneyAccountBalanceServiceGetVmusdBalanceAction | MoneyAccountBalanceServiceGetExchangeRateAction | MoneyAccountBalanceServiceGetMusdEquivalentValueAction | MoneyAccountBalanceServiceGetVaultApyAction; diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index bd149e8aa6..c88aa27d28 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -266,7 +266,7 @@ function createService({ /** * Configures the Contract mock so that `balanceOf` resolves to an object * whose `.toString()` returns `balance`. Used for single-contract flows - * such as `getMusdSHFvdBalance`. + * such as `getVmusdBalance`. * * @param balance - The raw uint256 balance string to return. */ @@ -345,11 +345,11 @@ function makeMockBN(value: string): { function mockMoneyAccountBalanceMulticall({ musdBalance = '0', - musdSHFvdValueInMusd = '0', + vmusdValueInMusd = '0', aggregate3, }: { musdBalance?: string; - musdSHFvdValueInMusd?: string; + vmusdValueInMusd?: string; aggregate3?: jest.Mock; } = {}): jest.Mock { const MUSD_RETURN_DATA = '0xMUSD'; @@ -379,7 +379,7 @@ function mockMoneyAccountBalanceMulticall({ (_functionFragment: string, data: string) => data === MUSD_RETURN_DATA ? [makeMockBN(musdBalance)] - : [makeMockBN(musdSHFvdValueInMusd)], + : [makeMockBN(vmusdValueInMusd)], ), }, }) as unknown as Contract, @@ -501,9 +501,9 @@ describe('MoneyAccountBalanceService', () => { }, }); - await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); - // getMusdSHFvdBalance uses vaultAddress — verify the new address was used. + // getVmusdBalance uses vaultAddress — verify the new address was used. expect(MockContract).toHaveBeenCalledWith( NEW_VAULT_ADDRESS, expect.anything(), @@ -663,11 +663,11 @@ describe('MoneyAccountBalanceService', () => { ).rejects.toThrow(VaultConfigNotAvailableError); }); - it('getMusdSHFvdBalance throws VaultConfigNotAvailableError', async () => { + it('getVmusdBalance throws VaultConfigNotAvailableError', async () => { const { service } = createService({ rffcFlags: {} }); await expect( - service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS), + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), ).rejects.toThrow(VaultConfigNotAvailableError); }); @@ -822,15 +822,15 @@ describe('MoneyAccountBalanceService', () => { }); // ---------------------------------------------------------- - // getMusdSHFvdBalance + // getVmusdBalance // ---------------------------------------------------------- - describe('getMusdSHFvdBalance', () => { + describe('getVmusdBalance', () => { it('returns the vault share balance for the given address', async () => { mockErc20BalanceOf('3000000'); const { service } = createService(); - const result = await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + const result = await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ balance: '3000000' }); }); @@ -839,7 +839,7 @@ describe('MoneyAccountBalanceService', () => { mockErc20BalanceOf('3000000'); const { service } = createService(); - await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); expect(MockContract).toHaveBeenCalledWith( MOCK_VAULT_ADDRESS, @@ -858,7 +858,7 @@ describe('MoneyAccountBalanceService', () => { mockGetNetworkConfig.mockReturnValue(undefined); await expect( - service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS), + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), ).rejects.toThrow('No network configuration found for chain 0xa4b1'); }); @@ -867,7 +867,7 @@ describe('MoneyAccountBalanceService', () => { mockGetNetworkClient.mockReturnValue({ provider: null }); await expect( - service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS), + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), ).rejects.toThrow('No provider found for chain 0xa4b1'); }); }); @@ -1046,10 +1046,10 @@ describe('MoneyAccountBalanceService', () => { underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, }; - it('returns musdBalance, musdSHFvdValueInMusd, and totalBalance from a single aggregate3 call', async () => { + it('returns musdBalance, vmusdValueInMusd, and totalBalance from a single aggregate3 call', async () => { const aggregate3 = mockMoneyAccountBalanceMulticall({ musdBalance: '5000000', - musdSHFvdValueInMusd: '2200000', + vmusdValueInMusd: '2200000', }); const { service } = createService({ rffcFlags: { @@ -1061,7 +1061,7 @@ describe('MoneyAccountBalanceService', () => { expect(result).toStrictEqual({ musdBalance: '5000000', - musdSHFvdValueInMusd: '2200000', + vmusdValueInMusd: '2200000', totalBalance: '7200000', }); expect(aggregate3).toHaveBeenCalledTimes(1); @@ -1099,7 +1099,7 @@ describe('MoneyAccountBalanceService', () => { it('falls back to an on-chain base() read when underlyingToken is absent from config', async () => { const aggregate3 = mockMoneyAccountBalanceMulticall({ musdBalance: '7', - musdSHFvdValueInMusd: '3', + vmusdValueInMusd: '3', }); // MOCK_VAULT_CONFIG has no underlyingToken. const { service } = createService(); @@ -1108,7 +1108,7 @@ describe('MoneyAccountBalanceService', () => { expect(result).toStrictEqual({ musdBalance: '7', - musdSHFvdValueInMusd: '3', + vmusdValueInMusd: '3', totalBalance: '10', }); // Accountant is instantiated for the base() fallback... @@ -1127,7 +1127,7 @@ describe('MoneyAccountBalanceService', () => { it('is also callable via the messenger action', async () => { mockMoneyAccountBalanceMulticall({ musdBalance: '5000000', - musdSHFvdValueInMusd: '2200000', + vmusdValueInMusd: '2200000', }); const { rootMessenger } = createService({ rffcFlags: { @@ -1142,7 +1142,7 @@ describe('MoneyAccountBalanceService', () => { expect(result).toStrictEqual({ musdBalance: '5000000', - musdSHFvdValueInMusd: '2200000', + vmusdValueInMusd: '2200000', totalBalance: '7200000', }); }); diff --git a/packages/money-account-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts index d565907901..23f62dd80a 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -74,7 +74,7 @@ const configLogger = createModuleLogger(projectLogger, 'config'); const MESSENGER_EXPOSED_METHODS = [ 'getMoneyAccountBalance', 'getMusdBalance', - 'getMusdSHFvdBalance', + 'getVmusdBalance', 'getExchangeRate', 'getMusdEquivalentValue', 'getVaultApy', @@ -141,7 +141,7 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< /** * Data service responsible for fetching Money account balances (mUSD and - * musdSHFvd) via on-chain RPC reads, the Veda Accountant exchange rate, and + * vmUSD) via on-chain RPC reads, the Veda Accountant exchange rate, and * the Veda vault APY from the Seven Seas REST API. * * All queries are cached via TanStack Query (inherited from @@ -568,7 +568,7 @@ export class MoneyAccountBalanceService extends BaseDataService< MULTICALL3_ABI, provider, ); - const [musdResult, musdSHFvdResult] = + const [musdResult, vmusdResult] = (await multicall3.callStatic.aggregate3(calls)) as [ Multicall3Result, Multicall3Result, @@ -578,15 +578,15 @@ export class MoneyAccountBalanceService extends BaseDataService< 'balanceOf', musdResult.returnData, )[0]; - const musdSHFvdBN = lens.interface.decodeFunctionResult( + const vmusdBN = lens.interface.decodeFunctionResult( 'balanceOfInAssets', - musdSHFvdResult.returnData, + vmusdResult.returnData, )[0]; return { musdBalance: musdBalanceBN.toString(), - musdSHFvdValueInMusd: musdSHFvdBN.toString(), - totalBalance: musdBalanceBN.add(musdSHFvdBN).toString(), + vmusdValueInMusd: vmusdBN.toString(), + totalBalance: musdBalanceBN.add(vmusdBN).toString(), }; }, staleTime: this.#balanceStaleTime, @@ -594,16 +594,16 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * Fetches the vmUSD (Veda vault share) ERC-20 balance for the given * account address via RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance as a raw uint256 string. + * @returns The vmUSD balance as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ - async getMusdSHFvdBalance(accountAddress: Hex): Promise<{ balance: string }> { + async getVmusdBalance(accountAddress: Hex): Promise<{ balance: string }> { return this.fetchQuery({ - queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], + queryKey: [`${this.name}:getVmusdBalance`, accountAddress], queryFn: async () => { const { boringVault, chainId } = this.#requireConfig(); const balance = await this.#fetchErc20Balance( @@ -619,7 +619,7 @@ export class MoneyAccountBalanceService extends BaseDataService< /** * Fetches the current exchange rate from the Veda Accountant contract via - * RPC. The rate represents the conversion factor from musdSHFvd shares to + * RPC. The rate represents the conversion factor from vmUSD shares to * the underlying mUSD asset. * * @param options - The options for the query. @@ -648,7 +648,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Fetches the mUSD-equivalent value of the account's musdSHFvd vault shares + * Fetches the mUSD-equivalent value of the account's vmUSD vault shares * via `Lens.balanceOfInAssets` RPC. * * @param accountAddress - The Money account's Ethereum address. diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts index 478226c835..6e1559febd 100644 --- a/packages/money-account-balance-service/src/response.types.ts +++ b/packages/money-account-balance-service/src/response.types.ts @@ -19,7 +19,7 @@ export type MusdEquivalentValueResponse = { */ export type MoneyAccountBalanceResponse = { musdBalance: string; - musdSHFvdValueInMusd: string; + vmusdValueInMusd: string; totalBalance: string; }; From 0204cd298ede34fb8bcdf2b80d5ceba5c949d73a Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 11 Jun 2026 16:00:36 -0400 Subject: [PATCH 5/9] feat: fixed lint errors in money-account-balance-service.test.ts --- .../src/money-account-balance-service.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index c88aa27d28..00842f3faa 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -375,11 +375,10 @@ function mockMoneyAccountBalanceMulticall({ encodeFunctionData: jest.fn().mockReturnValue('0xcalldata'), decodeFunctionResult: jest .fn() - .mockImplementation( - (_functionFragment: string, data: string) => - data === MUSD_RETURN_DATA - ? [makeMockBN(musdBalance)] - : [makeMockBN(vmusdValueInMusd)], + .mockImplementation((_functionFragment: string, data: string) => + data === MUSD_RETURN_DATA + ? [makeMockBN(musdBalance)] + : [makeMockBN(vmusdValueInMusd)], ), }, }) as unknown as Contract, From 1b0db35000370f2a54a43dcc45c13bdb7070ab3b Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 11 Jun 2026 16:02:39 -0400 Subject: [PATCH 6/9] feat: updated changelog to include link to PR --- packages/money-account-balance-service/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index 53ae34ab33..7dd34a3ca9 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -9,18 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `getMoneyAccountBalance` method that fetches the account's mUSD wallet balance and vault shares valued in mUSD in a single Multicall3 `aggregate3` request. -- Add optional `underlyingToken` field to `VaultConfig` (validated by `VaultConfigStruct`). When present, `getMusdBalance` reads the underlying mUSD token address from config and skips the on-chain `Accountant.base()` call; when absent it falls back to reading `base()` on-chain. -- Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaleTime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. +- Add `getMoneyAccountBalance` method that fetches the account's mUSD wallet balance and vault shares valued in mUSD in a single Multicall3 `aggregate3` request. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Add optional `underlyingToken` field to `VaultConfig` (validated by `VaultConfigStruct`). When present, `getMusdBalance` reads the underlying mUSD token address from config and skips the on-chain `Accountant.base()` call; when absent it falls back to reading `base()` on-chain. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaleTime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. ([#9100](https://github.com/MetaMask/core/pull/9100)) ### Changed -- **BREAKING:** Rename `musdSHFvd` to `vmusd` across the public API to align with the vmUSD token name: +- **BREAKING:** Rename `musdSHFvd` to `vmusd` across the public API to align with the vmUSD token name: ([#9100](https://github.com/MetaMask/core/pull/9100)) - `getMusdSHFvdBalance` method → `getVmusdBalance` - `MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction` type → `MoneyAccountBalanceServiceGetVmusdBalanceAction` - `MoneyAccountBalanceService:getMusdSHFvdBalance` messenger action string → `MoneyAccountBalanceService:getVmusdBalance` - `MoneyAccountBalanceResponse.musdSHFvdValueInMusd` property → `vmusdValueInMusd` -- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getVmusdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaleTime` remote feature flag. +- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getVmusdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaleTime` remote feature flag. ([#9100](https://github.com/MetaMask/core/pull/9100)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) - Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) From bf39537206b9a8ff326768eaee370805d7741e1c Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 11 Jun 2026 16:32:27 -0400 Subject: [PATCH 7/9] feat: ran messenger-action-types:generate --- ...unt-balance-service-method-action-types.ts | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts index a872a1cfe6..1c5493a479 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts @@ -19,19 +19,11 @@ export type MoneyAccountBalanceServiceGetMusdBalanceAction = { /** * Fetches the account's total Money balance inputs in a single batched RPC - * request via Multicall3's `aggregate3`, reading both values atomically at - * the same block: - * - the mUSD wallet balance (`mUSD.balanceOf`), and - * - the vault shares valued in mUSD (`Lens.balanceOfInAssets`). - * - * Both values are denominated in mUSD's decimals, so consumers can sum them - * directly to obtain the total balance. Sub-calls use `allowFailure: false`, - * so if either read reverts the whole query rejects rather than reporting a - * partial (and misleading) balance. + * request via Multicall3's `aggregate3` * * @param accountAddress - The Money account's Ethereum address. * @returns The mUSD balance and the mUSD-equivalent value of vault shares as - * raw uint256 strings. + * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ export type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { @@ -58,9 +50,7 @@ export type MoneyAccountBalanceServiceGetVmusdBalanceAction = { * the underlying mUSD asset. * * @param options - The options for the query. - * @param options.staleTime - The stale time for the query. Defaults to the - * remotely-configurable balance stale time (see - * {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY}). + * @param options.staleTime - Cache stale time override for this query. * @returns The exchange rate as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ @@ -70,13 +60,11 @@ export type MoneyAccountBalanceServiceGetExchangeRateAction = { }; /** - * Computes the mUSD-equivalent value of the account's vmUSD holdings. - * Internally fetches the vmUSD balance and exchange rate (using cached - * values when available within their staleTime windows), then multiplies - * them. + * Fetches the mUSD-equivalent value of the account's vmUSD vault shares + * via `Lens.balanceOfInAssets` RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The mUSD-equivalent value (vmUSD holdings) as a raw uint256 string. + * @returns The mUSD-equivalent value of vault shares as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ export type MoneyAccountBalanceServiceGetMusdEquivalentValueAction = { From f20f0c923ef7bd31e2c57b46ffb4ef51b520fd92 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 12 Jun 2026 12:40:06 -0400 Subject: [PATCH 8/9] fix: fixed moneyAccountBalanceStaleTime type in changelog --- packages/money-account-balance-service/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index 7dd34a3ca9..7a19faffe3 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `getMoneyAccountBalance` method that fetches the account's mUSD wallet balance and vault shares valued in mUSD in a single Multicall3 `aggregate3` request. ([#9100](https://github.com/MetaMask/core/pull/9100)) - Add optional `underlyingToken` field to `VaultConfig` (validated by `VaultConfigStruct`). When present, `getMusdBalance` reads the underlying mUSD token address from config and skips the on-chain `Accountant.base()` call; when absent it falls back to reading `base()` on-chain. ([#9100](https://github.com/MetaMask/core/pull/9100)) -- Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaleTime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaletime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. ([#9100](https://github.com/MetaMask/core/pull/9100)) ### Changed @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction` type → `MoneyAccountBalanceServiceGetVmusdBalanceAction` - `MoneyAccountBalanceService:getMusdSHFvdBalance` messenger action string → `MoneyAccountBalanceService:getVmusdBalance` - `MoneyAccountBalanceResponse.musdSHFvdValueInMusd` property → `vmusdValueInMusd` -- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getVmusdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaleTime` remote feature flag. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getVmusdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaletime` remote feature flag. ([#9100](https://github.com/MetaMask/core/pull/9100)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) - Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) From d6a3b01f903d2b15834b667132b5f0e5fd0e08ca Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 12 Jun 2026 12:54:27 -0400 Subject: [PATCH 9/9] feat: added test for actual encode/decode paths --- .../src/money-account-balance-service.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index 00842f3faa..d26b7c0813 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -7,10 +7,12 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { Json } from '@metamask/utils'; import nock, { cleanAll as nockCleanAll } from 'nock'; import { + LENS_ABI, MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, MULTICALL3_ADDRESS_BY_CHAIN_ID, VAULT_CONFIG_FEATURE_FLAG_KEY, @@ -1066,6 +1068,67 @@ describe('MoneyAccountBalanceService', () => { expect(aggregate3).toHaveBeenCalledTimes(1); }); + it('exercises real ABI encode/decode through the multicall path', async () => { + const { Contract: RealContract } = jest.requireActual< + typeof import('@ethersproject/contracts') + >('@ethersproject/contracts'); + const erc20Iface = new RealContract( + MOCK_UNDERLYING_TOKEN_ADDRESS, + abiERC20, + ).interface; + const lensIface = new RealContract(MOCK_LENS_ADDRESS, LENS_ABI).interface; + + const musdReturnData = erc20Iface.encodeFunctionResult('balanceOf', [ + '5000000', + ]); + const vmusdReturnData = lensIface.encodeFunctionResult( + 'balanceOfInAssets', + ['2200000'], + ); + + const aggregate3Mock = jest.fn().mockResolvedValue([ + { success: true, returnData: musdReturnData }, + { success: true, returnData: vmusdReturnData }, + ]); + + const multicall3Address = + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId]; + + MockContract.mockImplementation( + (address, abi) => + (address === multicall3Address + ? { callStatic: { aggregate3: aggregate3Mock } } + : new RealContract(address, abi)) as unknown as Contract, + ); + + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + + const [[calls]] = aggregate3Mock.mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0].callData).toBe( + erc20Iface.encodeFunctionData('balanceOf', [MOCK_ACCOUNT_ADDRESS]), + ); + expect(calls[1].callData).toBe( + lensIface.encodeFunctionData('balanceOfInAssets', [ + MOCK_ACCOUNT_ADDRESS, + MOCK_VAULT_ADDRESS, + MOCK_ACCOUNTANT_ADDRESS, + ]), + ); + }); + it('batches the mUSD and Lens reads into one aggregate3 request with allowFailure disabled', async () => { const aggregate3 = mockMoneyAccountBalanceMulticall(); const { service } = createService({