Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/chomp-api-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `ChompApiService.setBaseUrl` to update the base URL after construction. The base URL is now part of every query key, so requests are cached per endpoint and a request in flight against a previous endpoint can never be served in place of a result from the new one ([#9056](https://github.com/MetaMask/core/pull/9056))

### Changed

- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774))
Expand Down
56 changes: 56 additions & 0 deletions packages/chomp-api-service/src/chomp-api-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,62 @@ describe('ChompApiService', () => {
expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1);
});
});

describe('setBaseUrl', () => {
const NEW_BASE_URL = 'https://api.chomp.other.example.com';

it('routes subsequent requests to the new base URL', async () => {
nock(NEW_BASE_URL).get('/v1/account-upgrade/0xabc').reply(200, []);
const { service } = createService();

service.setBaseUrl(NEW_BASE_URL);
const result = await service.getUpgrades('0xabc');

expect(result).toStrictEqual([]);
});

it('caches per endpoint, so a read after the URL changes is not served from the previous endpoint cache', async () => {
// The base URL is part of the query key, so the entry cached against the
// original endpoint and the read issued after the switch are distinct
// queries: the second read misses the cache and hits the new host rather
// than being served the previous endpoint's data.
nock(BASE_URL).get('/v1/account-upgrade/0xabc').reply(200, []);
const { service } = createService();
await service.getUpgrades('0xabc'); // fully cached under the original URL

const newScope = nock(NEW_BASE_URL)
.get('/v1/account-upgrade/0xabc')
.reply(200, []);
service.setBaseUrl(NEW_BASE_URL);
await service.getUpgrades('0xabc');

expect(newScope.isDone()).toBe(true);
});

it('keeps a request in flight against the old endpoint on its own cache entry, so it cannot satisfy a read against the new one', async () => {
const oldScope = nock(BASE_URL)
.get('/v1/account-upgrade/0xabc')
.delay(20)
.reply(200, []);
const newScope = nock(NEW_BASE_URL)
.get('/v1/account-upgrade/0xabc')
.reply(200, []);
const { service } = createService();

// Let the first request get past the async auth-header step and actually
// hit the network against BASE_URL before switching, so it is genuinely
// in flight against the old endpoint when the new read is issued.
const inFlight = service.getUpgrades('0xabc');
await new Promise((resolve) => setTimeout(resolve, 0));

service.setBaseUrl(NEW_BASE_URL);
await service.getUpgrades('0xabc');
await inFlight;

expect(oldScope.isDone()).toBe(true);
expect(newScope.isDone()).toBe(true);
});
});
});

/**
Expand Down
34 changes: 25 additions & 9 deletions packages/chomp-api-service/src/chomp-api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export class ChompApiService extends BaseDataService<
typeof serviceName,
ChompApiServiceMessenger
> {
readonly #baseUrl: string;
#baseUrl: string;

/**
* Constructs a new ChompApiService.
Expand Down Expand Up @@ -301,6 +301,22 @@ export class ChompApiService extends BaseDataService<
);
}

/**
* Updates the base URL used for all subsequent CHOMP API requests.
*
* This is useful when the endpoint is sourced from a remote feature flag
* that may not have hydrated by the time the service is constructed: the
* service can be created with a fallback URL and re-pointed once the flag
* lands. The base URL is part of every query key, so requests already in
* flight against the previous endpoint occupy separate cache entries and
* can never be served in place of results from the new endpoint.
*
* @param baseUrl - The new base URL of the CHOMP API.
*/
setBaseUrl(baseUrl: string): void {
this.#baseUrl = baseUrl;
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Builds the standard headers for an authenticated CHOMP API request.
*
Expand Down Expand Up @@ -329,7 +345,7 @@ export class ChompApiService extends BaseDataService<
params: AssociateAddressParams,
): Promise<AssociateAddressResponse> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:associateAddress`, params],
queryKey: [`${this.name}:associateAddress`, this.#baseUrl, params],
staleTime: 0,
queryFn: async () => {
const headers = await this.#authHeaders();
Expand Down Expand Up @@ -369,7 +385,7 @@ export class ChompApiService extends BaseDataService<
params: CreateUpgradeParams,
): Promise<CreateUpgradeResponse> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:createUpgrade`, params],
queryKey: [`${this.name}:createUpgrade`, this.#baseUrl, params],
staleTime: 0,
queryFn: async () => {
const headers = await this.#authHeaders();
Expand Down Expand Up @@ -407,7 +423,7 @@ export class ChompApiService extends BaseDataService<
*/
async getUpgrades(address: Hex): Promise<UpgradeEntry[]> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:getUpgrades`, address],
queryKey: [`${this.name}:getUpgrades`, this.#baseUrl, address],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-flight fetch uses mutated base URL

Medium Severity

Each API method puts this.#baseUrl in the TanStack queryKey when the call starts, but the queryFn reads this.#baseUrl again only after awaiting #authHeaders(). If setBaseUrl runs in between, the network request can hit the new host while the result is stored under the old URL’s cache key, returning or caching data from the wrong endpoint.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f80c4b9. Configure here.

queryFn: async () => {
const headers = await this.#authHeaders();
const response = await fetch(
Expand Down Expand Up @@ -441,7 +457,7 @@ export class ChompApiService extends BaseDataService<
params: VerifyDelegationParams,
): Promise<VerifyDelegationResponse> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:verifyDelegation`, params],
queryKey: [`${this.name}:verifyDelegation`, this.#baseUrl, params],
staleTime: 0,
queryFn: async () => {
const headers = await this.#authHeaders();
Expand Down Expand Up @@ -480,7 +496,7 @@ export class ChompApiService extends BaseDataService<
intents: SendIntentParams[],
): Promise<SendIntentResponse[]> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:createIntents`, intents],
queryKey: [`${this.name}:createIntents`, this.#baseUrl, intents],
staleTime: 0,
queryFn: async () => {
const headers = await this.#authHeaders();
Expand Down Expand Up @@ -514,7 +530,7 @@ export class ChompApiService extends BaseDataService<
*/
async getIntentsByAddress(address: Hex): Promise<IntentEntry[]> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:getIntentsByAddress`, address],
queryKey: [`${this.name}:getIntentsByAddress`, this.#baseUrl, address],
queryFn: async () => {
const headers = await this.#authHeaders();
const response = await fetch(
Expand Down Expand Up @@ -549,7 +565,7 @@ export class ChompApiService extends BaseDataService<
params: CreateWithdrawalParams,
): Promise<CreateWithdrawalResponse> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:createWithdrawal`, params],
queryKey: [`${this.name}:createWithdrawal`, this.#baseUrl, params],
staleTime: 0,
queryFn: async () => {
const headers = await this.#authHeaders();
Expand Down Expand Up @@ -585,7 +601,7 @@ export class ChompApiService extends BaseDataService<
*/
async getServiceDetails(chainIds: Hex[]): Promise<ServiceDetailsResponse> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:getServiceDetails`, chainIds],
queryKey: [`${this.name}:getServiceDetails`, this.#baseUrl, chainIds],
queryFn: async () => {
const headers = await this.#authHeaders();
const url = new URL('/v1/chomp', this.#baseUrl);
Expand Down
Loading