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

## [Unreleased]

### Added

- Add `getAssociatedAddresses` method, exposed as the `ChompApiService:getAssociatedAddresses` messenger action, which fetches the active address associations of the authenticated profile via `GET /v1/auth/address`
- Also adds the `ProfileAddressEntry` type describing each returned entry and the `ChompApiServiceGetAssociatedAddressesAction` type
- Returned addresses are parsed into canonical lowercase form, entries are guaranteed to have `status: 'active'`, and results are never served from cache (the response is scoped to the authenticated profile, which the cache key cannot capture)

### Changed

- **BREAKING:** `associateAddress` now throws an `HttpError` on a 409 response instead of returning the parsed body
- A 409 from `POST /v1/auth/address` indicates the address is associated with a _different_ profile; the previous handling attempted to parse the error body as an association result and failed with a confusing validation error. An address already associated with the authenticated profile is reported via a 201 response with `status: 'active'`, which is unchanged.
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218))
- Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,33 @@ import type { ChompApiService } from './chomp-api-service';
*
* @param params - The association params containing signature, timestamp,
* and address.
* @returns The profile association result. Returns on both 201 and 409.
* @returns The profile association result: `status: 'created'` for a new
* association, `status: 'active'` when the address was already associated
* with the authenticated profile. Throws on 409, which indicates the
* address is associated with a different profile.
*/
export type ChompApiServiceAssociateAddressAction = {
type: `ChompApiService:associateAddress`;
handler: ChompApiService['associateAddress'];
};

/**
* Fetches the addresses associated with the authenticated profile.
*
* GET /v1/auth/address
*
* The result is never served from cache (`staleTime: 0`): it is scoped to
* the authenticated profile (which the query key cannot capture) and
* consumers use it to decide whether an association already exists.
*
* @returns The active address associations; empty array if none exist.
* Addresses are lowercased.
*/
export type ChompApiServiceGetAssociatedAddressesAction = {
type: `ChompApiService:getAssociatedAddresses`;
handler: ChompApiService['getAssociatedAddresses'];
};

/**
* Creates an account upgrade request.
*
Expand Down Expand Up @@ -120,6 +140,7 @@ export type ChompApiServiceGetServiceDetailsAction = {
*/
export type ChompApiServiceMethodActions =
| ChompApiServiceAssociateAddressAction
| ChompApiServiceGetAssociatedAddressesAction
| ChompApiServiceCreateUpgradeAction
| ChompApiServiceGetUpgradesAction
| ChompApiServiceVerifyDelegationAction
Expand Down
118 changes: 115 additions & 3 deletions packages/chomp-api-service/src/chomp-api-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ describe('ChompApiService', () => {
});
});

