From d042e196980ae2d78d09a0fd29560bc675348649 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Fri, 24 Jul 2026 13:37:27 +0100 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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, From d5358537cdc99fd3a8f1f5fecc927445bc3c7c3b Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 28 Jul 2026 15:36:29 +0100 Subject: [PATCH 04/14] fix: move id from base activity item into ramp order data field --- packages/client-utils/CHANGELOG.md | 2 +- .../src/mappers/helpers/caip.test.ts | 4 ++++ .../client-utils/src/mappers/helpers/caip.ts | 4 ++++ .../src/mappers/ramps-order-mapper.test.ts | 12 ++++++++-- .../src/mappers/ramps-order-mapper.ts | 13 +++++------ packages/client-utils/src/types.ts | 22 +++++++++++++------ 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 39acea9a801..997bfa8442d 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -10,7 +10,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` ([#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`. ([#9650](https://github.com/MetaMask/core/pull/9650)) + - `rampBuy`/`rampSell` activity items carry an optional `data.id` for orders (like pending ramp orders) that may not yet have an on-chain `hash`, and an optional `chainId` for precreated orders with no network assigned yet. ### Changed diff --git a/packages/client-utils/src/mappers/helpers/caip.test.ts b/packages/client-utils/src/mappers/helpers/caip.test.ts index 67e7462787b..73b42223e9f 100644 --- a/packages/client-utils/src/mappers/helpers/caip.test.ts +++ b/packages/client-utils/src/mappers/helpers/caip.test.ts @@ -29,6 +29,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('getNativeAsset', () => { diff --git a/packages/client-utils/src/mappers/helpers/caip.ts b/packages/client-utils/src/mappers/helpers/caip.ts index d0d0edc9085..cfc0c1cbbfd 100644 --- a/packages/client-utils/src/mappers/helpers/caip.ts +++ b/packages/client-utils/src/mappers/helpers/caip.ts @@ -57,6 +57,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}`; } 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 3b79a1b55df..8ad84cf04a4 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -34,9 +34,9 @@ describe('mapRampsOrder', () => { status: 'success', timestamp: 1716367781000, hash: '0xabc', - id: 'order-123', data: { from: '0xwallet', + id: 'order-123', fiat: { amount: '100', currency: 'USD' }, token: { amount: '0.05', @@ -99,10 +99,18 @@ describe('mapRampsOrder', () => { const item = mapRampsOrder({ ...baseOrder, txHash: '', status: 'PENDING' }); expect(item.hash).toBeUndefined(); - expect(item.id).toBe('order-123'); + 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: '' } }); + + expect(item.chainId).toBeUndefined(); + }); + it.each([ ['UNKNOWN', 'pending'], ['PRECREATED', 'pending'], diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index cbc8d703b55..e26456b3798 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -1,5 +1,3 @@ -import type { CaipChainId } from '@metamask/utils'; - import type { ActivityItem, Fee, @@ -93,10 +91,11 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem { }, ]; - // `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; + // `network.chainId` may already be CAIP-2 (non-EVM orders), a bare + // numeric/hex reference (today's only observed EVM format), or empty (a + // precreated stub order awaiting provider assignment) — normalize without + // assuming a namespace. + const chainId = formatChainIdToCaip(order.network.chainId); return { type: isBuy ? 'rampBuy' : 'rampSell', @@ -104,7 +103,6 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem { status: mapStatus(order.status), timestamp: order.createdAt, hash: order.txHash || undefined, - id: order.providerOrderId, data: { from: order.walletAddress, fiat, @@ -117,6 +115,7 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem { }, statusDescription: order.statusDescription, paymentDetails: order.paymentDetails, + id: order.providerOrderId, }, }; } diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 6a039f9d297..675790db816 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -75,16 +75,16 @@ export type Fee = { assetId?: string; }; -type ActivityData = { +type ActivityData< + Type extends ActivityKind, + Data, + ChainId = CaipChainId, +> = { type: Type; - chainId: CaipChainId; + chainId: ChainId; 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; }; @@ -190,7 +190,15 @@ export type ActivityItem = }; statusDescription?: string; paymentDetails?: RampOrderPaymentDetail[]; - } + // Stable identifier for orders 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; + }, + // Precreated stub orders (see `RampsController.addPrecreatedOrder`) may + // not have an assigned network yet, so unlike every other activity + // kind, a ramp order's chain id isn't guaranteed. + CaipChainId | undefined >; // Note: Update core-backend From 854e26ea9eb6ed563959e84dba7aa372f2195f11 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 28 Jul 2026 15:41:01 +0100 Subject: [PATCH 05/14] chore: update changelog --- packages/client-utils/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 997bfa8442d..253141284e2 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -10,7 +10,6 @@ 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` ([#9650](https://github.com/MetaMask/core/pull/9650)) - - `rampBuy`/`rampSell` activity items carry an optional `data.id` for orders (like pending ramp orders) that may not yet have an on-chain `hash`, and an optional `chainId` for precreated orders with no network assigned yet. ### Changed From 642341ca16f39c563b0d833b66c7504813355bcc Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 28 Jul 2026 16:15:43 +0100 Subject: [PATCH 06/14] fix: hide UNKNOWN and ID_EXPIRED ramps orders from the activity list These statuses represent background checkout attempts (e.g. precreated orders that never matched a real order) that the user never knowingly initiated as a distinct order and shouldn't see in their history. Co-Authored-By: Claude Sonnet 5 --- packages/client-utils/CHANGELOG.md | 1 + .../src/mappers/ramps-order-mapper.test.ts | 23 ++++++++++++------- .../src/mappers/ramps-order-mapper.ts | 15 +++++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 253141284e2..88152632a03 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -10,6 +10,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` ([#9650](https://github.com/MetaMask/core/pull/9650)) + - `mapRampsOrder` returns `null` for orders with an `UNKNOWN` or `ID_EXPIRED` status, since these represent background checkout attempts that should not appear in the activity list ([#9650](https://github.com/MetaMask/core/pull/9650)) ### Changed 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 8ad84cf04a4..d328cdd8839 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -65,7 +65,7 @@ describe('mapRampsOrder', () => { network: { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' }, }); - expect(item.chainId).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + expect(item?.chainId).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); }); it('maps a sell order to a rampSell activity item with an outbound token direction', () => { @@ -98,34 +98,41 @@ describe('mapRampsOrder', () => { 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( + expect(item?.hash).toBeUndefined(); + expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe( 'order-123', ); - expect(item.status).toBe('pending'); + 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: '' } }); - expect(item.chainId).toBeUndefined(); + expect(item?.chainId).toBeUndefined(); }); 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); + expect(item?.status).toBe(expectedStatus); + }, + ); + + it.each(['UNKNOWN', 'ID_EXPIRED'] as const)( + 'hides orders with RampsOrderStatus %s from the activity list', + (rampsStatus) => { + const item = mapRampsOrder({ ...baseOrder, status: rampsStatus }); + + expect(item).toBeNull(); }, ); diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index e26456b3798..4f674c03daa 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -47,7 +47,6 @@ function mapStatus(status: RampsOrderStatusLike): Status { case 'COMPLETED': return 'success'; case 'FAILED': - case 'ID_EXPIRED': return 'failed'; case 'CANCELLED': return 'cancelled'; @@ -56,13 +55,23 @@ function mapStatus(status: RampsOrderStatusLike): Status { } } +// `UNKNOWN` and `ID_EXPIRED` represent background checkout attempts (e.g. +// precreated orders that never matched a real order) that the user never +// knowingly initiated as a distinct order and shouldn't see in their history. +const HIDDEN_STATUSES = new Set(['UNKNOWN', 'ID_EXPIRED']); + /** * Maps a ramps order into the shared activity item shape. * * @param order - The ramps order to map. - * @returns The normalized activity item. + * @returns The normalized activity item, or `null` if the order's status + * should not be surfaced in the activity list. */ -export function mapRampsOrder(order: RampsOrderLike): ActivityItem { +export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { + if (HIDDEN_STATUSES.has(order.status)) { + return null; + } + // 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'; From 307cd2843b37de8315b9a47b78bfca94c8693bd7 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Tue, 28 Jul 2026 16:20:43 +0100 Subject: [PATCH 07/14] chore: lint --- .../client-utils/src/mappers/ramps-order-mapper.test.ts | 4 +--- packages/client-utils/src/mappers/ramps-order-mapper.ts | 5 ++++- packages/client-utils/src/types.ts | 6 +----- 3 files changed, 6 insertions(+), 9 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 d328cdd8839..caf7cd247d9 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -99,9 +99,7 @@ describe('mapRampsOrder', () => { const item = mapRampsOrder({ ...baseOrder, txHash: '', status: 'PENDING' }); expect(item?.hash).toBeUndefined(); - expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe( - 'order-123', - ); + expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe('order-123'); expect(item?.status).toBe('pending'); }); diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index 4f674c03daa..1a756676ae0 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -58,7 +58,10 @@ function mapStatus(status: RampsOrderStatusLike): Status { // `UNKNOWN` and `ID_EXPIRED` represent background checkout attempts (e.g. // precreated orders that never matched a real order) that the user never // knowingly initiated as a distinct order and shouldn't see in their history. -const HIDDEN_STATUSES = new Set(['UNKNOWN', 'ID_EXPIRED']); +const HIDDEN_STATUSES = new Set([ + 'UNKNOWN', + 'ID_EXPIRED', +]); /** * Maps a ramps order into the shared activity item shape. diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 675790db816..6ee7852b57d 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -75,11 +75,7 @@ export type Fee = { assetId?: string; }; -type ActivityData< - Type extends ActivityKind, - Data, - ChainId = CaipChainId, -> = { +type ActivityData = { type: Type; chainId: ChainId; status: Status; From 228d9f6898ce996191c0f9391aaf5466b959ea7e Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Wed, 29 Jul 2026 11:44:21 +0100 Subject: [PATCH 08/14] chore: move id into top-level ramps activity item --- packages/client-utils/CHANGELOG.md | 1 - .../src/mappers/ramps-order-mapper.test.ts | 43 ++++++++++++++++--- .../src/mappers/ramps-order-mapper.ts | 26 +++++++---- packages/client-utils/src/types.ts | 15 ++++--- 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 88152632a03..253141284e2 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -10,7 +10,6 @@ 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` ([#9650](https://github.com/MetaMask/core/pull/9650)) - - `mapRampsOrder` returns `null` for orders with an `UNKNOWN` or `ID_EXPIRED` status, since these represent background checkout attempts that should not appear in the activity list ([#9650](https://github.com/MetaMask/core/pull/9650)) ### Changed 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 caf7cd247d9..c635ca5152f 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -34,15 +34,14 @@ describe('mapRampsOrder', () => { status: 'success', timestamp: 1716367781000, hash: '0xabc', + id: 'order-123', data: { from: '0xwallet', - id: 'order-123', 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' }], @@ -99,7 +98,7 @@ describe('mapRampsOrder', () => { const item = mapRampsOrder({ ...baseOrder, txHash: '', status: 'PENDING' }); expect(item?.hash).toBeUndefined(); - expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe('order-123'); + expect(item?.type === 'rampBuy' ? item.id : 'unset').toBe('order-123'); expect(item?.status).toBe('pending'); }); @@ -110,7 +109,6 @@ describe('mapRampsOrder', () => { }); it.each([ - ['PRECREATED', 'pending'], ['CREATED', 'pending'], ['PENDING', 'pending'], ['COMPLETED', 'success'], @@ -125,7 +123,7 @@ describe('mapRampsOrder', () => { }, ); - it.each(['UNKNOWN', 'ID_EXPIRED'] as const)( + it.each(['UNKNOWN', 'ID_EXPIRED', 'PRECREATED'] as const)( 'hides orders with RampsOrderStatus %s from the activity list', (rampsStatus) => { const item = mapRampsOrder({ ...baseOrder, status: rampsStatus }); @@ -134,6 +132,41 @@ describe('mapRampsOrder', () => { }, ); + 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({ 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', diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index 1a756676ae0..83519e1d4fe 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -24,6 +24,7 @@ type RampsOrderStatusLike = * `client-utils` free of a dependency on `ramps-controller`. */ export type RampsOrderLike = { + id?: string; provider?: { id?: string; name?: string }; cryptoAmount: string | number; fiatAmount: number; @@ -39,6 +40,7 @@ export type RampsOrderLike = { network: { chainId: string }; statusDescription?: string; orderType: string; + excludeFromPurchases?: boolean; paymentDetails?: RampOrderPaymentDetail[]; }; @@ -55,12 +57,14 @@ function mapStatus(status: RampsOrderStatusLike): Status { } } -// `UNKNOWN` and `ID_EXPIRED` represent background checkout attempts (e.g. -// precreated orders that never matched a real order) that the user never -// knowingly initiated as a distinct order and shouldn't see in their history. +// `UNKNOWN`, `ID_EXPIRED`, and `PRECREATED` represent background checkout +// attempts (e.g. precreated orders that never matched a real order, or whose +// id expired before the provider assigned one) that the user never knowingly +// initiated as a distinct order and shouldn't see in their history. const HIDDEN_STATUSES = new Set([ 'UNKNOWN', 'ID_EXPIRED', + 'PRECREATED', ]); /** @@ -71,21 +75,27 @@ const HIDDEN_STATUSES = new Set([ * should not be surfaced in the activity list. */ export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { - if (HIDDEN_STATUSES.has(order.status)) { + if (HIDDEN_STATUSES.has(order.status) || order.excludeFromPurchases) { return null; } // 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'; + // some call sites (e.g. locally-created stub orders) use lowercase. Transak + // deposits (`'DEPOSIT'`) are a buy variant, not a sell. + const normalizedOrderType = order.orderType.toUpperCase(); + const isBuy = + normalizedOrderType === 'BUY' || normalizedOrderType === 'DEPOSIT'; const direction: TokenAmount['direction'] = isBuy ? 'in' : 'out'; + // `cryptoAmount`/`fiatAmount` are already human-formatted by the API, unlike + // `TokenAmount.decimals` elsewhere in this package, which signals a raw + // on-chain amount that still needs scaling. Omit `decimals` so clients don't + // wrongly re-scale an already-human amount. const token: TokenAmount | undefined = order.cryptoCurrency ? { amount: String(order.cryptoAmount), symbol: order.cryptoCurrency.symbol, assetId: order.cryptoCurrency.assetId, - decimals: order.cryptoCurrency.decimals, direction, } : undefined; @@ -115,6 +125,7 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { status: mapStatus(order.status), timestamp: order.createdAt, hash: order.txHash || undefined, + id: order.id ?? order.providerOrderId, data: { from: order.walletAddress, fiat, @@ -127,7 +138,6 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { }, statusDescription: order.statusDescription, paymentDetails: order.paymentDetails, - id: order.providerOrderId, }, }; } diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 6ee7852b57d..5e09d7fcd73 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -172,7 +172,7 @@ export type ActivityItem = transactionProtocol?: string; } > - | ActivityData< + | (ActivityData< 'rampBuy' | 'rampSell', { from?: string; @@ -186,16 +186,19 @@ export type ActivityItem = }; statusDescription?: string; paymentDetails?: RampOrderPaymentDetail[]; - // Stable identifier for orders 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; }, // Precreated stub orders (see `RampsController.addPrecreatedOrder`) may // not have an assigned network yet, so unlike every other activity // kind, a ramp order's chain id isn't guaranteed. CaipChainId | undefined - >; + > & { + // Stable identifier for orders that may not have a hash yet (e.g. a + // ramp order pending fiat settlement, where `hash` is empty until it + // settles on-chain). Sits next to `hash` since, like `hash`, it's a + // cross-kind identity concept — scoped to this union arm only, since no + // other activity kind needs it today. + id?: string; + }); // Note: Update core-backend export type ValueTransfer = _ValueTransfer & { From d9a871a22e622187ea963697bd80ff8c89de1566 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Wed, 29 Jul 2026 14:56:38 +0100 Subject: [PATCH 09/14] fix: add resolveRampsOrderChainId to handle different provider's network reponses --- .../src/mappers/helpers/caip.test.ts | 20 +++++- .../client-utils/src/mappers/helpers/caip.ts | 19 ++++++ .../src/mappers/ramps-order-mapper.test.ts | 53 ++++++++++++++- .../src/mappers/ramps-order-mapper.ts | 64 ++++++++++++++++--- 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/packages/client-utils/src/mappers/helpers/caip.test.ts b/packages/client-utils/src/mappers/helpers/caip.test.ts index 58aabd1f192..e3ebd5e006b 100644 --- a/packages/client-utils/src/mappers/helpers/caip.test.ts +++ b/packages/client-utils/src/mappers/helpers/caip.test.ts @@ -1,4 +1,8 @@ -import { formatAddressToAssetId, formatChainIdToCaip } from './caip.js'; +import { + caipChainIdFromAssetId, + formatAddressToAssetId, + formatChainIdToCaip, +} from './caip.js'; describe('caip helpers', () => { describe('formatChainIdToCaip', () => { @@ -80,4 +84,18 @@ describe('caip helpers', () => { ).toBeUndefined(); }); }); + + describe('caipChainIdFromAssetId', () => { + it('extracts the chain id segment from a CAIP-19 asset id', () => { + expect(caipChainIdFromAssetId('eip155:1/slip44:60')).toBe('eip155:1'); + }); + + it('returns undefined for an undefined asset id', () => { + expect(caipChainIdFromAssetId(undefined)).toBeUndefined(); + }); + + it('returns undefined for an asset id with no chain segment', () => { + expect(caipChainIdFromAssetId('not-an-asset-id')).toBeUndefined(); + }); + }); }); diff --git a/packages/client-utils/src/mappers/helpers/caip.ts b/packages/client-utils/src/mappers/helpers/caip.ts index d64d39731c3..fe23dad1575 100644 --- a/packages/client-utils/src/mappers/helpers/caip.ts +++ b/packages/client-utils/src/mappers/helpers/caip.ts @@ -2,6 +2,7 @@ import { toChecksumHexAddress } from '@metamask/controller-utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; import { isCaipAssetType, + isCaipChainId, isStrictHexString, parseCaipChainId, toCaipAssetType, @@ -84,3 +85,21 @@ export function formatAddressToAssetId( return toCaipAssetType(namespace, reference, 'erc20', checksummedAddress); } + +/** + * Extracts the CAIP-2 chain id from a CAIP-19 asset id + * (`eip155:1/slip44:60` → `eip155:1`). + * + * @param assetId - CAIP-19 asset id. + * @returns The CAIP-2 chain id, or `undefined` when it can't be extracted. + */ +export function caipChainIdFromAssetId( + assetId: string | undefined, +): CaipChainId | undefined { + if (!assetId) { + return undefined; + } + const slash = assetId.indexOf('/'); + const chainPart = slash === -1 ? assetId : assetId.slice(0, slash); + return isCaipChainId(chainPart) ? chainPart : undefined; +} 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 c635ca5152f..d68796ae58d 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -103,11 +103,62 @@ describe('mapRampsOrder', () => { }); it('maps a precreated stub order with an empty chain id to an undefined chainId, not eip155:0', () => { - const item = mapRampsOrder({ ...baseOrder, network: { chainId: '' } }); + 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 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.id : 'unset').toBe('order-123'); + }, + ); + it.each([ ['CREATED', 'pending'], ['PENDING', 'pending'], diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index 83519e1d4fe..44c72de733f 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -6,7 +6,7 @@ import type { Status, TokenAmount, } from '../types.js'; -import { formatChainIdToCaip } from './helpers/caip.js'; +import { caipChainIdFromAssetId, formatChainIdToCaip } from './helpers/caip.js'; type RampsOrderStatusLike = | 'UNKNOWN' @@ -28,7 +28,12 @@ export type RampsOrderLike = { provider?: { id?: string; name?: string }; cryptoAmount: string | number; fiatAmount: number; - cryptoCurrency?: { assetId?: string; symbol: string; decimals?: number }; + cryptoCurrency?: { + assetId?: string; + chainId?: string; + symbol: string; + decimals?: number; + }; fiatCurrency?: { symbol: string }; providerOrderId: string; providerOrderLink: string; @@ -37,7 +42,9 @@ export type RampsOrderLike = { txHash: string; walletAddress: string; status: RampsOrderStatusLike; - network: { chainId: string }; + // Declared as an object, but generic providers (e.g. Coinbase) actually send + // a free-form network name string at runtime — see `resolveRampsOrderChainId`. + network: { chainId: string } | string; statusDescription?: string; orderType: string; excludeFromPurchases?: boolean; @@ -67,6 +74,49 @@ const HIDDEN_STATUSES = new Set([ 'PRECREATED', ]); +/** + * Resolves the CAIP-2 chain id for a ramps order. + * + * Tries each source in order and falls through when a value is present but + * unparseable (e.g. Coinbase's network name string `"ethereum"`). Generic + * providers often return a free-form network name while still attaching a + * real CAIP `cryptoCurrency.chainId` / `assetId`. + * + * Precedence: `network.chainId` (object) → `network` (string) → + * `cryptoCurrency.chainId` → chain segment of `cryptoCurrency.assetId`. + * + * @param order - The ramps order to resolve a chain id for. + * @returns The CAIP-2 chain id, or `undefined` when it can't be resolved. + */ +function resolveRampsOrderChainId( + order: RampsOrderLike, +): ReturnType { + const { network } = order; + const networkChainId = + typeof network === 'string' ? network : network.chainId; + + return ( + formatChainIdToCaip(networkChainId) ?? + (order.cryptoCurrency?.chainId + ? formatChainIdToCaip(order.cryptoCurrency.chainId) + : undefined) ?? + caipChainIdFromAssetId(order.cryptoCurrency?.assetId) + ); +} + +/** + * Returns true when `txHash` looks like a real on-chain hash. Provider + * placeholders such as `""`, `"0x"`, and all-zero hashes must not be used as + * Activity row keys — they collide across orders whose hash isn't set yet. + * + * @param txHash - The order's raw `txHash` value. + * @returns `true` when the hash looks like a real on-chain hash. + */ +function isPlausibleRampTxHash(txHash: string): boolean { + const normalized = txHash.trim().toLowerCase(); + return normalized !== '' && !/^0x0*$/u.test(normalized); +} + /** * Maps a ramps order into the shared activity item shape. * @@ -113,18 +163,14 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { }, ]; - // `network.chainId` may already be CAIP-2 (non-EVM orders), a bare - // numeric/hex reference (today's only observed EVM format), or empty (a - // precreated stub order awaiting provider assignment) — normalize without - // assuming a namespace. - const chainId = formatChainIdToCaip(order.network.chainId); + const chainId = resolveRampsOrderChainId(order); return { type: isBuy ? 'rampBuy' : 'rampSell', chainId, status: mapStatus(order.status), timestamp: order.createdAt, - hash: order.txHash || undefined, + hash: isPlausibleRampTxHash(order.txHash) ? order.txHash : undefined, id: order.id ?? order.providerOrderId, data: { from: order.walletAddress, From 6eb79db79d31d2bf906ad2627c0a233dbccc3d22 Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Wed, 29 Jul 2026 15:08:06 +0100 Subject: [PATCH 10/14] fix: revert generic chainId change --- packages/client-utils/src/types.ts | 41 ++++++++++++++++-------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 3661552024e..4df9e7d1c8a 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -79,9 +79,9 @@ export type Fee = { assetType?: AssetType; }; -type ActivityData = { +type ActivityData = { type: Type; - chainId: ChainId; + chainId: CaipChainId; status: Status; timestamp: number; hash?: string; @@ -176,26 +176,29 @@ export type ActivityItem = transactionProtocol?: string; } > - | (ActivityData< - 'rampBuy' | 'rampSell', - { - from?: string; - fiat?: FiatAmount; - token?: TokenAmount; - fees?: Fee[]; - provider?: { - id?: string; - name?: string; - orderLink?: string; - }; - statusDescription?: string; - paymentDetails?: RampOrderPaymentDetail[]; - }, + | (Omit< + ActivityData< + 'rampBuy' | 'rampSell', + { + from?: string; + fiat?: FiatAmount; + token?: TokenAmount; + fees?: Fee[]; + provider?: { + id?: string; + name?: string; + orderLink?: string; + }; + statusDescription?: string; + paymentDetails?: RampOrderPaymentDetail[]; + } + >, + 'chainId' + > & { // Precreated stub orders (see `RampsController.addPrecreatedOrder`) may // not have an assigned network yet, so unlike every other activity // kind, a ramp order's chain id isn't guaranteed. - CaipChainId | undefined - > & { + chainId?: CaipChainId; // Stable identifier for orders that may not have a hash yet (e.g. a // ramp order pending fiat settlement, where `hash` is empty until it // settles on-chain). Sits next to `hash` since, like `hash`, it's a From 4928a522b17f47a5a89f758aae2f1bed6d94f57c Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Wed, 29 Jul 2026 15:15:23 +0100 Subject: [PATCH 11/14] chore: relocate ramps-specific code --- packages/client-utils/src/index.ts | 5 ++- .../src/mappers/helpers/caip.test.ts | 20 +-------- .../client-utils/src/mappers/helpers/caip.ts | 19 --------- .../src/mappers/ramps-order-mapper.test.ts | 10 +++++ .../src/mappers/ramps-order-mapper.ts | 42 +++++++++++++++---- packages/client-utils/src/types.ts | 12 +----- 6 files changed, 50 insertions(+), 58 deletions(-) diff --git a/packages/client-utils/src/index.ts b/packages/client-utils/src/index.ts index 7bb52fca2a8..a8b320d2b43 100644 --- a/packages/client-utils/src/index.ts +++ b/packages/client-utils/src/index.ts @@ -5,6 +5,9 @@ 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 { + RampOrderPaymentDetail, + RampsOrderLike, +} from './mappers/ramps-order-mapper.js'; export type * from './types.js'; diff --git a/packages/client-utils/src/mappers/helpers/caip.test.ts b/packages/client-utils/src/mappers/helpers/caip.test.ts index e3ebd5e006b..58aabd1f192 100644 --- a/packages/client-utils/src/mappers/helpers/caip.test.ts +++ b/packages/client-utils/src/mappers/helpers/caip.test.ts @@ -1,8 +1,4 @@ -import { - caipChainIdFromAssetId, - formatAddressToAssetId, - formatChainIdToCaip, -} from './caip.js'; +import { formatAddressToAssetId, formatChainIdToCaip } from './caip.js'; describe('caip helpers', () => { describe('formatChainIdToCaip', () => { @@ -84,18 +80,4 @@ describe('caip helpers', () => { ).toBeUndefined(); }); }); - - describe('caipChainIdFromAssetId', () => { - it('extracts the chain id segment from a CAIP-19 asset id', () => { - expect(caipChainIdFromAssetId('eip155:1/slip44:60')).toBe('eip155:1'); - }); - - it('returns undefined for an undefined asset id', () => { - expect(caipChainIdFromAssetId(undefined)).toBeUndefined(); - }); - - it('returns undefined for an asset id with no chain segment', () => { - expect(caipChainIdFromAssetId('not-an-asset-id')).toBeUndefined(); - }); - }); }); diff --git a/packages/client-utils/src/mappers/helpers/caip.ts b/packages/client-utils/src/mappers/helpers/caip.ts index fe23dad1575..d64d39731c3 100644 --- a/packages/client-utils/src/mappers/helpers/caip.ts +++ b/packages/client-utils/src/mappers/helpers/caip.ts @@ -2,7 +2,6 @@ import { toChecksumHexAddress } from '@metamask/controller-utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; import { isCaipAssetType, - isCaipChainId, isStrictHexString, parseCaipChainId, toCaipAssetType, @@ -85,21 +84,3 @@ export function formatAddressToAssetId( return toCaipAssetType(namespace, reference, 'erc20', checksummedAddress); } - -/** - * Extracts the CAIP-2 chain id from a CAIP-19 asset id - * (`eip155:1/slip44:60` → `eip155:1`). - * - * @param assetId - CAIP-19 asset id. - * @returns The CAIP-2 chain id, or `undefined` when it can't be extracted. - */ -export function caipChainIdFromAssetId( - assetId: string | undefined, -): CaipChainId | undefined { - if (!assetId) { - return undefined; - } - const slash = assetId.indexOf('/'); - const chainPart = slash === -1 ? assetId : assetId.slice(0, slash); - return isCaipChainId(chainPart) ? chainPart : undefined; -} 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 d68796ae58d..7d617db19b2 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -139,6 +139,16 @@ describe('mapRampsOrder', () => { 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, diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index 44c72de733f..7e8050fe18e 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -1,12 +1,36 @@ -import type { - ActivityItem, - Fee, - FiatAmount, - RampOrderPaymentDetail, - Status, - TokenAmount, -} from '../types.js'; -import { caipChainIdFromAssetId, formatChainIdToCaip } from './helpers/caip.js'; +import type { CaipChainId } from '@metamask/utils'; +import { isCaipChainId } from '@metamask/utils'; + +import type { ActivityItem, Fee, FiatAmount, Status, TokenAmount } from '../types.js'; +import { formatChainIdToCaip } from './helpers/caip.js'; + +/** + * 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 }[]; +}; + +/** + * Extracts the CAIP-2 chain id from a CAIP-19 asset id + * (`eip155:1/slip44:60` → `eip155:1`). + * + * @param assetId - CAIP-19 asset id. + * @returns The CAIP-2 chain id, or `undefined` when it can't be extracted. + */ +function caipChainIdFromAssetId( + assetId: string | undefined, +): CaipChainId | undefined { + if (!assetId) { + return undefined; + } + const slash = assetId.indexOf('/'); + const chainPart = slash === -1 ? assetId : assetId.slice(0, slash); + return isCaipChainId(chainPart) ? chainPart : undefined; +} type RampsOrderStatusLike = | 'UNKNOWN' diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 4df9e7d1c8a..0e6a90f2a22 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -1,6 +1,8 @@ import type { ValueTransfer as _ValueTransfer } from '@metamask/core-backend'; import type { CaipChainId } from '@metamask/utils'; +import type { RampOrderPaymentDetail } from './mappers/ramps-order-mapper.js'; + export type ActivityKind = | 'receive' | 'sell' @@ -88,16 +90,6 @@ type ActivityData = { 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', From d01c08674f51cff81db123f14783bfa218fba39f Mon Sep 17 00:00:00 2001 From: Alex Mendonca Date: Wed, 29 Jul 2026 15:25:46 +0100 Subject: [PATCH 12/14] fix: circular import --- packages/client-utils/src/index.ts | 5 +---- .../src/mappers/ramps-order-mapper.ts | 19 ++++++++----------- packages/client-utils/src/types.ts | 12 ++++++++++-- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/client-utils/src/index.ts b/packages/client-utils/src/index.ts index a8b320d2b43..7bb52fca2a8 100644 --- a/packages/client-utils/src/index.ts +++ b/packages/client-utils/src/index.ts @@ -5,9 +5,6 @@ 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 { - RampOrderPaymentDetail, - RampsOrderLike, -} 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.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index 7e8050fe18e..1485861ca9d 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -1,19 +1,16 @@ import type { CaipChainId } from '@metamask/utils'; import { isCaipChainId } from '@metamask/utils'; -import type { ActivityItem, Fee, FiatAmount, Status, TokenAmount } from '../types.js'; +import type { + ActivityItem, + Fee, + FiatAmount, + RampOrderPaymentDetail, + Status, + TokenAmount, +} from '../types.js'; import { formatChainIdToCaip } from './helpers/caip.js'; -/** - * 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 }[]; -}; - /** * Extracts the CAIP-2 chain id from a CAIP-19 asset id * (`eip155:1/slip44:60` → `eip155:1`). diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 0e6a90f2a22..4df9e7d1c8a 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -1,8 +1,6 @@ import type { ValueTransfer as _ValueTransfer } from '@metamask/core-backend'; import type { CaipChainId } from '@metamask/utils'; -import type { RampOrderPaymentDetail } from './mappers/ramps-order-mapper.js'; - export type ActivityKind = | 'receive' | 'sell' @@ -90,6 +88,16 @@ type ActivityData = { 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', From 635dd2e5f5be3fd2770ea97a2987f5e09e519f5a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 29 Jul 2026 10:00:13 -0600 Subject: [PATCH 13/14] fix: check empty string explicitly in formatChainIdToCaip Co-authored-by: Cursor --- packages/client-utils/src/mappers/helpers/caip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client-utils/src/mappers/helpers/caip.ts b/packages/client-utils/src/mappers/helpers/caip.ts index d64d39731c3..4369eabc638 100644 --- a/packages/client-utils/src/mappers/helpers/caip.ts +++ b/packages/client-utils/src/mappers/helpers/caip.ts @@ -32,7 +32,7 @@ export function formatChainIdToCaip( return Number.isNaN(reference) ? undefined : `eip155:${reference}`; } - if (!chainId) { + if (chainId === '') { return undefined; } From 68ca1fb163f9c60ecaeaa17c9ab99fc0586975d4 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 29 Jul 2026 10:21:23 -0600 Subject: [PATCH 14/14] fix: move ramp activity id into data for type-specific properties Francis requested that ramp order id live in data rather than as a top-level ActivityItem field, so type-specific properties stay scoped to rampBuy/rampSell and don't add noise to other activity kinds. Co-authored-by: Cursor --- .../src/mappers/ramps-order-mapper.test.ts | 12 ++++++++---- .../client-utils/src/mappers/ramps-order-mapper.ts | 2 +- packages/client-utils/src/types.ts | 10 ++++------ 3 files changed, 13 insertions(+), 11 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 7d617db19b2..4adf2729244 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.test.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.test.ts @@ -34,7 +34,6 @@ describe('mapRampsOrder', () => { status: 'success', timestamp: 1716367781000, hash: '0xabc', - id: 'order-123', data: { from: '0xwallet', fiat: { amount: '100', currency: 'USD' }, @@ -54,6 +53,7 @@ describe('mapRampsOrder', () => { paymentDetails: [ { fiatCurrency: 'USD', paymentMethod: 'card', fields: [] }, ], + id: 'order-123', }, }); }); @@ -98,7 +98,7 @@ describe('mapRampsOrder', () => { const item = mapRampsOrder({ ...baseOrder, txHash: '', status: 'PENDING' }); expect(item?.hash).toBeUndefined(); - expect(item?.type === 'rampBuy' ? item.id : 'unset').toBe('order-123'); + expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe('order-123'); expect(item?.status).toBe('pending'); }); @@ -165,7 +165,9 @@ describe('mapRampsOrder', () => { const item = mapRampsOrder({ ...baseOrder, txHash }); expect(item?.hash).toBeUndefined(); - expect(item?.type === 'rampBuy' ? item.id : 'unset').toBe('order-123'); + expect(item?.type === 'rampBuy' ? item.data.id : 'unset').toBe( + 'order-123', + ); }, ); @@ -214,7 +216,9 @@ describe('mapRampsOrder', () => { id: 'transak/orders/canonical-id', }); - expect(item).toMatchObject({ 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', () => { diff --git a/packages/client-utils/src/mappers/ramps-order-mapper.ts b/packages/client-utils/src/mappers/ramps-order-mapper.ts index 1485861ca9d..13709953eff 100644 --- a/packages/client-utils/src/mappers/ramps-order-mapper.ts +++ b/packages/client-utils/src/mappers/ramps-order-mapper.ts @@ -192,7 +192,6 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { status: mapStatus(order.status), timestamp: order.createdAt, hash: isPlausibleRampTxHash(order.txHash) ? order.txHash : undefined, - id: order.id ?? order.providerOrderId, data: { from: order.walletAddress, fiat, @@ -205,6 +204,7 @@ export function mapRampsOrder(order: RampsOrderLike): ActivityItem | null { }, statusDescription: order.statusDescription, paymentDetails: order.paymentDetails, + id: order.id ?? order.providerOrderId, }, }; } diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index 4df9e7d1c8a..0aed7e89565 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -191,6 +191,10 @@ export type ActivityItem = }; statusDescription?: string; paymentDetails?: RampOrderPaymentDetail[]; + // Stable identifier for orders that may not have a hash yet (e.g. a + // ramp order pending fiat settlement, where `hash` is empty until it + // settles on-chain). Lives in `data` as a ramp-specific property. + id?: string; } >, 'chainId' @@ -199,12 +203,6 @@ export type ActivityItem = // not have an assigned network yet, so unlike every other activity // kind, a ramp order's chain id isn't guaranteed. chainId?: CaipChainId; - // Stable identifier for orders that may not have a hash yet (e.g. a - // ramp order pending fiat settlement, where `hash` is empty until it - // settles on-chain). Sits next to `hash` since, like `hash`, it's a - // cross-kind identity concept — scoped to this union arm only, since no - // other activity kind needs it today. - id?: string; }); // Note: Update core-backend