Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/money-account-api-data-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional `fresh` option to `fetchPositions` that cancels in-flight reads, fetches with a zero stale time, invalidates the result for subsequent reads, and sends `Cache-Control: no-cache` so the Money API skips its Nest response cache when supported ([#10455](https://github.com/MetaMask/core/pull/10455))
- Accept optional additive `musd_balance_updated_at` on the positions `balance` summary ([#10455](https://github.com/MetaMask/core/pull/10455))

### Changed

- Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192))
Expand Down
1 change: 1 addition & 0 deletions packages/money-account-api-data-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {
export type {
InterestWindow,
InterestOptions,
FetchPositionsOptions,
HistoryOptions,
RateHistoryOptions,
} from './types.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import type { MoneyAccountApiDataService } from './money-account-api-data-servic
* Fetches the current vault positions for a given user address.
*
* @param address - The user's Ethereum address.
* @param options - Optional fetch options.
* @param options.fresh - When true, cancels in-flight reads, fetches with a
* zero stale time, invalidates the result for subsequent reads, and asks the
* Money API to skip its Nest response cache with `Cache-Control: no-cache`.
* @returns The position response containing vault positions and an optional
* `balance` summary (`null` when the API balance path is unavailable).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,87 @@ describe('MoneyAccountApiDataService', () => {
service.destroy();
});

it('sends Cache-Control: no-cache and invalidates the cached result when fresh', async () => {
const { service } = createService(Env.DEV);

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

await service.fetchPositions(MOCK_ADDRESS);

const refreshed = {
...MOCK_POSITION_RESPONSE,
as_of_block: 99999,
balance: {
...MOCK_POSITION_BALANCE,
musd_balance: '99',
total_balance: '1513626',
},
};

nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.matchHeader('cache-control', 'no-cache')
.reply(200, refreshed);

const freshResult = await service.fetchPositions(MOCK_ADDRESS, {
fresh: true,
});
expect(freshResult.as_of_block).toBe(99999);
expect(freshResult.balance?.musd_balance).toBe('99');

// The fresh result is invalidated after it is returned, so the next
// steady-state read must hit the network again rather than serve a
// pre-transaction body.
const afterFresh = {
...MOCK_POSITION_RESPONSE,
as_of_block: 100000,
};
nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.reply(200, afterFresh);

const steadyState = await service.fetchPositions(MOCK_ADDRESS);
expect(steadyState.as_of_block).toBe(100000);
service.destroy();
});

it('retries a fresh fetchPositions request using the service policy', async () => {
const { service } = createService(Env.DEV);

nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.matchHeader('cache-control', 'no-cache')
.reply(500);
nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.matchHeader('cache-control', 'no-cache')
.reply(200, MOCK_POSITION_RESPONSE);

const result = await service.fetchPositions(MOCK_ADDRESS, {
fresh: true,
});

expect(result).toStrictEqual(MOCK_POSITION_RESPONSE);
service.destroy();
});

it('throws HttpError after exhausting retries on a fresh fetchPositions request', async () => {
const { service } = createService(Env.DEV);

nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV])
.get(`/v1/positions/${MOCK_ADDRESS}`)
.matchHeader('cache-control', 'no-cache')
.times(DEFAULT_MAX_RETRIES + 1)
.reply(500);

await expect(
service.fetchPositions(MOCK_ADDRESS, { fresh: true }),
).rejects.toThrow(HttpError);
service.destroy();
});

it('throws HttpError on non-2xx response', async () => {
const { service } = createService(Env.DEV);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
HistoryOptions,
InterestOptions,
RateHistoryOptions,
FetchPositionsOptions,
} from './types.js';

// === GENERAL ===
Expand Down Expand Up @@ -242,50 +243,98 @@ export class MoneyAccountApiDataService extends BaseDataService<
* Fetches the current vault positions for a given user address.
*
* @param address - The user's Ethereum address.
* @param options - Optional fetch options.
* @param options.fresh - When true, cancels in-flight reads, fetches with a
* zero stale time, invalidates the result for subsequent reads, and asks the
* Money API to skip its Nest response cache with `Cache-Control: no-cache`.
* @returns The position response containing vault positions and an optional
* `balance` summary (`null` when the API balance path is unavailable).
*/
async fetchPositions(address: string): Promise<PositionResponse> {
const url = new URL(
`/v1/positions/${address.toLowerCase()}`,
this.#baseUrl,
);
async fetchPositions(
address: string,
{ fresh = false }: FetchPositionsOptions = {},
): Promise<PositionResponse> {
const normalizedAddress = address.toLowerCase();
const queryKey = [
`${this.name}:fetchPositions`,
normalizedAddress,
] as const;

if (fresh) {
// Cancel any in-flight non-fresh fetch so it cannot win the shared
// queryKey and re-seed a response that omitted Cache-Control: no-cache.
await this.cancelQueries({ queryKey: [...queryKey] });
}

return this.fetchQuery({
queryKey: [`${this.name}:fetchPositions`, address.toLowerCase()],
staleTime: DEFAULT_STALE_TIME_MS,
const result = await this.fetchQuery({
queryKey: [...queryKey],
staleTime: fresh ? 0 : DEFAULT_STALE_TIME_MS,
queryFn: async () => {
return this.#traceNetworkRequest(
{
name: TRACES.POSITIONS_API,
data: { operation: 'fetchPositions' },
},
async () => {
const response = await fetch(url, {
headers: await this.#getRequestHeaders(),
});
return this.#fetchPositionsFromNetwork(normalizedAddress, fresh);
},
});

if (!response.ok) {
throw new HttpError(
response.status,
`Money Account API positions request failed with status '${response.status}'`,
);
}
if (fresh) {
// The balance service may reject this body using minBlock. Keep it
// available to this caller, but do not let a later steady-state request
// treat it as warm merely because that request has a non-zero staleTime.
await this.invalidateQueries({
queryKey: [...queryKey],
refetchType: 'none',
});
}

const json: Json = await response.json();
return result;
}

const [error, validated] = validate(json, PositionResponseStruct);
if (error) {
throw new MoneyAccountApiResponseValidationError(
`Malformed response from positions endpoint: ${error.message}`,
);
}
/**
* Performs the HTTP GET for `/v1/positions/:address`.
*
* @param normalizedAddress - Lowercased account address.
* @param fresh - When true, send `Cache-Control: no-cache`.
* @returns Validated positions response.
*/
async #fetchPositionsFromNetwork(
normalizedAddress: string,
fresh: boolean,
): Promise<PositionResponse> {
const url = new URL(`/v1/positions/${normalizedAddress}`, this.#baseUrl);

return validated;
},
);
return this.#traceNetworkRequest(
{
name: TRACES.POSITIONS_API,
data: {
operation: 'fetchPositions',
...(fresh ? { fresh: true } : {}),
},
},
});
async () => {
const headers = {
...(await this.#getRequestHeaders()),
...(fresh ? { 'Cache-Control': 'no-cache' } : {}),
};

const response = await fetch(url, { headers });

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;
},
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export type PositionBalance = {
musd_balance: string;
vmusd_value_in_musd: string;
total_balance: string;
/** ISO-8601 timestamp of the last wallet mUSD observation, when provided. */
musd_balance_updated_at?: string | null;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/money-account-api-data-service/src/structs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const PositionBalanceStruct = object({
musd_balance: string(),
vmusd_value_in_musd: string(),
total_balance: string(),
// Additive — older API builds omit this field.
musd_balance_updated_at: optional(nullable(string())),
});

export const PositionResponseStruct = object({
Expand Down
14 changes: 14 additions & 0 deletions packages/money-account-api-data-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ export type InterestOptions = {
chainId?: number;
};

/**
* Options for the `fetchPositions` method.
*/
export type FetchPositionsOptions = {
/**
* When true, cancel an in-flight read, fetch with a zero stale time,
* invalidate the result for subsequent reads, and send
* `Cache-Control: no-cache` so the Money API also skips its Nest response
* cache and refreshes the wallet mUSD balance on demand. Intended for
* post-transaction read-your-writes refreshes.
*/
fresh?: boolean;
};

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

## [Unreleased]

### Added

- Surface Money API freshness on `fetchBalanceWithFallback` results (`asOfBlock`, `asOfTimestamp`, `dataFreshness`, `indexerLagSeconds`, `musdBalanceUpdatedAt`) when `source` is `api` ([#10455](https://github.com/MetaMask/core/pull/10455))
- Add optional `FetchBalanceWithFallbackOptions` (`minBlock`, `fresh`): when the API `as_of_block` is behind `minBlock`, throw `MoneyAccountBalanceStaleError` and fall back to RPC without reporting a defect; `minBlock` implies a fresh positions read, and explicit `fresh` is also forwarded to `fetchPositions` ([#10455](https://github.com/MetaMask/core/pull/10455))
- Export `MoneyAccountBalanceStaleError` ([#10455](https://github.com/MetaMask/core/pull/10455))

### Changed

- Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192))
Expand Down
14 changes: 14 additions & 0 deletions packages/money-account-balance-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,23 @@ const result = await service.fetchBalanceWithFallback(accountAddress);
// musdBalance, vmusdValueInMusd, totalBalance,
// source: 'api' | 'rpc',
// usedFallback: boolean,
// // when source === 'api':
// asOfBlock?, asOfTimestamp?, dataFreshness?, indexerLagSeconds?,
// musdBalanceUpdatedAt?,
// }
```

Post-transaction refreshes may pass additive options so a lagging Money API
result falls back to pending-tag RPC once, without changing the steady-state
`api` policy:

```ts
await service.fetchBalanceWithFallback(accountAddress, {
fresh: true,
minBlock: confirmedTxBlockNumber,
});
```

### Feature flag: `moneyAccountBalanceSource`

| Value | Behavior |
Expand Down
22 changes: 22 additions & 0 deletions packages/money-account-balance-service/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ export class MoneyAccountBalanceUnavailableError extends Error {
}
}

/**
* Thrown when the Money API returns a structurally valid balance whose
* indexer watermark (`as_of_block`) is still behind a caller-supplied
* `minBlock` (typically the confirmed transaction's block number).
* Triggers RPC fallback in {@link MoneyAccountBalanceService.fetchBalanceWithFallback}
* without being reported as a defect — lag is expected shortly after confirm.
*/
export class MoneyAccountBalanceStaleError extends Error {
readonly asOfBlock: number;

readonly minBlock: number;

constructor(asOfBlock: number, minBlock: number) {
super(
`Money API balance is stale: as_of_block ${asOfBlock} < minBlock ${minBlock}`,
);
this.name = 'MoneyAccountBalanceStaleError';
this.asOfBlock = asOfBlock;
this.minBlock = minBlock;
}
}

/**
* Thrown when every eligible balance source fails. Preserves each cause for
* diagnostics; never substitutes a zero balance.
Expand Down
2 changes: 2 additions & 0 deletions packages/money-account-balance-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type {
export type {
CanonicalMoneyAccountBalanceResponse,
ExchangeRateResponse,
FetchBalanceWithFallbackOptions,
MoneyAccountBalanceResponse,
MusdEquivalentValueResponse,
NormalizedVaultApyResponse,
Expand All @@ -31,6 +32,7 @@ export {
} from './constants.js';
export {
MoneyAccountBalanceFetchError,
MoneyAccountBalanceStaleError,
MoneyAccountBalanceUnavailableError,
MoneyAccountBalanceValidationError,
VaultConfigNotAvailableError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import type { MoneyAccountBalanceService } from './money-account-balance-service
* Callers must not select a source. Provenance is returned on the result so
* fallback is never silent. Malformed or unavailable source balances are
* reported via the messenger's `captureException` before fallback.
* Stale API results (behind `options.minBlock`) also fall back to RPC when
* the policy allows, without being reported as defects.
*
* @param accountAddress - The Money account's Ethereum address.
* @param options - Optional freshness / cache-bypass controls (additive).
* @returns Canonical balance amounts with source provenance.
* @throws {@link MoneyAccountBalanceFetchError} when every eligible source
* fails. Never returns a synthetic zero balance.
Expand Down
Loading
Loading