diff --git a/README.md b/README.md index 2afee9fea9..84701fb5e1 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,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; diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index b518709c7b..b2921188d3 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -7,4 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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`) + - Fetch vault exchange-rate time series (`fetchRateHistory`) + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-api-data-service/LICENSE b/packages/money-account-api-data-service/LICENSE index 9ec4f4514e..fe29e78e0f 100644 --- a/packages/money-account-api-data-service/LICENSE +++ b/packages/money-account-api-data-service/LICENSE @@ -1,6 +1,21 @@ -This project is licensed under either of +MIT License - * MIT license ([LICENSE.MIT](LICENSE.MIT)) - * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) +Copyright (c) 2026 MetaMask -at your option. +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 index c0da2b6c84..bbbdf2cc5c 100644 --- a/packages/money-account-api-data-service/README.md +++ b/packages/money-account-api-data-service/README.md @@ -1,6 +1,6 @@ # `@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 +Data service for fetching Money account positions, interest, cash-flow history, and vault rate history from the Money Account API. ## Installation diff --git a/packages/money-account-api-data-service/jest.config.js b/packages/money-account-api-data-service/jest.config.js index ca08413339..c17efa251a 100644 --- a/packages/money-account-api-data-service/jest.config.js +++ b/packages/money-account-api-data-service/jest.config.js @@ -11,10 +11,8 @@ const baseConfig = require('../../jest.config.packages'); const displayName = path.basename(__dirname); module.exports = merge(baseConfig, { - // The display name when running multiple projects displayName, - // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { branches: 100, diff --git a/packages/money-account-api-data-service/package.json b/packages/money-account-api-data-service/package.json index 06405917da..aebbda7765 100644 --- a/packages/money-account-api-data-service/package.json +++ b/packages/money-account-api-data-service/package.json @@ -10,7 +10,7 @@ "bugs": { "url": "https://github.com/MetaMask/core/issues" }, - "license": "(MIT OR Apache-2.0)", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/MetaMask/core.git" @@ -52,12 +52,21 @@ "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": "^2.0.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.11.0", + "@tanstack/query-core": "^4.43.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", 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..de31a87224 --- /dev/null +++ b/packages/money-account-api-data-service/src/errors.ts @@ -0,0 +1,13 @@ +/** + * 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) { + super( + message ?? + 'MoneyAccountApiDataService: malformed response received from Money Account API', + ); + this.name = 'MoneyAccountApiResponseValidationError'; + } +} 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/packages/money-account-api-data-service/src/index.ts b/packages/money-account-api-data-service/src/index.ts index 6972c11729..780facd0ac 100644 --- a/packages/money-account-api-data-service/src/index.ts +++ b/packages/money-account-api-data-service/src/index.ts @@ -1,9 +1,32 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { 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..a969d0b4d3 --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts @@ -0,0 +1,69 @@ +/** + * 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. + * 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. + */ +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..f396130de6 --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts @@ -0,0 +1,620 @@ +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 { + 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); + + const result = await rootMessenger.call( + 'MoneyAccountApiDataService:invalidateQueries', + ); + expect(result).toBeUndefined(); + 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'); + }); +}); 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..2f216249d0 --- /dev/null +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -0,0 +1,398 @@ +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 === + +/** + * 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}`, + ); + } + + 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}`, + ); + } + + return validated as unknown as InterestResponse; + }, + }); + } + + /** + * 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. + */ + 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, + ], + 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}`, + ); + } + + 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 ? [null] : [options.chainId]), + ...(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}`, + ); + } + + 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..ff779e24b2 --- /dev/null +++ b/packages/money-account-api-data-service/src/response.types.ts @@ -0,0 +1,106 @@ +import type { Infer } from '@metamask/superstruct'; + +import type { + HistoryResponseStruct, + InterestResponseStruct, + PositionResponseStruct, + 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. + */ +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..82500cb2f5 --- /dev/null +++ b/packages/money-account-api-data-service/src/types.ts @@ -0,0 +1,32 @@ +/** + * 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 index 02a0eea03f..02d3bf93d6 100644 --- a/packages/money-account-api-data-service/tsconfig.build.json +++ b/packages/money-account-api-data-service/tsconfig.build.json @@ -5,6 +5,10 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "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 index 025ba2ef7f..e514ad1c60 100644 --- a/packages/money-account-api-data-service/tsconfig.json +++ b/packages/money-account-api-data-service/tsconfig.json @@ -3,6 +3,10 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { "path": "../base-data-service" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ], "include": ["../../types", "./src"] } diff --git a/tsconfig.build.json b/tsconfig.build.json index 4f55be827b..3ab29485e3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -166,6 +166,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 3737df4455..e079935b56 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -158,6 +158,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 a2fae7c9df..db251e8aca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7464,10 +7464,17 @@ __metadata: 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:^2.0.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" 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"