From d6b490f2e1d43006303da5716ba0ecb9db2369e7 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 11:09:47 +0200 Subject: [PATCH 01/10] feat(money): scaffold money account api data service package --- .github/CODEOWNERS | 3 + .../CHANGELOG.md | 14 + .../money-account-api-data-service/LICENSE | 21 + .../money-account-api-data-service/README.md | 15 + .../jest.config.js | 24 + .../package.json | 78 +++ .../src/constants.ts | 29 + .../src/errors.ts | 14 + .../src/index.ts | 35 + .../src/logger.ts | 7 + ...nt-api-data-service-method-action-types.ts | 63 ++ .../money-account-api-data-service.test.ts | 642 ++++++++++++++++++ .../src/money-account-api-data-service.ts | 413 +++++++++++ .../src/response.types.ts | 102 +++ .../src/structs.ts | 101 +++ .../src/types.ts | 37 + .../tsconfig.build.json | 14 + .../tsconfig.json | 12 + .../typedoc.json | 7 + teams.json | 1 + tsconfig.build.json | 3 + tsconfig.json | 3 + yarn.lock | 23 + 23 files changed, 1661 insertions(+) create mode 100644 packages/money-account-api-data-service/CHANGELOG.md create mode 100644 packages/money-account-api-data-service/LICENSE create mode 100644 packages/money-account-api-data-service/README.md create mode 100644 packages/money-account-api-data-service/jest.config.js create mode 100644 packages/money-account-api-data-service/package.json create mode 100644 packages/money-account-api-data-service/src/constants.ts create mode 100644 packages/money-account-api-data-service/src/errors.ts create mode 100644 packages/money-account-api-data-service/src/index.ts create mode 100644 packages/money-account-api-data-service/src/logger.ts create mode 100644 packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts create mode 100644 packages/money-account-api-data-service/src/money-account-api-data-service.test.ts create mode 100644 packages/money-account-api-data-service/src/money-account-api-data-service.ts create mode 100644 packages/money-account-api-data-service/src/response.types.ts create mode 100644 packages/money-account-api-data-service/src/structs.ts create mode 100644 packages/money-account-api-data-service/src/types.ts create mode 100644 packages/money-account-api-data-service/tsconfig.build.json create mode 100644 packages/money-account-api-data-service/tsconfig.json create mode 100644 packages/money-account-api-data-service/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 11d189f205..baf6c31d23 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,6 +47,7 @@ ## Earn Team /packages/earn-controller @MetaMask/earn /packages/money-account-balance-service @MetaMask/earn +/packages/money-account-api-data-service @MetaMask/earn /packages/chomp-api-service @MetaMask/earn @MetaMask/delegation /packages/money-account-upgrade-controller @MetaMask/earn @MetaMask/delegation @@ -169,6 +170,8 @@ /packages/earn-controller/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform /packages/money-account-balance-service/package.json @MetaMask/earn @MetaMask/core-platform /packages/money-account-balance-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/money-account-api-data-service/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-api-data-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform /packages/ens-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/ens-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/gas-fee-controller/package.json @MetaMask/confirmations @MetaMask/core-platform diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md new file mode 100644 index 0000000000..396bc35528 --- /dev/null +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/money-account-api-data-service` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-api-data-service/LICENSE b/packages/money-account-api-data-service/LICENSE new file mode 100644 index 0000000000..fe29e78e0f --- /dev/null +++ b/packages/money-account-api-data-service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/money-account-api-data-service/README.md b/packages/money-account-api-data-service/README.md new file mode 100644 index 0000000000..bbbdf2cc5c --- /dev/null +++ b/packages/money-account-api-data-service/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-api-data-service` + +Data service for fetching Money account positions, interest, cash-flow history, and vault rate history from the Money Account API. + +## Installation + +`yarn add @metamask/money-account-api-data-service` + +or + +`npm install @metamask/money-account-api-data-service` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/money-account-api-data-service/jest.config.js b/packages/money-account-api-data-service/jest.config.js new file mode 100644 index 0000000000..c17efa251a --- /dev/null +++ b/packages/money-account-api-data-service/jest.config.js @@ -0,0 +1,24 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + displayName, + + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/money-account-api-data-service/package.json b/packages/money-account-api-data-service/package.json new file mode 100644 index 0000000000..000866a07f --- /dev/null +++ b/packages/money-account-api-data-service/package.json @@ -0,0 +1,78 @@ +{ + "name": "@metamask/money-account-api-data-service", + "version": "0.0.0", + "description": "Data service for fetching Money account positions, interest, cash-flow history, and vault rate history from the Money Account API", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-api-data-service#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-api-data-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-api-data-service", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-data-service": "^0.1.3", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^1.2.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "nock": "^13.3.1", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-api-data-service/src/constants.ts b/packages/money-account-api-data-service/src/constants.ts new file mode 100644 index 0000000000..120cfe8ab8 --- /dev/null +++ b/packages/money-account-api-data-service/src/constants.ts @@ -0,0 +1,29 @@ +/** + * Supported environments for the Money Account APY Tracking API. + */ +export enum Env { + DEV = 'dev', + UAT = 'uat', + PRD = 'prd', +} + +/** + * Base URL map for the Money Account APY Tracking API, keyed by environment. + */ +export const MONEY_ACCOUNT_API_URL_MAP: Record = { + [Env.DEV]: 'https://money.dev-api.cx.metamask.io', + [Env.UAT]: 'https://money.uat-api.cx.metamask.io', + [Env.PRD]: 'https://money.api.cx.metamask.io', +}; + +/** + * Default stale time (ms) for position/interest/history queries. + * Matches the server-side cache TTL of 30 seconds. + */ +export const DEFAULT_STALE_TIME_MS = 30_000; + +/** + * Default stale time (ms) for rate-history queries. + * Matches the server-side cache TTL of 5 minutes. + */ +export const RATE_HISTORY_STALE_TIME_MS = 300_000; diff --git a/packages/money-account-api-data-service/src/errors.ts b/packages/money-account-api-data-service/src/errors.ts new file mode 100644 index 0000000000..9d0b3b0338 --- /dev/null +++ b/packages/money-account-api-data-service/src/errors.ts @@ -0,0 +1,14 @@ +/** + * Thrown when a response from the Money Account API fails superstruct + * validation. Indicates a contract mismatch between client and server. + */ +export class MoneyAccountApiResponseValidationError extends Error { + constructor(message?: string, options?: ErrorOptions) { + super( + message ?? + 'MoneyAccountApiDataService: malformed response received from Money Account API', + options, + ); + this.name = 'MoneyAccountApiResponseValidationError'; + } +} diff --git a/packages/money-account-api-data-service/src/index.ts b/packages/money-account-api-data-service/src/index.ts new file mode 100644 index 0000000000..8fcabaf084 --- /dev/null +++ b/packages/money-account-api-data-service/src/index.ts @@ -0,0 +1,35 @@ +export { + getHistoryNextPageParam, + MoneyAccountApiDataService, +} from './money-account-api-data-service'; +export type { + MoneyAccountApiDataServiceActions, + MoneyAccountApiDataServiceEvents, + MoneyAccountApiDataServiceMessenger, +} from './money-account-api-data-service'; +export type { + MoneyAccountApiDataServiceFetchPositionsAction, + MoneyAccountApiDataServiceFetchInterestAction, + MoneyAccountApiDataServiceFetchHistoryAction, + MoneyAccountApiDataServiceFetchRateHistoryAction, +} from './money-account-api-data-service-method-action-types'; +export type { + PositionResponse, + InterestResponse, + HistoryResponse, + RateHistoryResponse, + VaultPosition, + CashFlowEntry, + RateHistoryEntry, + DataFreshness, + CashFlowType, + CashFlowSource, +} from './response.types'; +export type { + InterestWindow, + InterestOptions, + HistoryOptions, + RateHistoryOptions, +} from './types'; +export { Env } from './constants'; +export { MoneyAccountApiResponseValidationError } from './errors'; diff --git a/packages/money-account-api-data-service/src/logger.ts b/packages/money-account-api-data-service/src/logger.ts new file mode 100644 index 0000000000..3b9c4a811d --- /dev/null +++ b/packages/money-account-api-data-service/src/logger.ts @@ -0,0 +1,7 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger( + 'money-account-api-data-service', +); + +export { createModuleLogger }; diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts new file mode 100644 index 0000000000..334010179e --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts @@ -0,0 +1,63 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountApiDataService } from './money-account-api-data-service'; + +/** + * Fetches the current vault positions for a given user address. + * + * @param address - The user's Ethereum address. + * @returns The position response containing vault positions. + */ +export type MoneyAccountApiDataServiceFetchPositionsAction = { + type: `MoneyAccountApiDataService:fetchPositions`; + handler: MoneyAccountApiDataService['fetchPositions']; +}; + +/** + * Fetches the interest earned for a given address and vault over a + * specified time window. + * + * @param address - The user's Ethereum address. + * @param options - Options specifying vault, window, and optional chain ID. + * @returns The interest response. + */ +export type MoneyAccountApiDataServiceFetchInterestAction = { + type: `MoneyAccountApiDataService:fetchInterest`; + handler: MoneyAccountApiDataService['fetchInterest']; +}; + +/** + * Fetches cursor-paginated cash-flow history for a given address. + * + * @param address - The user's Ethereum address. + * @param options - Optional filtering and pagination options. + * @returns The history response containing cash-flow entries. + */ +export type MoneyAccountApiDataServiceFetchHistoryAction = { + type: `MoneyAccountApiDataService:fetchHistory`; + handler: MoneyAccountApiDataService['fetchHistory']; +}; + +/** + * Fetches the exchange-rate time series for a given vault. + * + * @param vaultAddress - The vault's Ethereum address. + * @param options - Optional range and chain ID filters. + * @returns The rate history response. + */ +export type MoneyAccountApiDataServiceFetchRateHistoryAction = { + type: `MoneyAccountApiDataService:fetchRateHistory`; + handler: MoneyAccountApiDataService['fetchRateHistory']; +}; + +/** + * Union of all MoneyAccountApiDataService action types. + */ +export type MoneyAccountApiDataServiceMethodActions = + | MoneyAccountApiDataServiceFetchPositionsAction + | MoneyAccountApiDataServiceFetchInterestAction + | MoneyAccountApiDataServiceFetchHistoryAction + | MoneyAccountApiDataServiceFetchRateHistoryAction; diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts new file mode 100644 index 0000000000..591f7c9376 --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts @@ -0,0 +1,642 @@ +import { DEFAULT_MAX_RETRIES, HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll as nockCleanAll } from 'nock'; + +import { Env, MONEY_ACCOUNT_API_URL_MAP } from './constants'; +import { MoneyAccountApiResponseValidationError } from './errors'; +import type { MoneyAccountApiDataServiceMessenger } from './money-account-api-data-service'; +import { + getHistoryNextPageParam, + MoneyAccountApiDataService, + serviceName, +} from './money-account-api-data-service'; + +// ============================================================ +// Fixtures +// ============================================================ + +const MOCK_ADDRESS = '0x1111111111111111111111111111111111111111'; +const MOCK_VAULT_ADDRESS = '0x2222222222222222222222222222222222222222'; + +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, + positions: [ + { + vault_address: MOCK_VAULT_ADDRESS, + shares_held: '1000000000000000000', + current_rate: '1052340000000000000', + current_value_assets: '1052340000000000000', + current_value_usd: '1052.34', + cost_basis_assets: '1000000000000000000', + cost_basis_usd: '1000.00', + realized_interest_usd: '10.00', + unrealised_interest_usd: '42.34', + lifetime_interest_usd: '52.34', + current_apy: '0.0412', + effective_apy: '0.0395', + }, + ], +}; + +const MOCK_INTEREST_RESPONSE = { + address: MOCK_ADDRESS, + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + window_start: '2026-05-25T00:00:00Z', + window_end: '2026-06-01T00:00:00Z', + interest_earned_assets: '5000000000000000', + interest_earned_usd: '5.00', + method: 'nav_difference', + as_of_block: 12345, + as_of_timestamp: '2026-06-01T12:00:00Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 5, +}; + +const MOCK_HISTORY_RESPONSE = { + address: MOCK_ADDRESS, + cash_flows: [ + { + type: 'deposit' as const, + chain_id: 143, + vault_address: MOCK_VAULT_ADDRESS, + timestamp: '2026-05-01T10:00:00Z', + block_number: 10000, + log_index: 0, + tx_hash: '0xabc123', + assets_usd: '1000.00', + assets_wei: '1000000000000000000', + shares_wei: '950000000000000000', + rate: '1.052340', + source: 'teller' as const, + }, + ], + next_cursor: 'eyJiIjoxMDAwMH0=', + has_more: true, + as_of_block: 12345, + as_of_timestamp: '2026-06-01T12:00:00Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 5, +}; + +const MOCK_RATE_HISTORY_RESPONSE = { + vault_address: MOCK_VAULT_ADDRESS, + chain_id: 143, + range_start: '2026-05-01T00:00:00Z', + range_end: '2026-06-01T00:00:00Z', + rates: [ + { + timestamp: '2026-05-01T00:00:00Z', + block_number: 10000, + rate: '1.000000000000000000', + tx_hash: '0xdef456', + }, + { + timestamp: '2026-06-01T00:00:00Z', + block_number: 12345, + rate: '1.052340000000000000', + tx_hash: '0xghi789', + }, + ], + as_of_block: 12345, + as_of_timestamp: '2026-06-01T12:00:00Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 5, +}; + +// ============================================================ +// Messenger helpers +// ============================================================ + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function createServiceMessenger( + rootMessenger: RootMessenger, +): MoneyAccountApiDataServiceMessenger { + return new Messenger({ + namespace: serviceName, + parent: rootMessenger, + }); +} + +// ============================================================ +// Factory +// ============================================================ + +function createService( + env: Env = Env.DEV, +): { + service: MoneyAccountApiDataService; + rootMessenger: RootMessenger; + messenger: MoneyAccountApiDataServiceMessenger; +} { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + const service = new MoneyAccountApiDataService({ + messenger, + env, + }); + return { service, rootMessenger, messenger }; +} + +// ============================================================ +// Tests +// ============================================================ + +describe('MoneyAccountApiDataService', () => { + afterEach(() => { + nockCleanAll(); + }); + + describe('constructor', () => { + it('initializes with default PRD environment', () => { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + const service = new MoneyAccountApiDataService({ messenger }); + expect(service.name).toBe(serviceName); + service.destroy(); + }); + + it('initializes with specified environment', () => { + const { service } = createService(Env.UAT); + expect(service.name).toBe(serviceName); + service.destroy(); + }); + }); + + describe('fetchPositions', () => { + it('returns position data for a valid address', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + + it('lowercases the address in the request', async () => { + const { service } = createService(Env.DEV); + const upperAddress = MOCK_ADDRESS.toUpperCase().replace('0X', '0x'); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await service.fetchPositions(upperAddress); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + HttpError, + ); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .once() + .reply(200, { invalid: true }); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + MoneyAccountApiResponseValidationError, + ); + service.destroy(); + }); + + it('caches responses via TanStack Query', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .once() + .reply(200, MOCK_POSITION_RESPONSE); + + const result1 = await service.fetchPositions(MOCK_ADDRESS); + const result2 = await service.fetchPositions(MOCK_ADDRESS); + expect(result1).toStrictEqual(result2); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, MOCK_POSITION_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchPositions', + MOCK_ADDRESS, + ); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + }); + + describe('fetchInterest', () => { + it('returns interest data for valid params', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .reply(200, MOCK_INTEREST_RESPONSE); + + const result = await service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }); + expect(result).toStrictEqual(MOCK_INTEREST_RESPONSE); + service.destroy(); + }); + + it('includes chain_id query param when specified', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '30d', + chain_id: '143', + }) + .reply(200, MOCK_INTEREST_RESPONSE); + + const result = await service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '30d', + chainId: 143, + }); + expect(result).toStrictEqual(MOCK_INTEREST_RESPONSE); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(400); + + await expect( + service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }), + ).rejects.toThrow(HttpError); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .once() + .reply(200, { bad: 'data' }); + + await expect( + service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }), + ).rejects.toThrow(MoneyAccountApiResponseValidationError); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .reply(200, MOCK_INTEREST_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchInterest', + MOCK_ADDRESS, + { vaultAddress: MOCK_VAULT_ADDRESS, window: '7d' }, + ); + expect(result).toStrictEqual(MOCK_INTEREST_RESPONSE); + service.destroy(); + }); + }); + + describe('fetchHistory', () => { + it('returns history data with no options', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await service.fetchHistory(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + + it('includes vault_address query param when specified', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .query({ vault_address: MOCK_VAULT_ADDRESS }) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await service.fetchHistory(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + }); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + + it('includes all optional query params', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + chain_id: '143', + cursor: 'abc123', + limit: '10', + }) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await service.fetchHistory(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + chainId: 143, + cursor: 'abc123', + limit: 10, + }); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + + it('supports paginated fetching via cursor', async () => { + const { service } = createService(Env.DEV); + + const page2Response = { + ...MOCK_HISTORY_RESPONSE, + next_cursor: null, + has_more: false, + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + + const firstPage = await service.fetchHistory(MOCK_ADDRESS); + expect(firstPage.next_cursor).toBe('eyJiIjoxMDAwMH0='); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .query({ cursor: 'eyJiIjoxMDAwMH0=' }) + .reply(200, page2Response); + + const secondPage = await service.fetchHistory(MOCK_ADDRESS, { + cursor: 'eyJiIjoxMDAwMH0=', + }); + expect(secondPage.next_cursor).toBeNull(); + expect(secondPage.has_more).toBe(false); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + await expect(service.fetchHistory(MOCK_ADDRESS)).rejects.toThrow( + HttpError, + ); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .once() + .reply(200, { wrong: 'shape' }); + + await expect(service.fetchHistory(MOCK_ADDRESS)).rejects.toThrow( + MoneyAccountApiResponseValidationError, + ); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, MOCK_HISTORY_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchHistory', + MOCK_ADDRESS, + ); + expect(result).toStrictEqual(MOCK_HISTORY_RESPONSE); + service.destroy(); + }); + }); + + describe('fetchRateHistory', () => { + it('returns rate history data with no options', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + const result = await service.fetchRateHistory(MOCK_VAULT_ADDRESS); + expect(result).toStrictEqual(MOCK_RATE_HISTORY_RESPONSE); + service.destroy(); + }); + + it('includes optional query params', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .query({ + chain_id: '143', + from: '2026-05-01T00:00:00Z', + to: '2026-06-01T00:00:00Z', + }) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + const result = await service.fetchRateHistory(MOCK_VAULT_ADDRESS, { + chainId: 143, + from: '2026-05-01T00:00:00Z', + to: '2026-06-01T00:00:00Z', + }); + expect(result).toStrictEqual(MOCK_RATE_HISTORY_RESPONSE); + service.destroy(); + }); + + it('throws HttpError on non-2xx response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(404); + + await expect( + service.fetchRateHistory(MOCK_VAULT_ADDRESS), + ).rejects.toThrow(HttpError); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed response', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .once() + .reply(200, { not: 'valid' }); + + await expect( + service.fetchRateHistory(MOCK_VAULT_ADDRESS), + ).rejects.toThrow(MoneyAccountApiResponseValidationError); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .reply(200, MOCK_RATE_HISTORY_RESPONSE); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:fetchRateHistory', + MOCK_VAULT_ADDRESS, + ); + expect(result).toStrictEqual(MOCK_RATE_HISTORY_RESPONSE); + service.destroy(); + }); + }); + + describe('invalidateQueries', () => { + it('invalidates cached queries', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .twice() + .reply(200, MOCK_POSITION_RESPONSE); + + await service.fetchPositions(MOCK_ADDRESS); + await service.invalidateQueries(); + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(MOCK_POSITION_RESPONSE); + service.destroy(); + }); + + it('is callable via messenger action', async () => { + const { rootMessenger, service } = createService(Env.DEV); + + await rootMessenger.call( + 'MoneyAccountApiDataService:invalidateQueries', + ); + service.destroy(); + }); + }); + + describe('destroy', () => { + it('cleans up messenger subscriptions', () => { + const { service } = createService(Env.DEV); + expect(() => service.destroy()).not.toThrow(); + }); + }); +}); + +describe('MoneyAccountApiResponseValidationError', () => { + it('uses default message when none provided', () => { + const error = new MoneyAccountApiResponseValidationError(); + expect(error.message).toBe( + 'MoneyAccountApiDataService: malformed response received from Money Account API', + ); + expect(error.name).toBe('MoneyAccountApiResponseValidationError'); + }); + + it('uses custom message when provided', () => { + const error = new MoneyAccountApiResponseValidationError('custom message'); + expect(error.message).toBe('custom message'); + expect(error.name).toBe('MoneyAccountApiResponseValidationError'); + }); + + it('preserves the cause when provided', () => { + const cause = new Error('struct failure'); + const error = new MoneyAccountApiResponseValidationError('msg', { cause }); + expect(error.cause).toBe(cause); + }); +}); + +describe('getHistoryNextPageParam', () => { + it('returns next_cursor when present', () => { + expect(getHistoryNextPageParam(MOCK_HISTORY_RESPONSE)).toBe( + 'eyJiIjoxMDAwMH0=', + ); + }); + + it('returns null when there are no more pages', () => { + expect( + getHistoryNextPageParam({ ...MOCK_HISTORY_RESPONSE, next_cursor: null }), + ).toBeNull(); + }); +}); diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts new file mode 100644 index 0000000000..9390cc021e --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -0,0 +1,413 @@ +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import { BaseDataService } from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { validate } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import { + DEFAULT_STALE_TIME_MS, + Env, + MONEY_ACCOUNT_API_URL_MAP, + RATE_HISTORY_STALE_TIME_MS, +} from './constants'; +import { MoneyAccountApiResponseValidationError } from './errors'; +import { projectLogger, createModuleLogger } from './logger'; +import type { MoneyAccountApiDataServiceMethodActions } from './money-account-api-data-service-method-action-types'; +import type { + HistoryResponse, + InterestResponse, + PositionResponse, + RateHistoryResponse, +} from './response.types'; +import { + HistoryResponseStruct, + InterestResponseStruct, + PositionResponseStruct, + RateHistoryResponseStruct, +} from './structs'; +import type { + HistoryOptions, + InterestOptions, + RateHistoryOptions, +} from './types'; + +// === GENERAL === + +/** + * The name of the {@link MoneyAccountApiDataService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'MoneyAccountApiDataService'; + +const log = createModuleLogger(projectLogger, serviceName); + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'fetchPositions', + 'fetchInterest', + 'fetchHistory', + 'fetchRateHistory', +] as const; + +/** + * Invalidates cached queries for {@link MoneyAccountApiDataService}. + */ +export type MoneyAccountApiDataServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link MoneyAccountApiDataService} exposes to other consumers. + */ +export type MoneyAccountApiDataServiceActions = + | MoneyAccountApiDataServiceMethodActions + | MoneyAccountApiDataServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link MoneyAccountApiDataService} calls. + */ +type AllowedActions = never; + +/** + * Published when {@link MoneyAccountApiDataService}'s cache is updated. + */ +export type MoneyAccountApiDataServiceCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a key within {@link MoneyAccountApiDataService}'s cache is + * updated. + */ +export type MoneyAccountApiDataServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link MoneyAccountApiDataService} exposes to other consumers. + */ +export type MoneyAccountApiDataServiceEvents = + | MoneyAccountApiDataServiceCacheUpdatedEvent + | MoneyAccountApiDataServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link MoneyAccountApiDataService} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link MoneyAccountApiDataService}. + */ +export type MoneyAccountApiDataServiceMessenger = Messenger< + typeof serviceName, + MoneyAccountApiDataServiceActions | AllowedActions, + MoneyAccountApiDataServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Extracts the pagination cursor from a history response for infinite query + * navigation. + * + * @param lastPage - The most recently fetched history page. + * @returns The cursor for the next page, or null if no more pages. + */ +export function getHistoryNextPageParam( + lastPage: HistoryResponse, +): string | null { + return lastPage.next_cursor; +} + +/** + * Data service responsible for fetching positions, interest, cash-flow + * history, and vault rate history from the Money Account APY Tracking API. + */ +export class MoneyAccountApiDataService extends BaseDataService< + typeof serviceName, + MoneyAccountApiDataServiceMessenger +> { + readonly #baseUrl: string; + + /** + * Constructs a new MoneyAccountApiDataService. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.env - The target environment. Defaults to production. + * @param args.queryClientConfig - Configuration for the underlying TanStack + * Query client. + * @param args.policyOptions - Options to pass to `createServicePolicy`. + */ + constructor({ + messenger, + env = Env.PRD, + queryClientConfig = {}, + policyOptions = {}, + }: { + messenger: MoneyAccountApiDataServiceMessenger; + env?: Env; + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ + name: serviceName, + messenger, + queryClientConfig, + policyOptions: { + retryFilterPolicy: handleWhen( + (error) => + !(error instanceof MoneyAccountApiResponseValidationError), + ), + ...policyOptions, + }, + }); + + this.#baseUrl = MONEY_ACCOUNT_API_URL_MAP[env]; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + log('Initialized', { env, baseUrl: this.#baseUrl }); + } + + /** + * Fetches the current vault positions for a given user address. + * + * @param address - The user's Ethereum address. + * @returns The position response containing vault positions. + */ + async fetchPositions(address: string): Promise { + const url = new URL( + `/v1/positions/${address.toLowerCase()}`, + this.#baseUrl, + ); + + return this.fetchQuery({ + queryKey: [`${this.name}:fetchPositions`, address.toLowerCase()], + staleTime: DEFAULT_STALE_TIME_MS, + queryFn: async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API positions request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate(json, PositionResponseStruct); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from positions endpoint: ${error.message}`, + { cause: error }, + ); + } + + return validated as unknown as PositionResponse; + }, + }); + } + + /** + * Fetches the interest earned for a given address and vault over a + * specified time window. + * + * @param address - The user's Ethereum address. + * @param options - Options specifying vault, window, and optional chain ID. + * @returns The interest response. + */ + async fetchInterest( + address: string, + options: InterestOptions, + ): Promise { + const url = new URL( + `/v1/positions/${address.toLowerCase()}/interest`, + this.#baseUrl, + ); + url.searchParams.append( + 'vault_address', + options.vaultAddress.toLowerCase(), + ); + url.searchParams.append('window', options.window); + if (options.chainId !== undefined) { + url.searchParams.append('chain_id', String(options.chainId)); + } + + return this.fetchQuery({ + queryKey: [ + `${this.name}:fetchInterest`, + address.toLowerCase(), + options.vaultAddress.toLowerCase(), + options.window, + ...(options.chainId !== undefined ? [options.chainId] : []), + ], + staleTime: DEFAULT_STALE_TIME_MS, + queryFn: async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API interest request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate(json, InterestResponseStruct); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from interest endpoint: ${error.message}`, + { cause: error }, + ); + } + + return validated as unknown as InterestResponse; + }, + }); + } + + /** + * Fetches cursor-paginated cash-flow history for a given address. + * Uses `fetchInfiniteQuery` for proper TanStack Query pagination semantics. + * + * @param address - The user's Ethereum address. + * @param options - Optional filtering and pagination options. + * @returns The history response containing cash-flow entries for the requested page. + */ + async fetchHistory( + address: string, + options?: HistoryOptions, + ): Promise { + const normalizedAddress = address.toLowerCase(); + const normalizedVault = options?.vaultAddress?.toLowerCase() ?? null; + + return this.fetchInfiniteQuery( + { + queryKey: [ + `${this.name}:fetchHistory`, + normalizedAddress, + normalizedVault, + options?.chainId ?? null, + options?.limit ?? null, + ], + initialPageParam: null as string | null, + getNextPageParam: getHistoryNextPageParam, + staleTime: DEFAULT_STALE_TIME_MS, + queryFn: async (context) => { + const cursor = context.pageParam as string | null | undefined; + + const url = new URL( + `/v1/positions/${normalizedAddress}/history`, + this.#baseUrl, + ); + if (normalizedVault) { + url.searchParams.append('vault_address', normalizedVault); + } + if (options?.chainId !== undefined) { + url.searchParams.append('chain_id', String(options.chainId)); + } + if (cursor) { + url.searchParams.append('cursor', cursor); + } + if (options?.limit !== undefined) { + url.searchParams.append('limit', String(options.limit)); + } + + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API history request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate(json, HistoryResponseStruct); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from history endpoint: ${error.message}`, + { cause: error }, + ); + } + + return validated as unknown as HistoryResponse; + }, + }, + options?.cursor ?? undefined, + ); + } + + /** + * Fetches the exchange-rate time series for a given vault. + * + * @param vaultAddress - The vault's Ethereum address. + * @param options - Optional range and chain ID filters. + * @returns The rate history response. + */ + async fetchRateHistory( + vaultAddress: string, + options?: RateHistoryOptions, + ): Promise { + const url = new URL( + `/v1/vaults/${vaultAddress.toLowerCase()}/rate-history`, + this.#baseUrl, + ); + if (options?.chainId !== undefined) { + url.searchParams.append('chain_id', String(options.chainId)); + } + if (options?.from) { + url.searchParams.append('from', options.from); + } + if (options?.to) { + url.searchParams.append('to', options.to); + } + + return this.fetchQuery({ + queryKey: [ + `${this.name}:fetchRateHistory`, + vaultAddress.toLowerCase(), + ...(options?.chainId !== undefined ? [options.chainId] : [null]), + ...(options?.from ? [options.from] : [null]), + ...(options?.to ? [options.to] : [null]), + ], + staleTime: RATE_HISTORY_STALE_TIME_MS, + queryFn: async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Money Account API rate-history request failed with status '${response.status}'`, + ); + } + + const json: Json = await response.json(); + + const [error, validated] = validate(json, RateHistoryResponseStruct); + if (error) { + throw new MoneyAccountApiResponseValidationError( + `Malformed response from rate-history endpoint: ${error.message}`, + { cause: error }, + ); + } + + return validated as unknown as RateHistoryResponse; + }, + }); + } +} diff --git a/packages/money-account-api-data-service/src/response.types.ts b/packages/money-account-api-data-service/src/response.types.ts new file mode 100644 index 0000000000..3046d57f50 --- /dev/null +++ b/packages/money-account-api-data-service/src/response.types.ts @@ -0,0 +1,102 @@ +import type { Infer } from '@metamask/superstruct'; + +import type { + HistoryResponseStruct, + InterestResponseStruct, + PositionResponseStruct, + RateHistoryResponseStruct, +} from './structs'; + +/** + * Data freshness indicator returned by all business endpoints. + */ +export type DataFreshness = 'live' | 'degraded'; + +/** + * A single vault position within the positions response. + */ +export type VaultPosition = { + vault_address: string; + shares_held: string; + current_rate: string; + current_value_assets: string; + current_value_usd: string; + cost_basis_assets: string; + cost_basis_usd: string; + realized_interest_usd: string; + unrealised_interest_usd: string; + lifetime_interest_usd: string; + current_apy: string; + effective_apy: string; +}; + +/** + * Response from `GET /v1/positions/:address`. + * Derived from {@link PositionResponseStruct} to ensure type/struct parity. + */ +export type PositionResponse = Infer; + +/** + * Response from `GET /v1/positions/:address/interest`. + * Derived from {@link InterestResponseStruct} to ensure type/struct parity. + */ +export type InterestResponse = Infer; + +/** + * Cash-flow type for the history endpoint. + */ +export type CashFlowType = + | 'deposit' + | 'withdraw' + | 'transfer_in' + | 'transfer_out'; + +/** + * Cash-flow source label. + */ +export type CashFlowSource = + | 'teller' + | 'withdraw_queue' + | 'atomic_queue' + | 'erc20_transfer' + | 'cross_chain'; + +/** + * A single entry in the cash-flow history. + */ +export type CashFlowEntry = { + type: CashFlowType; + chain_id: number; + vault_address: string; + timestamp: string; + block_number: number; + log_index: number; + tx_hash: string; + assets_usd: string; + assets_wei: string; + shares_wei: string; + rate: string; + source: CashFlowSource; +}; + +/** + * Response from `GET /v1/positions/:address/history`. + * Derived from {@link HistoryResponseStruct} to ensure type/struct parity. + */ +export type HistoryResponse = Infer; + +/** + * A single entry in the rate-history time series. + */ +export type RateHistoryEntry = { + timestamp: string; + block_number: number; + rate: string; + tx_hash: string; +}; + +/** + * Response from `GET /v1/vaults/:address/rate-history`. + * Derived from {@link RateHistoryResponseStruct} to ensure type/struct parity. + */ +export type RateHistoryResponse = Infer; diff --git a/packages/money-account-api-data-service/src/structs.ts b/packages/money-account-api-data-service/src/structs.ts new file mode 100644 index 0000000000..ae08ec3dae --- /dev/null +++ b/packages/money-account-api-data-service/src/structs.ts @@ -0,0 +1,101 @@ +import { + array, + boolean, + enums, + number, + object, + string, + nullable, +} from '@metamask/superstruct'; + +const DataFreshnessStruct = enums(['live', 'degraded']); + +const VaultPositionStruct = object({ + vault_address: string(), + shares_held: string(), + current_rate: string(), + current_value_assets: string(), + current_value_usd: string(), + cost_basis_assets: string(), + cost_basis_usd: string(), + realized_interest_usd: string(), + unrealised_interest_usd: string(), + lifetime_interest_usd: string(), + current_apy: string(), + effective_apy: string(), +}); + +export const PositionResponseStruct = object({ + address: string(), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), + positions: array(VaultPositionStruct), +}); + +export const InterestResponseStruct = object({ + address: string(), + vault_address: string(), + window: string(), + window_start: string(), + window_end: string(), + interest_earned_assets: string(), + interest_earned_usd: string(), + method: string(), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), +}); + +const CashFlowEntryStruct = object({ + type: enums(['deposit', 'withdraw', 'transfer_in', 'transfer_out']), + chain_id: number(), + vault_address: string(), + timestamp: string(), + block_number: number(), + log_index: number(), + tx_hash: string(), + assets_usd: string(), + assets_wei: string(), + shares_wei: string(), + rate: string(), + source: enums([ + 'teller', + 'withdraw_queue', + 'atomic_queue', + 'erc20_transfer', + 'cross_chain', + ]), +}); + +export const HistoryResponseStruct = object({ + address: string(), + cash_flows: array(CashFlowEntryStruct), + next_cursor: nullable(string()), + has_more: boolean(), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), +}); + +const RateHistoryEntryStruct = object({ + timestamp: string(), + block_number: number(), + rate: string(), + tx_hash: string(), +}); + +export const RateHistoryResponseStruct = object({ + vault_address: string(), + chain_id: number(), + range_start: string(), + range_end: string(), + rates: array(RateHistoryEntryStruct), + as_of_block: number(), + as_of_timestamp: string(), + data_freshness: DataFreshnessStruct, + indexer_lag_seconds: number(), +}); diff --git a/packages/money-account-api-data-service/src/types.ts b/packages/money-account-api-data-service/src/types.ts new file mode 100644 index 0000000000..e9a91ea507 --- /dev/null +++ b/packages/money-account-api-data-service/src/types.ts @@ -0,0 +1,37 @@ +/** + * Valid time-window values for the interest endpoint. + */ +export type InterestWindow = + | '24h' + | '7d' + | '30d' + | 'ytd' + | 'since_inception'; + +/** + * Options for the `fetchInterest` method. + */ +export type InterestOptions = { + vaultAddress: string; + window: InterestWindow; + chainId?: number; +}; + +/** + * Options for the `fetchHistory` method. + */ +export type HistoryOptions = { + vaultAddress?: string; + chainId?: number; + cursor?: string; + limit?: number; +}; + +/** + * Options for the `fetchRateHistory` method. + */ +export type RateHistoryOptions = { + chainId?: number; + from?: string; + to?: string; +}; diff --git a/packages/money-account-api-data-service/tsconfig.build.json b/packages/money-account-api-data-service/tsconfig.build.json new file mode 100644 index 0000000000..02d3bf93d6 --- /dev/null +++ b/packages/money-account-api-data-service/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-data-service/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-api-data-service/tsconfig.json b/packages/money-account-api-data-service/tsconfig.json new file mode 100644 index 0000000000..e514ad1c60 --- /dev/null +++ b/packages/money-account-api-data-service/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-api-data-service/typedoc.json b/packages/money-account-api-data-service/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/money-account-api-data-service/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 77b6692841..2722eb5128 100644 --- a/teams.json +++ b/teams.json @@ -26,6 +26,7 @@ "metamask/eip-7702-internal-rpc-middleware": "team-delegation", "metamask/earn-controller": "team-earn", "metamask/money-account-balance-service": "team-earn", + "metamask/money-account-api-data-service": "team-earn", "metamask/notification-services-controller": "team-assets", "metamask/compliance-controller": "team-perps", "metamask/perps-controller": "team-perps", diff --git a/tsconfig.build.json b/tsconfig.build.json index 370ddee32e..0c202db087 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -157,6 +157,9 @@ { "path": "./packages/money-account-balance-service/tsconfig.build.json" }, + { + "path": "./packages/money-account-api-data-service/tsconfig.build.json" + }, { "path": "./packages/money-account-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 9e22d9f796..05c711b8a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -149,6 +149,9 @@ { "path": "./packages/money-account-balance-service" }, + { + "path": "./packages/money-account-api-data-service" + }, { "path": "./packages/money-account-controller" }, diff --git a/yarn.lock b/yarn.lock index 858979fbeb..673b61e3a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7383,6 +7383,29 @@ __metadata: languageName: node linkType: hard +"@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": + version: 0.0.0-use.local + resolution: "@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-data-service": "npm:^0.1.3" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + nock: "npm:^13.3.1" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/money-account-balance-service@workspace:packages/money-account-balance-service": version: 0.0.0-use.local resolution: "@metamask/money-account-balance-service@workspace:packages/money-account-balance-service" From 05eaf2631afec77f0a3b7bcf163697b9a0e74072 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 11:25:37 +0200 Subject: [PATCH 02/10] chore: update CHANGELOG --- packages/money-account-api-data-service/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index 396bc35528..0a431c487b 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release of `@metamask/money-account-api-data-service` ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `MoneyAccountApiDataService` data service ([#9278](https://github.com/MetaMask/core/pull/9278)) + - Fetch user vault positions from the Money Account API (`fetchPositions`) + - Fetch interest earned over a time window (`fetchInterest`) + - Fetch cursor-paginated cash-flow history (`fetchHistory`) + - Fetch vault exchange-rate time series (`fetchRateHistory`) [Unreleased]: https://github.com/MetaMask/core/ From 77979c9c8b59da499f7cbff499fab9e86f923513 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 11:29:11 +0200 Subject: [PATCH 03/10] docs: update on docs to clarify pagination consumer calls --- .../src/money-account-api-data-service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts index 9390cc021e..205fb67364 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -283,6 +283,11 @@ export class MoneyAccountApiDataService extends BaseDataService< * Fetches cursor-paginated cash-flow history for a given address. * Uses `fetchInfiniteQuery` for proper TanStack Query pagination semantics. * + * When paginating, consumers must re-pass the same filter options + * (`vaultAddress`, `chainId`, `limit`) alongside `cursor` on every page + * request. This ensures the query key matches the original infinite query + * and that the HTTP request includes the correct filters. + * * @param address - The user's Ethereum address. * @param options - Optional filtering and pagination options. * @returns The history response containing cash-flow entries for the requested page. From aaf3013f2039122022275703ce2ae4c391d2a95e Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 11:43:41 +0200 Subject: [PATCH 04/10] readme-content:update --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 31241932b0..bdddbafdbd 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/messenger`](packages/messenger) - [`@metamask/messenger-cli`](packages/messenger-cli) +- [`@metamask/money-account-api-data-service`](packages/money-account-api-data-service) - [`@metamask/money-account-balance-service`](packages/money-account-balance-service) - [`@metamask/money-account-controller`](packages/money-account-controller) - [`@metamask/money-account-upgrade-controller`](packages/money-account-upgrade-controller) @@ -189,6 +190,7 @@ linkStyle default opacity:0.5 message_manager(["@metamask/message-manager"]); messenger(["@metamask/messenger"]); messenger_cli(["@metamask/messenger-cli"]); + money_account_api_data_service(["@metamask/money-account-api-data-service"]); money_account_balance_service(["@metamask/money-account-balance-service"]); money_account_controller(["@metamask/money-account-controller"]); money_account_upgrade_controller(["@metamask/money-account-upgrade-controller"]); @@ -405,6 +407,9 @@ linkStyle default opacity:0.5 message_manager --> base_controller; message_manager --> controller_utils; message_manager --> messenger; + money_account_api_data_service --> base_data_service; + money_account_api_data_service --> controller_utils; + money_account_api_data_service --> messenger; money_account_balance_service --> base_data_service; money_account_balance_service --> controller_utils; money_account_balance_service --> messenger; From 780f2a81225765f8ca1e4b3f6ed48405cb210134 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 11:48:25 +0200 Subject: [PATCH 05/10] lint fix --- .../src/money-account-api-data-service.test.ts | 8 ++------ .../src/money-account-api-data-service.ts | 3 +-- packages/money-account-api-data-service/src/types.ts | 7 +------ 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts index 591f7c9376..fe8d3f207e 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts @@ -140,9 +140,7 @@ function createServiceMessenger( // Factory // ============================================================ -function createService( - env: Env = Env.DEV, -): { +function createService(env: Env = Env.DEV): { service: MoneyAccountApiDataService; rootMessenger: RootMessenger; messenger: MoneyAccountApiDataServiceMessenger; @@ -590,9 +588,7 @@ describe('MoneyAccountApiDataService', () => { it('is callable via messenger action', async () => { const { rootMessenger, service } = createService(Env.DEV); - await rootMessenger.call( - 'MoneyAccountApiDataService:invalidateQueries', - ); + await rootMessenger.call('MoneyAccountApiDataService:invalidateQueries'); service.destroy(); }); }); diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts index 205fb67364..7d236bc080 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -163,8 +163,7 @@ export class MoneyAccountApiDataService extends BaseDataService< queryClientConfig, policyOptions: { retryFilterPolicy: handleWhen( - (error) => - !(error instanceof MoneyAccountApiResponseValidationError), + (error) => !(error instanceof MoneyAccountApiResponseValidationError), ), ...policyOptions, }, diff --git a/packages/money-account-api-data-service/src/types.ts b/packages/money-account-api-data-service/src/types.ts index e9a91ea507..82500cb2f5 100644 --- a/packages/money-account-api-data-service/src/types.ts +++ b/packages/money-account-api-data-service/src/types.ts @@ -1,12 +1,7 @@ /** * Valid time-window values for the interest endpoint. */ -export type InterestWindow = - | '24h' - | '7d' - | '30d' - | 'ytd' - | 'since_inception'; +export type InterestWindow = '24h' | '7d' | '30d' | 'ytd' | 'since_inception'; /** * Options for the `fetchInterest` method. From 76643bc3cd51d7c1f44417a6c51e232c212768b6 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 11:54:25 +0200 Subject: [PATCH 06/10] build: add missing @tanstack/query-core dependency --- packages/money-account-api-data-service/package.json | 3 ++- yarn.lock | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/money-account-api-data-service/package.json b/packages/money-account-api-data-service/package.json index 000866a07f..5256e682a5 100644 --- a/packages/money-account-api-data-service/package.json +++ b/packages/money-account-api-data-service/package.json @@ -57,7 +57,8 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/messenger": "^1.2.0", "@metamask/superstruct": "^3.1.0", - "@metamask/utils": "^11.11.0" + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/yarn.lock b/yarn.lock index e94709226f..a70fd9f479 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7418,6 +7418,7 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From e52f898ae06efbd547c37a9218e50b68ad2a2a3a Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 11:59:54 +0200 Subject: [PATCH 07/10] generate action types --- .../money-account-api-data-service-method-action-types.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts index 334010179e..a969d0b4d3 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts @@ -31,10 +31,16 @@ export type MoneyAccountApiDataServiceFetchInterestAction = { /** * Fetches cursor-paginated cash-flow history for a given address. + * Uses `fetchInfiniteQuery` for proper TanStack Query pagination semantics. + * + * When paginating, consumers must re-pass the same filter options + * (`vaultAddress`, `chainId`, `limit`) alongside `cursor` on every page + * request. This ensures the query key matches the original infinite query + * and that the HTTP request includes the correct filters. * * @param address - The user's Ethereum address. * @param options - Optional filtering and pagination options. - * @returns The history response containing cash-flow entries. + * @returns The history response containing cash-flow entries for the requested page. */ export type MoneyAccountApiDataServiceFetchHistoryAction = { type: `MoneyAccountApiDataService:fetchHistory`; From ad147c19d4f8feea198f423652bdbe2ef99c1fb0 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 12:17:04 +0200 Subject: [PATCH 08/10] fix yarn lint:eslint --- .../src/money-account-api-data-service.test.ts | 5 ++++- .../src/money-account-api-data-service.ts | 4 ++-- .../money-account-api-data-service/src/response.types.ts | 4 ++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts index fe8d3f207e..cda957357d 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts @@ -588,7 +588,10 @@ describe('MoneyAccountApiDataService', () => { it('is callable via messenger action', async () => { const { rootMessenger, service } = createService(Env.DEV); - await rootMessenger.call('MoneyAccountApiDataService:invalidateQueries'); + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:invalidateQueries', + ); + expect(result).toBeUndefined(); service.destroy(); }); }); diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts index 7d236bc080..990c7421b2 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -250,7 +250,7 @@ export class MoneyAccountApiDataService extends BaseDataService< address.toLowerCase(), options.vaultAddress.toLowerCase(), options.window, - ...(options.chainId !== undefined ? [options.chainId] : []), + ...(options.chainId === undefined ? [] : [options.chainId]), ], staleTime: DEFAULT_STALE_TIME_MS, queryFn: async () => { @@ -385,7 +385,7 @@ export class MoneyAccountApiDataService extends BaseDataService< queryKey: [ `${this.name}:fetchRateHistory`, vaultAddress.toLowerCase(), - ...(options?.chainId !== undefined ? [options.chainId] : [null]), + ...(options?.chainId === undefined ? [null] : [options.chainId]), ...(options?.from ? [options.from] : [null]), ...(options?.to ? [options.to] : [null]), ], diff --git a/packages/money-account-api-data-service/src/response.types.ts b/packages/money-account-api-data-service/src/response.types.ts index 3046d57f50..ff779e24b2 100644 --- a/packages/money-account-api-data-service/src/response.types.ts +++ b/packages/money-account-api-data-service/src/response.types.ts @@ -7,6 +7,10 @@ import type { RateHistoryResponseStruct, } from './structs'; +// All types in this file mirror the external Money Account API's snake_case +// JSON contract verbatim to maintain 1:1 parity with API responses. +/* eslint-disable @typescript-eslint/naming-convention */ + /** * Data freshness indicator returned by all business endpoints. */ From ae6334b712b2a3ad2cd713980f44d6b4f68755e8 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Fri, 26 Jun 2026 12:26:21 +0200 Subject: [PATCH 09/10] address build issues --- .../src/errors.ts | 3 +-- .../src/index.ts | 5 +---- .../money-account-api-data-service.test.ts | 21 ------------------- .../src/money-account-api-data-service.ts | 19 ----------------- 4 files changed, 2 insertions(+), 46 deletions(-) diff --git a/packages/money-account-api-data-service/src/errors.ts b/packages/money-account-api-data-service/src/errors.ts index 9d0b3b0338..de31a87224 100644 --- a/packages/money-account-api-data-service/src/errors.ts +++ b/packages/money-account-api-data-service/src/errors.ts @@ -3,11 +3,10 @@ * validation. Indicates a contract mismatch between client and server. */ export class MoneyAccountApiResponseValidationError extends Error { - constructor(message?: string, options?: ErrorOptions) { + constructor(message?: string) { super( message ?? 'MoneyAccountApiDataService: malformed response received from Money Account API', - options, ); this.name = 'MoneyAccountApiResponseValidationError'; } diff --git a/packages/money-account-api-data-service/src/index.ts b/packages/money-account-api-data-service/src/index.ts index 8fcabaf084..780facd0ac 100644 --- a/packages/money-account-api-data-service/src/index.ts +++ b/packages/money-account-api-data-service/src/index.ts @@ -1,7 +1,4 @@ -export { - getHistoryNextPageParam, - MoneyAccountApiDataService, -} from './money-account-api-data-service'; +export { MoneyAccountApiDataService } from './money-account-api-data-service'; export type { MoneyAccountApiDataServiceActions, MoneyAccountApiDataServiceEvents, diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts index cda957357d..f396130de6 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts @@ -11,7 +11,6 @@ import { Env, MONEY_ACCOUNT_API_URL_MAP } from './constants'; import { MoneyAccountApiResponseValidationError } from './errors'; import type { MoneyAccountApiDataServiceMessenger } from './money-account-api-data-service'; import { - getHistoryNextPageParam, MoneyAccountApiDataService, serviceName, } from './money-account-api-data-service'; @@ -618,24 +617,4 @@ describe('MoneyAccountApiResponseValidationError', () => { expect(error.message).toBe('custom message'); expect(error.name).toBe('MoneyAccountApiResponseValidationError'); }); - - it('preserves the cause when provided', () => { - const cause = new Error('struct failure'); - const error = new MoneyAccountApiResponseValidationError('msg', { cause }); - expect(error.cause).toBe(cause); - }); -}); - -describe('getHistoryNextPageParam', () => { - it('returns next_cursor when present', () => { - expect(getHistoryNextPageParam(MOCK_HISTORY_RESPONSE)).toBe( - 'eyJiIjoxMDAwMH0=', - ); - }); - - it('returns null when there are no more pages', () => { - expect( - getHistoryNextPageParam({ ...MOCK_HISTORY_RESPONSE, next_cursor: null }), - ).toBeNull(); - }); }); diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts index 990c7421b2..2f216249d0 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -113,19 +113,6 @@ export type MoneyAccountApiDataServiceMessenger = Messenger< // === SERVICE DEFINITION === -/** - * Extracts the pagination cursor from a history response for infinite query - * navigation. - * - * @param lastPage - The most recently fetched history page. - * @returns The cursor for the next page, or null if no more pages. - */ -export function getHistoryNextPageParam( - lastPage: HistoryResponse, -): string | null { - return lastPage.next_cursor; -} - /** * Data service responsible for fetching positions, interest, cash-flow * history, and vault rate history from the Money Account APY Tracking API. @@ -210,7 +197,6 @@ export class MoneyAccountApiDataService extends BaseDataService< if (error) { throw new MoneyAccountApiResponseValidationError( `Malformed response from positions endpoint: ${error.message}`, - { cause: error }, ); } @@ -269,7 +255,6 @@ export class MoneyAccountApiDataService extends BaseDataService< if (error) { throw new MoneyAccountApiResponseValidationError( `Malformed response from interest endpoint: ${error.message}`, - { cause: error }, ); } @@ -307,8 +292,6 @@ export class MoneyAccountApiDataService extends BaseDataService< options?.chainId ?? null, options?.limit ?? null, ], - initialPageParam: null as string | null, - getNextPageParam: getHistoryNextPageParam, staleTime: DEFAULT_STALE_TIME_MS, queryFn: async (context) => { const cursor = context.pageParam as string | null | undefined; @@ -345,7 +328,6 @@ export class MoneyAccountApiDataService extends BaseDataService< if (error) { throw new MoneyAccountApiResponseValidationError( `Malformed response from history endpoint: ${error.message}`, - { cause: error }, ); } @@ -406,7 +388,6 @@ export class MoneyAccountApiDataService extends BaseDataService< if (error) { throw new MoneyAccountApiResponseValidationError( `Malformed response from rate-history endpoint: ${error.message}`, - { cause: error }, ); } From 31e57bd366784b06aa960ecb9c6e3f381354c2da Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Tue, 7 Jul 2026 11:40:16 +0200 Subject: [PATCH 10/10] build: address issues --- packages/money-account-api-data-service/CHANGELOG.md | 2 +- packages/money-account-api-data-service/package.json | 2 +- .../money-account-api-data-service/src/index.test.ts | 9 --------- yarn.lock | 4 ++-- 4 files changed, 4 insertions(+), 13 deletions(-) delete mode 100644 packages/money-account-api-data-service/src/index.test.ts diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index 0a431c487b..b2921188d3 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `MoneyAccountApiDataService` data service ([#9278](https://github.com/MetaMask/core/pull/9278)) +- Add `MoneyAccountApiDataService` data service ([#9402](https://github.com/MetaMask/core/pull/9402)) - Fetch user vault positions from the Money Account API (`fetchPositions`) - Fetch interest earned over a time window (`fetchInterest`) - Fetch cursor-paginated cash-flow history (`fetchHistory`) diff --git a/packages/money-account-api-data-service/package.json b/packages/money-account-api-data-service/package.json index 5256e682a5..aebbda7765 100644 --- a/packages/money-account-api-data-service/package.json +++ b/packages/money-account-api-data-service/package.json @@ -55,7 +55,7 @@ "dependencies": { "@metamask/base-data-service": "^0.1.3", "@metamask/controller-utils": "^12.3.0", - "@metamask/messenger": "^1.2.0", + "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", "@tanstack/query-core": "^4.43.0" diff --git a/packages/money-account-api-data-service/src/index.test.ts b/packages/money-account-api-data-service/src/index.test.ts deleted file mode 100644 index bc062d3694..0000000000 --- a/packages/money-account-api-data-service/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/yarn.lock b/yarn.lock index 5993d2eb87..db251e8aca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7418,7 +7418,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/messenger@npm:^1.0.0, @metamask/messenger@npm:^1.1.0, @metamask/messenger@npm:^1.1.1, @metamask/messenger@npm:^1.2.0": +"@metamask/messenger@npm:^1.0.0, @metamask/messenger@npm:^1.1.0, @metamask/messenger@npm:^1.1.1": version: 1.2.0 resolution: "@metamask/messenger@npm:1.2.0" dependencies: @@ -7466,7 +7466,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/messenger": "npm:^1.2.0" + "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0"