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
4 changes: 4 additions & 0 deletions packages/client-utils/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 `mapRampsOrder` for mapping ramps buy/sell orders into the shared activity item shape, and add `rampBuy`/`rampSell` to `ActivityKind` and `ActivityItem` ([#9650](https://github.com/MetaMask/core/pull/9650))

## [1.3.1]

### Changed
Expand Down
2 changes: 2 additions & 0 deletions packages/client-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ export type { Formatters } from './formatters/create-formatters.js';
export { mapApiTransaction } from './mappers/api-transaction-mapper.js';
export { mapKeyringTransaction } from './mappers/keyring-transaction-mapper.js';
export { mapLocalTransaction } from './mappers/local-transaction-mapper.js';
export { mapRampsOrder } from './mappers/ramps-order-mapper.js';
export type { RampsOrderLike } from './mappers/ramps-order-mapper.js';

export type * from './types.js';
4 changes: 4 additions & 0 deletions packages/client-utils/src/mappers/helpers/caip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ describe('caip helpers', () => {
it('returns undefined for invalid decimal chain ids', () => {
expect(formatChainIdToCaip('not-a-number')).toBeUndefined();
});

it('returns undefined for an empty chain id instead of eip155:0', () => {
expect(formatChainIdToCaip('')).toBeUndefined();
});
});

describe('formatAddressToAssetId', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/client-utils/src/mappers/helpers/caip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export function formatChainIdToCaip(
return Number.isNaN(reference) ? undefined : `eip155:${reference}`;
}

if (chainId === '') {
return undefined;
}

const reference = Number(chainId);
return Number.isNaN(reference) ? undefined : `eip155:${reference}`;
}
Expand Down
264 changes: 264 additions & 0 deletions packages/client-utils/src/mappers/ramps-order-mapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import type { RampsOrderLike } from './ramps-order-mapper.js';
import { mapRampsOrder } from './ramps-order-mapper.js';

const baseOrder: RampsOrderLike = {
provider: { id: 'transak', name: 'Transak' },
cryptoAmount: '0.05',
fiatAmount: 100,
cryptoCurrency: {
assetId: 'eip155:1/slip44:60',
symbol: 'ETH',
decimals: 18,
},
fiatCurrency: { symbol: 'USD' },
providerOrderId: 'order-123',
providerOrderLink: 'https://transak.com/orders/order-123',
createdAt: 1716367781000,
totalFeesFiat: 2.5,
txHash: '0xabc',
walletAddress: '0xwallet',
status: 'COMPLETED',
network: { chainId: '1' },
statusDescription: 'Your purchase was successful!',
orderType: 'buy',
paymentDetails: [{ fiatCurrency: 'USD', paymentMethod: 'card', fields: [] }],
};

describe('mapRampsOrder', () => {
it('maps a completed buy order to a rampBuy activity item', () => {
const item = mapRampsOrder(baseOrder);

expect(item).toMatchObject({
type: 'rampBuy',
chainId: 'eip155:1',
status: 'success',
timestamp: 1716367781000,
hash: '0xabc',
data: {
from: '0xwallet',
fiat: { amount: '100', currency: 'USD' },
token: {
amount: '0.05',
symbol: 'ETH',
assetId: 'eip155:1/slip44:60',
direction: 'in',
},
fees: [{ type: 'total', amount: '2.5', symbol: 'USD' }],
provider: {
id: 'transak',
name: 'Transak',
orderLink: 'https://transak.com/orders/order-123',
},
statusDescription: 'Your purchase was successful!',
paymentDetails: [
{ fiatCurrency: 'USD', paymentMethod: 'card', fields: [] },
],
id: 'order-123',
},
});
});

it('passes through an already-CAIP-formatted network chainId unchanged', () => {
const item = mapRampsOrder({
...baseOrder,
network: { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' },
});

expect(item?.chainId).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp');
});

it('maps a sell order to a rampSell activity item with an outbound token direction', () => {
const item = mapRampsOrder({ ...baseOrder, orderType: 'sell' });

expect(item).toMatchObject({
type: 'rampSell',
data: { token: { direction: 'out' } },
});
});

it('maps an uppercase BUY orderType (the real V2 API shape) to a rampBuy activity item', () => {
const item = mapRampsOrder({ ...baseOrder, orderType: 'BUY' });

expect(item).toMatchObject({
type: 'rampBuy',
data: { token: { direction: 'in' } },
});
});

it('maps an uppercase SELL orderType (the real V2 API shape) to a rampSell activity item', () => {
const item = mapRampsOrder({ ...baseOrder, orderType: 'SELL' });

expect(item).toMatchObject({
type: 'rampSell',
data: { token: { direction: 'out' } },
});
});

it('maps an empty txHash to an undefined hash while keeping the provider order id', () => {
const item = mapRampsOrder({ ...baseOrder, txHash: '', status: 'PENDING' });

expect(item?.hash).toBeUndefined();
expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe('order-123');
expect(item?.status).toBe('pending');
});

it('maps a precreated stub order with an empty chain id to an undefined chainId, not eip155:0', () => {
const item = mapRampsOrder({
...baseOrder,
network: { chainId: '' },
cryptoCurrency: undefined,
});

expect(item?.chainId).toBeUndefined();
});

it('falls through an unparseable network name to cryptoCurrency.chainId', () => {
// Coinbase (and other generic providers) return network as a free-form
// name string while still attaching a CAIP cryptoCurrency.chainId.
const item = mapRampsOrder({
...baseOrder,
network: 'ethereum',
cryptoCurrency: {
assetId: 'eip155:1/slip44:60',
chainId: 'eip155:1',
symbol: 'ETH',
decimals: 18,
},
});

expect(item?.chainId).toBe('eip155:1');
});

it('falls through an unparseable network name to cryptoCurrency.assetId', () => {
const item = mapRampsOrder({
...baseOrder,
network: 'ethereum',
cryptoCurrency: { assetId: 'eip155:1/slip44:60', symbol: 'ETH' },
});

expect(item?.chainId).toBe('eip155:1');
});

it('returns an undefined chainId when cryptoCurrency.assetId has no valid chain segment', () => {
const item = mapRampsOrder({
...baseOrder,
network: 'ethereum',
cryptoCurrency: { assetId: 'not-an-asset-id', symbol: 'ETH' },
});

expect(item?.chainId).toBeUndefined();
});

it('returns an undefined chainId when network is an unparseable name and crypto currency has no chain', () => {
const item = mapRampsOrder({
...baseOrder,
network: 'ethereum',
cryptoCurrency: undefined,
});

expect(item?.chainId).toBeUndefined();
});

it.each(['0x', '0x0000'])(
'treats placeholder txHash %s as missing while keeping the order id',
(txHash) => {
const item = mapRampsOrder({ ...baseOrder, txHash });

expect(item?.hash).toBeUndefined();
expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe(
'order-123',
);
},
);

it.each([
['CREATED', 'pending'],
['PENDING', 'pending'],
['COMPLETED', 'success'],
['FAILED', 'failed'],
['CANCELLED', 'cancelled'],
] as const)(
'maps RampsOrderStatus %s to Status %s',
(rampsStatus, expectedStatus) => {
const item = mapRampsOrder({ ...baseOrder, status: rampsStatus });

expect(item?.status).toBe(expectedStatus);
},
);

it.each(['UNKNOWN', 'ID_EXPIRED', 'PRECREATED'] as const)(
'hides orders with RampsOrderStatus %s from the activity list',
(rampsStatus) => {
const item = mapRampsOrder({ ...baseOrder, status: rampsStatus });

expect(item).toBeNull();
},
);

it('hides orders excluded from purchases', () => {
const item = mapRampsOrder({ ...baseOrder, excludeFromPurchases: true });

expect(item).toBeNull();
});

it.each(['DEPOSIT', 'deposit'] as const)(
'maps an orderType of %s to a rampBuy activity item',
(orderType) => {
const item = mapRampsOrder({ ...baseOrder, orderType });

expect(item).toMatchObject({ type: 'rampBuy' });
},
);

it('prefers the canonical order id over providerOrderId when present', () => {
const item = mapRampsOrder({
...baseOrder,
id: 'transak/orders/canonical-id',
});

expect(item).toMatchObject({
data: { id: 'transak/orders/canonical-id' },
});
});

it('does not report a decimals field on the token amount, since cryptoAmount is already human-formatted', () => {
const item = mapRampsOrder(baseOrder);

expect(item).toMatchObject({
data: { token: { amount: '0.05', symbol: 'ETH' } },
});
expect(
item?.type === 'rampBuy' ? item.data.token : undefined,
).not.toHaveProperty('decimals');
});

it('degrades gracefully when optional fields are missing', () => {
const minimalOrder: RampsOrderLike = {
cryptoAmount: '0.05',
fiatAmount: 100,
providerOrderId: 'order-456',
providerOrderLink: '',
createdAt: 1716367781000,
totalFeesFiat: 0,
txHash: '',
walletAddress: '0xwallet',
status: 'CREATED',
network: { chainId: '1' },
orderType: 'buy',
};

expect(() => mapRampsOrder(minimalOrder)).not.toThrow();

const item = mapRampsOrder(minimalOrder);

expect(item).toMatchObject({
type: 'rampBuy',
data: {
fiat: { amount: '100', currency: undefined },
token: undefined,
provider: { id: undefined, name: undefined },
paymentDetails: undefined,
},
});
});
});
Loading