it('returns the response on 409 without throwing', async () => {
nock(BASE_URL).post('/v1/auth/address').reply(409, {
it('returns the response when the address is already associated with the profile', async () => {
nock(BASE_URL).post('/v1/auth/address').reply(201, {
address: '0xabc',
status: 'active',
});
Expand All @@ -60,7 +60,19 @@ describe('ChompApiService', () => {
});
});

it('throws on non-201/409 status', async () => {
it('throws when the address is associated with another profile (409)', async () => {
nock(BASE_URL).post('/v1/auth/address').reply(409, {
statusCode: 409,
message: 'Address is already associated with another profile',
});
const { service } = createService();

await expect(service.associateAddress(associateParams)).rejects.toThrow(
"POST /v1/auth/address failed with status '409'",
);
});

it('throws on non-OK status', async () => {
nock(BASE_URL)
.post('/v1/auth/address')
.times(DEFAULT_MAX_RETRIES + 1)
Expand All @@ -84,6 +96,106 @@ describe('ChompApiService', () => {
});
});

describe('getAssociatedAddresses', () => {
const addressEntry = {
profileId: 'p1',
address: '0xabc',
status: 'active',
};

it('sends a GET with auth headers and returns the address entries', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`)
.reply(200, [addressEntry]);
const { rootMessenger } = createService();

const result = await rootMessenger.call(
'ChompApiService:getAssociatedAddresses',
);

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

it('returns an empty array when no addresses are associated', async () => {
nock(BASE_URL).get('/v1/auth/address').reply(200, []);
const { service } = createService();

const result = await service.getAssociatedAddresses();

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

it('lowercases returned addresses', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.reply(200, [
{
profileId: 'p1',
address: '0xABCdef1234567890ABCdef1234567890ABCdef12',
status: 'active',
},
]);
const { service } = createService();

const result = await service.getAssociatedAddresses();

expect(result).toStrictEqual([
{
profileId: 'p1',
address: '0xabcdef1234567890abcdef1234567890abcdef12',
status: 'active',
},
]);
});

it('rejects entries with a non-active status', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.reply(200, [{ profileId: 'p1', address: '0xabc', status: 'deleted' }]);
const { service } = createService();

await expect(service.getAssociatedAddresses()).rejects.toThrow(
'At path: 0.status',
);
});

it('does not serve results from cache', async () => {
nock(BASE_URL).get('/v1/auth/address').reply(200, []);
nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]);
const { service } = createService();

const first = await service.getAssociatedAddresses();
const second = await service.getAssociatedAddresses();

expect(first).toStrictEqual([]);
expect(second).toStrictEqual([addressEntry]);
});

it('throws on non-OK status', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.times(DEFAULT_MAX_RETRIES + 1)
.reply(500);
const { service } = createService();

await expect(service.getAssociatedAddresses()).rejects.toThrow(
"GET /v1/auth/address failed with status '500'",
);
});

it('throws on malformed response', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.reply(200, JSON.stringify([{ bad: 'data' }]));
const { service } = createService();

await expect(service.getAssociatedAddresses()).rejects.toThrow(
'At path: 0.profileId',
);
});
});

describe('createUpgrade', () => {
const upgradeParams = {
r: '0x1' as const,
Expand Down
65 changes: 63 additions & 2 deletions packages/chomp-api-service/src/chomp-api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Messenger } from '@metamask/messenger';
import {
array,
boolean,
coerce,
create,
enums,
literal,
Expand All @@ -27,6 +28,7 @@ import type { ChompApiServiceMethodActions } from './chomp-api-service-method-ac
import type {
AssociateAddressParams,
AssociateAddressResponse,
ProfileAddressEntry,
CreateUpgradeParams,
CreateUpgradeResponse,
UpgradeEntry,
Expand Down Expand Up @@ -56,6 +58,7 @@ export const serviceName = 'ChompApiService';
*/
const MESSENGER_EXPOSED_METHODS = [
'associateAddress',
'getAssociatedAddresses',
'createUpgrade',
'getUpgrades',
'verifyDelegation',
Expand Down Expand Up @@ -129,6 +132,24 @@ const AssociateAddressResponseStruct = type({
status: enums(['active', 'created']),
});

/**
* Parses addresses into canonical lowercase form. CHOMP stores and returns
* addresses lowercased, but `StrictHexStruct` alone accepts any casing, so
* this makes the canonical form a guarantee of the parsed data rather than a
* convention consumers must each remember.
*/
const LowercaseHexAddressStruct = coerce(StrictHexStruct, string(), (value) =>
value.toLowerCase(),
);

const ProfileAddressEntryArrayStruct = array(
type({
profileId: string(),
address: LowercaseHexAddressStruct,
status: enums(['active']),
}),
);

const AccountUpgradeStatusStruct = enums(['pending', 'upgraded']);

const AuthorizationDataStruct = type({
Expand Down Expand Up @@ -323,7 +344,10 @@ export class ChompApiService extends BaseDataService<
*
* @param params - The association params containing signature, timestamp,
* and address.
* @returns The profile association result. Returns on both 201 and 409.
* @returns The profile association result: `status: 'created'` for a new
* association, `status: 'active'` when the address was already associated
* with the authenticated profile. Throws on 409, which indicates the
* address is associated with a different profile.
*/
async associateAddress(
params: AssociateAddressParams,
Expand All @@ -342,7 +366,7 @@ export class ChompApiService extends BaseDataService<
},
);

if (!response.ok && response.status !== 409) {
if (!response.ok) {
throw new HttpError(
response.status,
`POST /v1/auth/address failed with status '${response.status}'`,
Expand All @@ -356,6 +380,43 @@ export class ChompApiService extends BaseDataService<
return create(jsonResponse, AssociateAddressResponseStruct);
}

/**
* Fetches the addresses associated with the authenticated profile.
*
* GET /v1/auth/address
*
* The result is never served from cache (`staleTime: 0`): it is scoped to
* the authenticated profile (which the query key cannot capture) and
* consumers use it to decide whether an association already exists.
*
* @returns The active address associations; empty array if none exist.
* Addresses are lowercased.
*/
async getAssociatedAddresses(): Promise<ProfileAddressEntry[]> {
const jsonResponse = await this.fetchQuery({
queryKey: [`${this.name}:getAssociatedAddresses`],
staleTime: 0,
queryFn: async () => {
const headers = await this.#authHeaders();
const response = await fetch(
new URL('/v1/auth/address', this.#baseUrl),
{ headers },
);

if (!response.ok) {
throw new HttpError(
response.status,
`GET /v1/auth/address failed with status '${response.status}'`,
);
}

return response.json();
},
});

return create(jsonResponse, ProfileAddressEntryArrayStruct);
}

/**
* Creates an account upgrade request.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/chomp-api-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
} from './chomp-api-service';
export type {
ChompApiServiceAssociateAddressAction,
ChompApiServiceGetAssociatedAddressesAction,
ChompApiServiceCreateUpgradeAction,
ChompApiServiceGetUpgradesAction,
ChompApiServiceVerifyDelegationAction,
Expand All @@ -31,6 +32,7 @@ export type {
IntentEntry,
IntentMetadataParams,
IntentMetadataResponse,
ProfileAddressEntry,
SendIntentParams,
SendIntentResponse,
ServiceDetailsChain,
Expand Down
14 changes: 14 additions & 0 deletions packages/chomp-api-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,27 @@ export type CreateWithdrawalParams = {
* `profileId` is only included when the address was newly associated
* (`status: 'created'`). When the address was already associated with the
* authenticated profile (`status: 'active'`), only `address` is returned.
* Both cases respond with 201; an address associated with a different
* profile responds with 409, which is surfaced as an error.
*/
export type AssociateAddressResponse = {
profileId?: string;
address: Hex;
status: 'active' | 'created';
};

/**
* One entry returned by GET /v1/auth/address. The endpoint returns an array
* of these — the active address associations of the authenticated profile
* (the API filters out soft-deleted associations, so `status` is always
* `'active'`). Addresses are lowercased.
*/
export type ProfileAddressEntry = {
profileId: string;
address: Hex;
status: 'active';
};

export type AccountUpgradeStatus = 'pending' | 'upgraded';

export type AuthorizationData = {
Expand Down
7 changes: 7 additions & 0 deletions packages/money-account-upgrade-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING:** The `associate-address` upgrade step now checks the profile's existing address associations via `ChompApiService:getAssociatedAddresses` before signing, and reports `already-done` without signing or submitting anything when the address is already associated
- `MoneyAccountUpgradeControllerMessenger` consumers must grant the `ChompApiService:getAssociatedAddresses` action alongside the previously required actions, and must provide a `@metamask/chomp-api-service` version that registers it (`>=3.2.0`).
- The lookup is an optimization: if it fails, the step falls through to the previous sign-and-submit behavior.
- A 409 conflict from the association request is disambiguated by re-fetching the associations, so a same-profile create race reports `already-done` instead of failing the upgrade; a genuine conflict (address associated with a different profile) still fails the step.

## [2.2.1]

### Changed
Expand Down
Loading
Loading