From d042e196980ae2d78d09a0fd29560bc675348649 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Fri, 24 Jul 2026 13:37:27 +0100 Subject: [PATCH 1/3] feat(client-utils): map ramps orders into the shared activity item shape Add `mapRampsOrder` and `rampBuy`/`rampSell` `ActivityKind`s so ramps buy/sell orders can appear in the generic activity list and open through the same details pipeline as Send/Swap/Bridge, instead of a bespoke order-details page. Co-Authored-By: Claude Sonnet 5 --- packages/client-utils/CHANGELOG.md | 5 + packages/client-utils/src/index.ts | 2 + .../src/mappers/ramps-order-mapper.test.ts | 135 ++++++++++++++++++ .../src/mappers/ramps-order-mapper.ts | 120 ++++++++++++++++ packages/client-utils/src/types.ts | 34 ++++- 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 packages/client-utils/src/mappers/ramps-order-mapper.test.ts create mode 100644 packages/client-utils/src/mappers/ramps-order-mapper.ts diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index d67a2516bba..a6b90c50e40 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` ([#0](https://github.com/MetaMask/core/pull/0)) + - 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 82c03f827c1..7bb52fca2a8 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 00000000000..de5820502f1 --- /dev/null +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -0,0 +1,135 @@ +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 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 00000000000..6fa6000b7e6 --- /dev/null +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -0,0 +1,120 @@ +import type { CaipChainId } from '@metamask/utils'; + +import { formatChainIdToCaip } from './helpers/caip.js'; +import type { + ActivityItem, + Fee, + FiatAmount, + RampOrderPaymentDetail, + Status, + TokenAmount, +} from '../types.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 { + const direction: TokenAmount['direction'] = + order.orderType === 'buy' ? '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: order.orderType === 'buy' ? '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 1008b4fe60b..6a039f9d297 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 From 5ae42f53d50343602ea34092ed46fab0efcf848f Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Fri, 24 Jul 2026 13:54:27 +0100 Subject: [PATCH 2/3] fix: address CI failures on ramps activity mapper PR - Fix changelog entry to link the actual PR (#9650) instead of a placeholder - Reformat ramps-order-mapper.ts import order per oxfmt (lint:misc:check) Co-Authored-By: Claude Sonnet 5 --- packages/client-utils/CHANGELOG.md | 2 +- packages/client-utils/src/mappers/ramps-order-mapper.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index a6b90c50e40..709e1f1d51e 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `mapRampsOrder` for mapping ramps buy/sell orders into the shared activity item shape, and add `rampBuy`/`rampSell` to `ActivityKind` and `ActivityItem` ([#0](https://github.com/MetaMask/core/pull/0)) +- 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] diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index 6fa6000b7e6..f4b8d5b1376 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -1,6 +1,5 @@ import type { CaipChainId } from '@metamask/utils'; -import { formatChainIdToCaip } from './helpers/caip.js'; import type { ActivityItem, Fee, @@ -9,6 +8,7 @@ import type { Status, TokenAmount, } from '../types.js'; +import { formatChainIdToCaip } from './helpers/caip.js'; type RampsOrderStatusLike = | 'UNKNOWN' From b2bfbe44bfa17a62b0e300aff28d2af0d02936bb Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Fri, 24 Jul 2026 15:50:50 +0100 Subject: [PATCH 3/3] fix: normalize ramps orderType casing in mapRampsOrder The V2 API returns orderType uppercased ('BUY'/'SELL'); only the client's own locally-created stub order (RampsController.addPrecreatedOrder) uses lowercase. The strict 'buy' comparison misclassified every real buy order as rampSell with an outbound token direction. Co-Authored-By: Claude Sonnet 5 --- .../src/mappers/ramps-order-mapper.test.ts | 18 ++++++++++++++++++ .../src/mappers/ramps-order-mapper.ts | 8 +++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts index de5820502f1..3b79a1b55df 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -77,6 +77,24 @@ describe('mapRampsOrder', () => { }); }); + 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' }); diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index f4b8d5b1376..cbc8d703b55 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -65,8 +65,10 @@ function mapStatus(status: RampsOrderStatusLike): Status { * @returns The normalized activity item. */ export function mapRampsOrder(order: RampsOrderLike): ActivityItem { - const direction: TokenAmount['direction'] = - order.orderType === 'buy' ? 'in' : 'out'; + // 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 ? { @@ -97,7 +99,7 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem { const chainId = formatChainIdToCaip(order.network.chainId) as CaipChainId; return { - type: order.orderType === 'buy' ? 'rampBuy' : 'rampSell', + type: isBuy ? 'rampBuy' : 'rampSell', chainId, status: mapStatus(order.status), timestamp: order.createdAt,