diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index d67a2516bb..709e1f1d51 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -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 diff --git a/packages/client-utils/src/index.ts b/packages/client-utils/src/index.ts index 82c03f827c..7bb52fca2a 100644 --- a/packages/client-utils/src/index.ts +++ b/packages/client-utils/src/index.ts @@ -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'; diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts new file mode 100644 index 0000000000..3b79a1b55d --- /dev/null +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -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, + }, + }); + }); +}); diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts new file mode 100644 index 0000000000..cbc8d703b5 --- /dev/null +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -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, + }, + }; +} diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 1008b4fe60..6a039f9d29 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -48,7 +48,9 @@ export type ActivityKind = | 'stopMarketCloseShort' | 'marketCloseShort' | 'assetActivation' - | 'assetDeactivation'; + | 'assetDeactivation' + | 'rampBuy' + | 'rampSell'; export type Status = 'pending' | 'success' | 'failed' | 'cancelled'; @@ -79,9 +81,23 @@ type ActivityData = { 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', @@ -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