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
5 changes: 5 additions & 0 deletions packages/client-utils/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 `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))
- Add an optional `id` field to activity items for items (like pending ramp orders) that may not yet have an on-chain `hash`.

## [1.2.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';
153 changes: 153 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,153 @@
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',
id: 'order-123',
data: {
from: '0xwallet',
fiat: { amount: '100', currency: 'USD' },
token: {
amount: '0.05',
symbol: 'ETH',
assetId: 'eip155:1/slip44:60',
decimals: 18,
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: [] },
],
},
});
});

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.id).toBe('order-123');
expect(item.status).toBe('pending');
});

it.each([
['UNKNOWN', 'pending'],
['PRECREATED', 'pending'],
['CREATED', 'pending'],
['PENDING', 'pending'],
['COMPLETED', 'success'],
['FAILED', 'failed'],
['ID_EXPIRED', '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('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,
},
});
});
});
122 changes: 122 additions & 0 deletions packages/client-utils/src/mappers/ramps-order-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { CaipChainId } from '@metamask/utils';

import type {
ActivityItem,
Fee,
FiatAmount,
RampOrderPaymentDetail,
Status,
TokenAmount,
} from '../types.js';
import { formatChainIdToCaip } from './helpers/caip.js';

type RampsOrderStatusLike =
| 'UNKNOWN'
| 'PRECREATED'
| 'CREATED'
| 'PENDING'
| 'FAILED'
| 'COMPLETED'
| 'CANCELLED'
| 'ID_EXPIRED';

/**
* The subset of `RampsOrder` (from `@metamask/ramps-controller`) that this
* mapper depends on. Redeclared locally rather than imported to keep
* `client-utils` free of a dependency on `ramps-controller`.
*/
export type RampsOrderLike = {
provider?: { id?: string; name?: string };
cryptoAmount: string | number;
fiatAmount: number;
cryptoCurrency?: { assetId?: string; symbol: string; decimals?: number };
fiatCurrency?: { symbol: string };
providerOrderId: string;
providerOrderLink: string;
createdAt: number;
totalFeesFiat: number;
txHash: string;
walletAddress: string;
status: RampsOrderStatusLike;
network: { chainId: string };
statusDescription?: string;
orderType: string;
paymentDetails?: RampOrderPaymentDetail[];
};

function mapStatus(status: RampsOrderStatusLike): Status {
switch (status) {
case 'COMPLETED':
return 'success';
case 'FAILED':
case 'ID_EXPIRED':
return 'failed';
case 'CANCELLED':
return 'cancelled';
default:
return 'pending';
}
}

/**
* Maps a ramps order into the shared activity item shape.
*
* @param order - The ramps order to map.
* @returns The normalized activity item.
*/
export function mapRampsOrder(order: RampsOrderLike): ActivityItem {
// The V2 API returns `orderType` uppercased (e.g. `'BUY'`); normalize since
// some call sites (e.g. locally-created stub orders) use lowercase.
const isBuy = order.orderType.toUpperCase() === 'BUY';
const direction: TokenAmount['direction'] = isBuy ? 'in' : 'out';

const token: TokenAmount | undefined = order.cryptoCurrency
? {
amount: String(order.cryptoAmount),
symbol: order.cryptoCurrency.symbol,
assetId: order.cryptoCurrency.assetId,
decimals: order.cryptoCurrency.decimals,
direction,
}
: undefined;

const fiat: FiatAmount = {
amount: String(order.fiatAmount),
currency: order.fiatCurrency?.symbol,
};

const fees: Fee[] = [
{
type: 'total',
amount: String(order.totalFeesFiat),
symbol: order.fiatCurrency?.symbol,
},
];

// `network.chainId` may already be CAIP-2 (non-EVM orders) or a bare
// numeric/hex reference (today's only observed EVM format) — normalize
// without assuming a namespace.
const chainId = formatChainIdToCaip(order.network.chainId) as CaipChainId;

return {
type: isBuy ? 'rampBuy' : 'rampSell',
chainId,
status: mapStatus(order.status),
timestamp: order.createdAt,
hash: order.txHash || undefined,
id: order.providerOrderId,
data: {
from: order.walletAddress,
fiat,
token,
fees,
provider: {
id: order.provider?.id,
name: order.provider?.name,
orderLink: order.providerOrderLink,
},
statusDescription: order.statusDescription,
paymentDetails: order.paymentDetails,
},
};
}
34 changes: 33 additions & 1 deletion packages/client-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export type ActivityKind =
| 'stopMarketCloseShort'
| 'marketCloseShort'
| 'assetActivation'
| 'assetDeactivation';
| 'assetDeactivation'
| 'rampBuy'
| 'rampSell';

export type Status = 'pending' | 'success' | 'failed' | 'cancelled';

Expand Down Expand Up @@ -79,9 +81,23 @@ type ActivityData<Type extends ActivityKind, Data> = {
status: Status;
timestamp: number;
hash?: string;
// Stable identifier for items that may not have a hash yet (e.g. a ramp
// order pending fiat settlement, where `hash` is empty until it settles
// on-chain).
id?: string;
data: Data;
};

/**
* Bank transfer instruction fields attached to a ramp order by providers
* that require manual payment (e.g. SEPA, wire transfer).
*/
export type RampOrderPaymentDetail = {
fiatCurrency: string;
paymentMethod: string;
fields: { name: string; id: string; value: string }[];
};

export type ActivityItem =
| ActivityData<
'approveSpendingCap' | 'revokeSpendingCap' | 'increaseSpendingCap',
Expand Down Expand Up @@ -159,6 +175,22 @@ export type ActivityItem =
transactionCategory?: string;
transactionProtocol?: string;
}
>
| ActivityData<
'rampBuy' | 'rampSell',
{
from?: string;
fiat?: FiatAmount;
token?: TokenAmount;
fees?: Fee[];
provider?: {
id?: string;
name?: string;
orderLink?: string;
};
statusDescription?: string;
paymentDetails?: RampOrderPaymentDetail[];
}
>;

// Note: Update core-backend
Expand Down