diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 47795e4b6575..b01d65cbdfb1 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -19,7 +19,10 @@ markdown and must NOT be removed or edited without updating the validator regist type=manual-testing Section must have real testing steps or an explicit N/A. type=screenshot Section must have evidence (image/URL) or an explicit N/A. type=checklist Section must have all checkboxes consciously checked. - required=true|false Whether a missing/invalid section blocks the PR check. + required=true|false Whether a missing/invalid section runs the validator at all. + blocking=true|false Whether a failure of this check fails the CI workflow. + Default: false — failures are shown as warnings in the sticky + comment but do not block the PR. Sections without a directive are checked for structural presence only. --> @@ -36,7 +39,7 @@ Write a short description of the changes included in this pull request, also inc ## **Changelog** - + '; + const plan = buildValidationPlan(tokenize(md)); + expect(plan[0].blocking).toBe(true); + }); + + it('reads blocking=false from the directive', () => { + const md = '## **Section**\n'; + const plan = buildValidationPlan(tokenize(md)); + expect(plan[0].blocking).toBe(false); + }); + + it('defaults blocking to false when the key is absent', () => { + const md = '## **Section**\n'; + const plan = buildValidationPlan(tokenize(md)); + expect(plan[0].blocking).toBe(false); + }); }); // ─── validatePlanTypes ──────────────────────────────────────────────────────── @@ -622,12 +640,12 @@ describe('validatePlanTypes', () => { const registry = { text: () => ({ ok: true as const }) }; it('does not throw when all types are present in the registry', () => { - const plan = [{ title: '## Foo', type: 'text', required: true }]; + const plan = [{ title: '## Foo', type: 'text', required: true, blocking: false }]; expect(() => validatePlanTypes(plan, registry)).not.toThrow(); }); it('throws for an unknown type', () => { - const plan = [{ title: '## Bar', type: 'unknown-type', required: true }]; + const plan = [{ title: '## Bar', type: 'unknown-type', required: true, blocking: false }]; expect(() => validatePlanTypes(plan, registry)).toThrow( /Unknown mms-check type "unknown-type" in section "## Bar"/, ); @@ -635,9 +653,157 @@ describe('validatePlanTypes', () => { it('throws for the first unknown type in a mixed plan', () => { const plan = [ - { title: '## Good', type: 'text', required: true }, - { title: '## Bad', type: 'typo-type', required: true }, + { title: '## Good', type: 'text', required: true, blocking: false }, + { title: '## Bad', type: 'typo-type', required: true, blocking: false }, ]; expect(() => validatePlanTypes(plan, registry)).toThrow(/typo-type/); }); }); + +// ─── parseDirective — blocking key ──────────────────────────────────────────── + +describe('parseDirective — blocking key', () => { + it('parses blocking=true', () => { + expect(parseDirective('mms-check: type=changelog required=true blocking=true')).toEqual({ + type: 'changelog', + required: 'true', + blocking: 'true', + }); + }); + + it('parses blocking=false', () => { + expect(parseDirective('mms-check: type=text required=true blocking=false')).toEqual({ + type: 'text', + required: 'true', + blocking: 'false', + }); + }); + + it('does not include a blocking key when the field is absent', () => { + const result = parseDirective('mms-check: type=text required=true'); + expect(result).not.toHaveProperty('blocking'); + }); +}); + +// ─── runAllChecks — blocking flag stamping ──────────────────────────────────── + +describe('runAllChecks — blocking flag on failures', () => { + it('stamps blocking:true on the changelog failure when template has blocking=true', () => { + // The real pull-request-template.md tags changelog with blocking=true. + const body = ` +## **Description** + +Real description. + +## **Changelog** + +CHANGELOG entry: + +## **Related issues** + +Fixes: #1 + +## **Manual testing steps** + +\`\`\`gherkin +Feature: ready for review + + Scenario: user opens the PR + Given the author fills the PR + When user submits + Then the validator passes +\`\`\` + +## **Screenshots/Recordings** + +### **Before** + +N/A + +### **After** + +N/A + +## **Pre-merge author checklist** + +- [x] Followed contributor docs +- [x] Completed PR template +- [x] Included tests if applicable +- [x] Documented code with JSDoc if applicable +- [x] Applied right labels + +#### Performance checks (if applicable) + +- [x] Tested on Android +- [x] Tested with power user scenario +- [x] Instrumented with Sentry traces if applicable + +## **Pre-merge reviewer checklist** + +- [ ] Reviewer item +`.trim(); + + const failures = runAllChecks(body, false); + const changelogFailure = failures.find((f) => f.reason.includes('Changelog')); + expect(changelogFailure).toBeDefined(); + expect(changelogFailure?.blocking).toBe(true); + }); + + it('stamps blocking:false on non-blocking failures', () => { + const body = ` +## **Description** + +## **Changelog** + +CHANGELOG entry: Real entry. + +## **Related issues** + +Fixes: #1 + +## **Manual testing steps** + +\`\`\`gherkin +Feature: ready for review + + Scenario: user opens the PR + Given the author fills the PR + When user submits + Then the validator passes +\`\`\` + +## **Screenshots/Recordings** + +### **Before** + +N/A + +### **After** + +N/A + +## **Pre-merge author checklist** + +- [x] Followed contributor docs +- [x] Completed PR template +- [x] Included tests if applicable +- [x] Documented code with JSDoc if applicable +- [x] Applied right labels + +#### Performance checks (if applicable) + +- [x] Tested on Android +- [x] Tested with power user scenario +- [x] Instrumented with Sentry traces if applicable + +## **Pre-merge reviewer checklist** + +- [ ] Reviewer item +`.trim(); + + const failures = runAllChecks(body, false); + const descriptionFailure = failures.find((f) => f.reason.includes('Description')); + expect(descriptionFailure).toBeDefined(); + expect(descriptionFailure?.blocking).toBe(false); + }); +}); diff --git a/.github/scripts/shared/pr-template-checks.ts b/.github/scripts/shared/pr-template-checks.ts index 733f49e8a94b..d253a9f459ca 100644 --- a/.github/scripts/shared/pr-template-checks.ts +++ b/.github/scripts/shared/pr-template-checks.ts @@ -10,12 +10,19 @@ import * as fs from 'fs'; import * as path from 'path'; import { tokenize, sectionTokens, Token } from './markdown-tokenizer'; -// ─── Public result type ────────────────────────────────────────────────────── +// ─── Result types ───────────────────────────────────────────────────────────── -export type PrTemplateCheckResult = +// Returned by individual validators — no knowledge of the blocking flag, which +// is a plan-level concern stamped by runAllChecks. +type ValidatorResult = | { ok: true } | { ok: false; reason: string }; +// Returned by runAllChecks — adds the blocking flag from the plan entry. +export type PrTemplateCheckResult = + | { ok: true } + | { ok: false; reason: string; blocking: boolean }; + // ─── Validation-plan types ─────────────────────────────────────────────────── interface ValidationPlanEntry { @@ -24,10 +31,12 @@ interface ValidationPlanEntry { // Validator type key, must exist in VALIDATORS or module load throws. type: string; required: boolean; + // Whether a failure of this check fails the workflow. Default: false. + blocking: boolean; } type ValidatorContext = { hasNoChangelogLabel: boolean }; -type Validator = (section: string, ctx: ValidatorContext) => PrTemplateCheckResult; +type Validator = (section: string, ctx: ValidatorContext) => ValidatorResult; // ─── Template loading (single read at module load) ─────────────────────────── @@ -87,13 +96,15 @@ export function buildValidationPlan(tokens: Token[]): ValidationPlanEntry[] { !directiveFoundForCurrent ) { const directive = parseDirective(token.content); - if (directive?.type) { - plan.push({ - title: currentHeading, - type: directive.type, - // Any value other than the explicit string "false" means required. - required: directive.required !== 'false', - }); + if (directive?.type) { + plan.push({ + title: currentHeading, + type: directive.type, + // Any value other than the explicit string "false" means required. + required: directive.required !== 'false', + // Only the explicit string "true" makes a check blocking; default is false. + blocking: directive.blocking === 'true', + }); directiveFoundForCurrent = true; } } @@ -280,7 +291,7 @@ export function extractSection(body: string, title: string): string | null { export function hasNonEmptyDescription( descriptionSection: string, -): PrTemplateCheckResult { +): ValidatorResult { if (stripHtmlComments(descriptionSection).trim().length === 0) { return { ok: false, @@ -292,7 +303,7 @@ export function hasNonEmptyDescription( export function hasValidIssueLink( relatedIssuesSection: string, -): PrTemplateCheckResult { +): ValidatorResult { // Search only in plain text tokens — a Fixes/Closes/Refs line inside a // fenced code block (e.g. a doc example) must not count as the real link. const textOutsideFences = tokenize(relatedIssuesSection) @@ -317,7 +328,7 @@ export function hasValidIssueLink( export function hasRealManualTesting( manualTestingSection: string, -): PrTemplateCheckResult { +): ValidatorResult { const sanitized = stripHtmlComments(manualTestingSection).trim(); if (sanitized.length === 0) { return { @@ -338,7 +349,7 @@ export function hasRealManualTesting( export function hasScreenshotsOrNa( screenshotsSection: string, -): PrTemplateCheckResult { +): ValidatorResult { // Subheadings exist in every PR (they ship with the template); they don't // count as content. We trust the author for whether the content is // meaningful — CI only enforces "section was filled". @@ -357,7 +368,7 @@ export function hasScreenshotsOrNa( export function hasAllAuthorChecklistChecked( checklistSection: string, -): PrTemplateCheckResult { +): ValidatorResult { // Use only checkbox tokens — checkboxes that appear inside fenced code // blocks are examples or docs, not real checklist items. const checkboxes = tokenize(checklistSection).filter( @@ -400,7 +411,7 @@ export function hasAllAuthorChecklistChecked( export function hasChangelogEntry( changelogSection: string, hasNoChangelogLabel: boolean, -): PrTemplateCheckResult { +): ValidatorResult { if (hasNoChangelogLabel) { return { ok: true }; } @@ -442,14 +453,14 @@ export function hasChangelogEntry( export function runAllChecks( body: string, hasNoChangelogLabel: boolean, -): { ok: false; reason: string }[] { +): { ok: false; reason: string; blocking: boolean }[] { const ctx = { hasNoChangelogLabel }; - const failures: { ok: false; reason: string }[] = []; + const failures: { ok: false; reason: string; blocking: boolean }[] = []; for (const entry of VALIDATION_PLAN) { if (!entry.required) continue; const section = extractSection(body, entry.title) ?? ''; const result = VALIDATORS[entry.type](section, ctx); - if (!result.ok) failures.push(result); + if (!result.ok) failures.push({ ...result, blocking: entry.blocking }); } return failures; } diff --git a/.github/scripts/shared/pr-template-comment.test.ts b/.github/scripts/shared/pr-template-comment.test.ts index 47ce0b40f411..1b7bdcc6c716 100644 --- a/.github/scripts/shared/pr-template-comment.test.ts +++ b/.github/scripts/shared/pr-template-comment.test.ts @@ -52,38 +52,67 @@ function makeOctokit( describe('renderFailureComment', () => { it('contains the static heading', () => { - const result = renderFailureComment(['Reason A'], false); + const result = renderFailureComment({ blocking: ['Reason A'], warning: [] }); expect(result).toContain('PR template — items to address before'); }); - it('lists each reason as a bullet', () => { - const result = renderFailureComment(['First reason', 'Second reason'], false); - expect(result).toContain('- First reason'); - expect(result).toContain('- Second reason'); - }); - it('contains the ready-for-review doc link', () => { - const result = renderFailureComment(['x'], false); + const result = renderFailureComment({ blocking: [], warning: ['x'] }); expect(result).toContain('ready-for-review.md'); }); - it('shows the blocking message when isDraft is false', () => { - const result = renderFailureComment(['x'], false); - expect(result).toContain('blocking'); - expect(result).not.toContain('informational'); + it('does not prepend the sticky marker (that is done by upsertStickyComment)', () => { + const result = renderFailureComment({ blocking: ['x'], warning: [] }); + expect(result).not.toContain(''); }); - it('shows the informational message when isDraft is true', () => { - const result = renderFailureComment(['x'], true); - expect(result).toContain('informational'); - // The draft note mentions "blocking" only in the future-tense clause - // ("will start blocking once…"), not as the primary description. - expect(result).not.toMatch(/^_This check is blocking\./m); + describe('blocking-only failures', () => { + it('shows the Blocking section with the reason', () => { + const result = renderFailureComment({ blocking: ['Missing changelog'], warning: [] }); + expect(result).toContain('**Blocking**'); + expect(result).toContain('- Missing changelog'); + }); + + it('does not show the Warnings section when there are no warnings', () => { + const result = renderFailureComment({ blocking: ['x'], warning: [] }); + expect(result).not.toContain('**Warnings**'); + }); }); - it('does not prepend the sticky marker (that is done by upsertStickyComment)', () => { - const result = renderFailureComment(['x'], false); - expect(result).not.toContain(''); + describe('warning-only failures', () => { + it('shows the Warnings section with the reason', () => { + const result = renderFailureComment({ blocking: [], warning: ['Missing description'] }); + expect(result).toContain('**Warnings**'); + expect(result).toContain('- Missing description'); + }); + + it('does not show the Blocking section when there are no blocking failures', () => { + const result = renderFailureComment({ blocking: [], warning: ['x'] }); + expect(result).not.toContain('**Blocking**'); + }); + }); + + describe('mixed failures', () => { + it('shows both Blocking and Warnings sections', () => { + const result = renderFailureComment({ + blocking: ['Missing changelog'], + warning: ['Missing description'], + }); + expect(result).toContain('**Blocking**'); + expect(result).toContain('- Missing changelog'); + expect(result).toContain('**Warnings**'); + expect(result).toContain('- Missing description'); + }); + + it('lists each reason as a bullet in the correct group', () => { + const result = renderFailureComment({ + blocking: ['B1', 'B2'], + warning: ['W1'], + }); + expect(result).toContain('- B1'); + expect(result).toContain('- B2'); + expect(result).toContain('- W1'); + }); }); }); diff --git a/.github/scripts/shared/pr-template-comment.ts b/.github/scripts/shared/pr-template-comment.ts index b3ce25c1ff3a..339cb9709e72 100644 --- a/.github/scripts/shared/pr-template-comment.ts +++ b/.github/scripts/shared/pr-template-comment.ts @@ -10,22 +10,39 @@ const READY_FOR_REVIEW_DOC_URL = 'https://github.com/MetaMask/metamask-mobile/blob/main/docs/readme/ready-for-review.md'; /** - * Build the markdown body of the sticky comment, given the aggregated failure - * reasons and the draft flag. The marker is prepended by `upsertStickyComment` - * so callers never need to think about it. + * Build the markdown body of the sticky comment from grouped failure reasons. + * Blocking failures fail the workflow; warning failures are informational. + * Groups with no entries are omitted so authors never see an empty section. + * The marker is prepended by `upsertStickyComment` so callers never need to + * think about it. */ -export function renderFailureComment( - reasons: string[], - isDraft: boolean, -): string { +export function renderFailureComment({ + blocking, + warning, +}: { + blocking: string[]; + warning: string[]; +}): string { const heading = '### PR template — items to address before "Ready for review"'; - const draftNote = isDraft - ? '_This check is informational while the PR is in draft. It will start blocking once you mark the PR as Ready for review._' - : '_This check is blocking. Address every item below, then push to re-run._'; - const bullets = reasons.map((r) => `- ${r}`).join('\n'); const footer = `See [docs/readme/ready-for-review.md](${READY_FOR_REVIEW_DOC_URL}) for the full Definition of Ready for Review.`; - return [heading, '', draftNote, '', bullets, '', footer].join('\n'); + + const sections: string[] = [heading, '']; + + if (blocking.length > 0) { + sections.push('**Blocking** — these items fail the workflow until fixed:'); + sections.push(blocking.map((r) => `- ${r}`).join('\n')); + sections.push(''); + } + + if (warning.length > 0) { + sections.push('**Warnings** — informational, address before merging:'); + sections.push(warning.map((r) => `- ${r}`).join('\n')); + sections.push(''); + } + + sections.push(footer); + return sections.join('\n'); } /** diff --git a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts index c758b2ec411c..0646282d4f87 100644 --- a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts +++ b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.test.ts @@ -1,11 +1,18 @@ import { renderHook } from '@testing-library/react-native'; import { StatusTypes as BridgeStatus } from '@metamask/bridge-controller'; +import { + SolScope, + TransactionStatus as KeyringTransactionStatus, +} from '@metamask/keyring-api'; import { TransactionStatus as TxStatus } from '@metamask/transaction-controller'; import { PostTradeStatus as Status } from './PostTradeBottomSheet.types'; import { usePostTradeTxStatus } from './usePostTradeTxStatus'; -let mockTransactionMeta: { status: TxStatus } | undefined; +const SOLANA_MAINNET_CHAIN_ID = 1151111081099710; + +let mockTransactionMeta: { status?: TxStatus; hash?: string } | undefined; let mockBridgeHistory = {}; +let mockNonEvmTransactions: unknown = {}; jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), @@ -16,37 +23,145 @@ jest.mock('../../../../../selectors/transactionController', () => ({ jest.mock('../../../../../selectors/bridgeStatusController', () => ({ selectBridgeHistoryForAccount: () => mockBridgeHistory, })); +jest.mock('../../../../../selectors/multichain/multichain', () => ({ + selectMultichainTransactions: () => mockNonEvmTransactions, +})); const statusOf = ( txStatus?: TxStatus, bridgeStatus?: BridgeStatus, - isBridge = false, + { + isBridge = false, + metaHash, + srcChainId, + destChainId, + statusSrcTxHash, + transactionHash, + nonEvmTransactions = {}, + }: { + isBridge?: boolean; + metaHash?: string; + srcChainId?: number; + destChainId?: number; + statusSrcTxHash?: string; + transactionHash?: string; + nonEvmTransactions?: unknown; + } = {}, ) => { - mockTransactionMeta = txStatus ? { status: txStatus } : undefined; - mockBridgeHistory = bridgeStatus - ? { 'tx-id': { status: { status: bridgeStatus } } } - : {}; + mockTransactionMeta = + txStatus || metaHash !== undefined + ? { status: txStatus, hash: metaHash } + : undefined; + mockBridgeHistory = + bridgeStatus !== undefined + ? { + 'tx-id': { + quote: { + srcChainId: srcChainId ?? 1, + destChainId: destChainId ?? 1, + }, + status: { + status: bridgeStatus, + srcChain: { txHash: statusSrcTxHash }, + }, + }, + } + : {}; + mockNonEvmTransactions = nonEvmTransactions; const params = { initialStatus: Status.InProgress, isBridge, transactionMetaId: 'tx-id', + transactionHash, }; return renderHook(() => usePostTradeTxStatus(params)).result.current; }; +const solanaTx = (status: KeyringTransactionStatus, id = 'sol-sig') => ({ + 'account-1': { + [SolScope.Mainnet]: { + transactions: [{ id, status }], + }, + }, +}); + describe('usePostTradeTxStatus', () => { it('maps transaction and bridge statuses', () => { expect(statusOf(TxStatus.confirmed)).toBe(Status.Success); expect(statusOf(TxStatus.confirmed, BridgeStatus.UNKNOWN)).toBe( Status.Success, ); - expect(statusOf(TxStatus.confirmed, undefined, true)).toBe( - Status.InProgress, - ); + expect( + statusOf(TxStatus.confirmed, BridgeStatus.UNKNOWN, { + isBridge: true, + srcChainId: 1, + destChainId: 10, + }), + ).toBe(Status.InProgress); expect(statusOf(TxStatus.failed)).toBe(Status.Failed); expect(statusOf(undefined, BridgeStatus.COMPLETE)).toBe(Status.Success); expect(statusOf(undefined, BridgeStatus.FAILED)).toBe(Status.Failed); expect(statusOf(undefined, BridgeStatus.UNKNOWN)).toBe(Status.InProgress); }); + + it('resolves same-chain Solana swaps from multichain transactions', () => { + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.Success); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + metaHash: '', + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.Success); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Failed), + }), + ).toBe(Status.Failed); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + isBridge: true, + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: 1, + transactionHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.InProgress); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + statusSrcTxHash: 'sol-sig', + nonEvmTransactions: solanaTx(KeyringTransactionStatus.Confirmed), + }), + ).toBe(Status.Success); + + expect( + statusOf(undefined, BridgeStatus.UNKNOWN, { + srcChainId: SOLANA_MAINNET_CHAIN_ID, + destChainId: SOLANA_MAINNET_CHAIN_ID, + nonEvmTransactions: solanaTx( + KeyringTransactionStatus.Confirmed, + 'tx-id', + ), + }), + ).toBe(Status.Success); + }); }); diff --git a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts index bcc0dcb99057..929f91d45f4a 100644 --- a/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts +++ b/app/components/UI/Bridge/components/PostTradeBottomSheet/usePostTradeTxStatus.ts @@ -1,11 +1,20 @@ import { useSelector } from 'react-redux'; -import { StatusTypes } from '@metamask/bridge-controller'; +import { + formatChainIdToCaip, + isSolanaChainId, + StatusTypes, +} from '@metamask/bridge-controller'; +import { + TransactionStatus as KeyringTransactionStatus, + type Transaction, +} from '@metamask/keyring-api'; import { TransactionStatus, type TransactionMeta, } from '@metamask/transaction-controller'; import type { RootState } from '../../../../../reducers'; import { selectBridgeHistoryForAccount } from '../../../../../selectors/bridgeStatusController'; +import { selectMultichainTransactions } from '../../../../../selectors/multichain/multichain'; import { selectTransactionMetadataById } from '../../../../../selectors/transactionController'; import { findBridgeHistoryItem } from '../../../../../util/bridge/findBridgeHistoryItem'; import { PostTradeStatus } from './PostTradeBottomSheet.types'; @@ -17,6 +26,37 @@ interface UsePostTradeTxStatusParams { transactionHash?: string; } +const normalizeHash = (hash?: string) => hash || undefined; + +const getMultichainPostTradeStatus = ( + state: RootState, + transactionHash?: string, + chainId?: number, +): PostTradeStatus | undefined => { + if (!transactionHash || !chainId) { + return undefined; + } + + const nonEvmTransactions = selectMultichainTransactions(state); + const sourceScope = formatChainIdToCaip(chainId); + const sourceChainTransactions = Object.values(nonEvmTransactions).flatMap( + (accountTransactions) => + accountTransactions[sourceScope]?.transactions ?? [], + ); + const transaction = sourceChainTransactions.find( + (tx: Transaction) => tx.id === transactionHash, + ); + + if (transaction?.status === KeyringTransactionStatus.Confirmed) { + return PostTradeStatus.Success; + } + if (transaction?.status === KeyringTransactionStatus.Failed) { + return PostTradeStatus.Failed; + } + + return undefined; +}; + export const usePostTradeTxStatus = ({ initialStatus, isBridge, @@ -29,14 +69,34 @@ export const usePostTradeTxStatus = ({ : undefined, ) as TransactionMeta | undefined; const bridgeHistory = useSelector(selectBridgeHistoryForAccount); + const submittedTransactionHash = + normalizeHash(transactionMeta?.hash) ?? normalizeHash(transactionHash); const bridgeHistoryItem = findBridgeHistoryItem({ bridgeHistory, transactionMetaId, transactionActionId: transactionMeta?.actionId, - transactionHash: transactionMeta?.hash ?? transactionHash, + transactionHash: submittedTransactionHash, }); + const quote = bridgeHistoryItem?.quote; + const sourceTransactionHash = + submittedTransactionHash ?? + normalizeHash(bridgeHistoryItem?.status?.srcChain?.txHash) ?? + normalizeHash(transactionMetaId); + // Same-chain Solana swaps never terminalize in `BridgeStatusController`, so + // resolve them from `MultichainTransactionsController` instead + const shouldResolveFromMultichain = Boolean( + !isBridge && quote && isSolanaChainId(quote.srcChainId), + ); + const multichainStatus = useSelector((state: RootState) => + getMultichainPostTradeStatus( + state, + shouldResolveFromMultichain ? sourceTransactionHash : undefined, + quote?.srcChainId, + ), + ); + if (initialStatus === PostTradeStatus.Failed) { return PostTradeStatus.Failed; } @@ -60,6 +120,10 @@ export const usePostTradeTxStatus = ({ return PostTradeStatus.Success; } + if (multichainStatus) { + return multichainStatus; + } + if (!isBridge && transactionStatus === TransactionStatus.confirmed) { return PostTradeStatus.Success; } diff --git a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx index 150c78209914..04a22bea22b0 100644 --- a/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx +++ b/app/components/UI/Money/components/MoneyActionButtonRow/MoneyActionButtonRow.tsx @@ -91,7 +91,7 @@ const MoneyActionButtonRow = ({ testID={MoneyActionButtonRowTestIds.ADD_BUTTON} /> { ); expect(getByText('Convert crypto')).toBeOnTheScreen(); - expect(getByText('Deposit funds')).toBeOnTheScreen(); - expect(getByText('Add your $1,203.89 mUSD')).toBeOnTheScreen(); - expect(getByText('Receive from external wallet')).toBeOnTheScreen(); + expect(getByText('Debit card or bank')).toBeOnTheScreen(); + expect(getByText('Your $1,203.89 mUSD')).toBeOnTheScreen(); + expect(getByText('External wallet')).toBeOnTheScreen(); expect(getByText('Coming soon')).toBeOnTheScreen(); expect( getByTestId(MoneyAddMoneySheetTestIds.RECEIVE_EXTERNAL_ROW), @@ -154,28 +154,6 @@ describe('MoneyAddMoneySheet', () => { expect(getByText('Add funds')).toBeOnTheScreen(); }); - it('renders a description under the Convert crypto row', () => { - const { getByTestId, getByText } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_DESCRIPTION), - ).toBeOnTheScreen(); - expect(getByText('From any account')).toBeOnTheScreen(); - }); - - it('renders a description under the Deposit funds row', () => { - const { getByTestId, getByText } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_DESCRIPTION), - ).toBeOnTheScreen(); - expect(getByText('From debit card or bank')).toBeOnTheScreen(); - }); - it('preserves the locale fiat prefix in the Move mUSD row', () => { (useMusdBalance as jest.Mock).mockReturnValue({ fiatBalanceAggregated: '1500.00', @@ -186,7 +164,7 @@ describe('MoneyAddMoneySheet', () => { }); const { getByText } = renderWithProvider(); - expect(getByText('Add your CA$1,500.00 mUSD')).toBeOnTheScreen(); + expect(getByText('Your CA$1,500.00 mUSD')).toBeOnTheScreen(); }); it('shows the move-mUSD row disabled with the "Add mUSD" label when the selected EVM account has no mUSD tokens or fiat balance', () => { @@ -237,7 +215,7 @@ describe('MoneyAddMoneySheet', () => { expect(mockInitiateDeposit).not.toHaveBeenCalled(); }); - it('shows the move-mUSD row with the "Add your $X mUSD" label when the selected EVM account mUSD fiat balance is positive', () => { + it('shows the move-mUSD row with the "Your $X mUSD" label when the selected EVM account mUSD fiat balance is positive', () => { (useMusdBalance as jest.Mock).mockReturnValue({ fiatBalanceAggregated: '12.34', fiatBalanceAggregatedFormatted: '$12.34', @@ -253,7 +231,7 @@ describe('MoneyAddMoneySheet', () => { expect( getByTestId(MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION), ).toBeOnTheScreen(); - expect(getByText('Add your $12.34 mUSD')).toBeOnTheScreen(); + expect(getByText('Your $12.34 mUSD')).toBeOnTheScreen(); }); it('shows the move-mUSD row with the token amount when the user has mUSD tokens but rates are unavailable', () => { @@ -272,7 +250,7 @@ describe('MoneyAddMoneySheet', () => { expect( getByTestId(MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION), ).toBeOnTheScreen(); - expect(getByText('Add your 42.50 mUSD')).toBeOnTheScreen(); + expect(getByText('Your 42.50 mUSD')).toBeOnTheScreen(); }); it('initiates a deposit with autoSelectFiatPayment when Deposit funds is pressed', () => { diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts index 7b29b060bc7c..cee9159c36a6 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.testIds.ts @@ -1,11 +1,7 @@ export const MoneyAddMoneySheetTestIds = { CONTAINER: 'money-add-money-sheet', CONVERT_CRYPTO_OPTION: 'money-add-money-sheet-convert-crypto', - CONVERT_CRYPTO_DESCRIPTION: - 'money-add-money-sheet-convert-crypto-description', DEPOSIT_FUNDS_OPTION: 'money-add-money-sheet-deposit-funds', - DEPOSIT_FUNDS_DESCRIPTION: 'money-add-money-sheet-deposit-funds-description', MOVE_MUSD_OPTION: 'money-add-money-sheet-move-musd', - MOVE_MUSD_DESCRIPTION: 'money-add-money-sheet-move-musd-description', RECEIVE_EXTERNAL_ROW: 'money-add-money-sheet-receive-external', }; diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx index b94eeb5b68d4..3497c4bcd85b 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx @@ -47,8 +47,6 @@ import { interface Option { label: string; - description?: string; - descriptionTestID?: string; icon: IconName; onPress: () => void; testID: string; @@ -164,8 +162,6 @@ const MoneyAddMoneySheet: React.FC = () => { const baseOptions: Option[] = [ { label: strings('money.add_money_sheet.convert_crypto'), - description: strings('money.add_money_sheet.convert_crypto_description'), - descriptionTestID: MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_DESCRIPTION, icon: IconName.Refresh, onPress: handleConvertCrypto, testID: MoneyAddMoneySheetTestIds.CONVERT_CRYPTO_OPTION, @@ -176,12 +172,7 @@ const MoneyAddMoneySheet: React.FC = () => { ? [ { label: strings('money.add_money_sheet.deposit_funds'), - description: strings( - 'money.add_money_sheet.deposit_funds_description', - ), - descriptionTestID: - MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_DESCRIPTION, - icon: IconName.AttachMoney, + icon: IconName.Bank, onPress: handleDepositFunds, testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, disabled: !hasNativeFiatProvider, @@ -195,8 +186,6 @@ const MoneyAddMoneySheet: React.FC = () => { ...baseOptions, { label: moveMusdLabel, - description: strings('money.add_money_sheet.move_musd_description'), - descriptionTestID: MoneyAddMoneySheetTestIds.MOVE_MUSD_DESCRIPTION, icon: IconName.Add, onPress: handleMoveMusd, testID: MoneyAddMoneySheetTestIds.MOVE_MUSD_OPTION, @@ -261,15 +250,6 @@ const MoneyAddMoneySheet: React.FC = () => { > {item.label} - {item.description ? ( - - {item.description} - - ) : null} )} diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx index 6052f5c08452..02db9fc7c77f 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx @@ -11,9 +11,9 @@ import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; import { selectMoneyOnboardingSeen } from '../../../../../reducers/user/selectors'; import { selectWalletHomeOnboardingFlowVisible } from '../../../../../selectors/onboarding'; import { useMoneyNavigation } from '../../hooks/useMoneyNavigation'; +import { useMoneyAccountAddRouting } from '../../hooks/useMoneyAccountAddRouting'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import { - BOTTOM_SHEET_NAMES, COMPONENT_NAMES, MONEY_BUTTON_INTENTS, MONEY_BUTTON_TYPES, @@ -22,9 +22,9 @@ import { SCREEN_NAMES, } from '../../constants/moneyEvents'; +const mockTrackButtonClicked = jest.fn(); const mockTrackComponentViewed = jest.fn(); const mockTrackSurfaceClicked = jest.fn(); -const mockTrackButtonClicked = jest.fn(); const mockTrackTooltipClicked = jest.fn(); jest.mock('../../hooks/useMoneyAnalytics', () => ({ @@ -33,6 +33,7 @@ jest.mock('../../hooks/useMoneyAnalytics', () => ({ const mockNavigate = jest.fn(); const mockNavigateToMoneyHome = jest.fn(); +const mockRouteAddMoney = jest.fn(); jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); @@ -59,6 +60,11 @@ jest.mock('../../hooks/useMoneyNavigation', () => ({ useMoneyNavigation: jest.fn(), })); +jest.mock('../../hooks/useMoneyAccountAddRouting', () => ({ + __esModule: true, + useMoneyAccountAddRouting: jest.fn(), +})); + jest.mock('../../../../../reducers/user/selectors', () => ({ __esModule: true, selectMoneyOnboardingSeen: jest.fn(), @@ -76,6 +82,7 @@ const mockSelectWalletHomeOnboardingFlowVisible = jest.mocked( selectWalletHomeOnboardingFlowVisible, ); const mockUseMoneyNavigation = jest.mocked(useMoneyNavigation); +const mockUseMoneyAccountAddRouting = jest.mocked(useMoneyAccountAddRouting); const createBalanceMock = ( overrides: Partial> = {}, @@ -133,10 +140,15 @@ describe('MoneyBalanceCard', () => { mockUseMoneyNavigation.mockReturnValue({ navigateToMoneyHome: mockNavigateToMoneyHome, }); + mockRouteAddMoney.mockResolvedValue(undefined); + mockUseMoneyAccountAddRouting.mockReturnValue({ + hasMusdBalance: false, + routeAddMoney: mockRouteAddMoney, + } as unknown as ReturnType); (useMoneyAnalytics as jest.Mock).mockReturnValue({ + trackButtonClicked: mockTrackButtonClicked, trackComponentViewed: mockTrackComponentViewed, trackSurfaceClicked: mockTrackSurfaceClicked, - trackButtonClicked: mockTrackButtonClicked, trackTooltipClicked: mockTrackTooltipClicked, }); }); @@ -211,17 +223,48 @@ describe('MoneyBalanceCard', () => { ).not.toBeOnTheScreen(); }); - it('opens the Add money sheet when Add is pressed', () => { + it('initiates a deposit when Add is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, }); }); + it('tracks the Add click with the Buy flow redirect target when the wallet holds no mUSD', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'money.balance_card.add', + redirect_target: SCREEN_NAMES.RAMP_BUY, + }); + }); + + it('tracks the Add click with the deposit redirect target when the wallet holds mUSD', () => { + mockUseMoneyAccountAddRouting.mockReturnValue({ + hasMusdBalance: true, + routeAddMoney: mockRouteAddMoney, + } as unknown as ReturnType); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'money.balance_card.add', + redirect_target: SCREEN_NAMES.MONEY_DEPOSIT, + }); + }); + it('renders the label', () => { const { getByTestId } = renderWithProvider(); @@ -267,16 +310,29 @@ describe('MoneyBalanceCard', () => { ).not.toBeOnTheScreen(); }); - it('opens the Add money sheet when Earn is pressed', () => { + it('routes add money when Earn is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, }); }); + + it('tracks the Earn click with the empty-state label key', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'homepage.sections.money_empty_state.earn', + redirect_target: SCREEN_NAMES.RAMP_BUY, + }); + }); }); describe('when balance is empty and onboarding has not been seen', () => { @@ -334,12 +390,13 @@ describe('MoneyBalanceCard', () => { ).toBeOnTheScreen(); }); - it('calls navigateToMoneyHome when Earn is pressed', () => { + it('routes add money when Earn is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1); + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigateToMoneyHome).not.toHaveBeenCalled(); }); }); @@ -348,18 +405,18 @@ describe('MoneyBalanceCard', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true); }); - it('renders the Get started button with the get_started label', () => { + it('renders the Earn button (never Get started) when the stepper is visible', () => { const { getByTestId, queryByTestId } = renderWithProvider( , ); expect( - getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), + getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), ).toHaveTextContent( - strings('homepage.sections.money_empty_state.get_started'), + strings('homepage.sections.money_empty_state.earn'), ); expect( - queryByTestId(MoneyBalanceCardTestIds.EARN_BUTTON), + queryByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), ).not.toBeOnTheScreen(); }); @@ -371,14 +428,12 @@ describe('MoneyBalanceCard', () => { ).toBeOnTheScreen(); }); - it('calls navigateToMoneyHome when Get started is pressed', () => { + it('routes add money when Earn is pressed', () => { const { getByTestId } = renderWithProvider(); - fireEvent.press( - getByTestId(MoneyBalanceCardTestIds.GET_STARTED_BUTTON), - ); + fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1); + expect(mockRouteAddMoney).toHaveBeenCalled(); }); }); }); @@ -439,14 +494,12 @@ describe('MoneyBalanceCard', () => { expect(mockNavigateToMoneyHome).toHaveBeenCalledTimes(1); }); - it('opens the Add money sheet when Add is pressed', () => { + it('routes add money when Add is pressed', () => { const { getByTestId } = renderWithProvider(); fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); + expect(mockRouteAddMoney).toHaveBeenCalled(); }); it('opens the Money balance info sheet when the info icon is pressed', () => { @@ -459,7 +512,7 @@ describe('MoneyBalanceCard', () => { }); }); - it('opens the Add money sheet (and not the Money home) when Earn is pressed in empty state', () => { + it('routes add money (and not the Money home) when Earn is pressed in empty state', () => { mockUseMoneyAccountBalance.mockReturnValue( createBalanceMock({ totalFiatRaw: '0', @@ -471,10 +524,8 @@ describe('MoneyBalanceCard', () => { fireEvent.press(getByTestId(MoneyBalanceCardTestIds.EARN_BUTTON)); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); + expect(mockRouteAddMoney).toHaveBeenCalled(); + expect(mockNavigateToMoneyHome).not.toHaveBeenCalled(); }); }); @@ -648,16 +699,13 @@ describe('MoneyBalanceCard', () => { ).toBe(ButtonVariant.Primary); }); - it('renders Get started as Secondary when the onboarding stepper is visible', () => { + it('renders Earn as Secondary when the onboarding stepper is visible', () => { mockSelectWalletHomeOnboardingFlowVisible.mockReturnValue(true); const { UNSAFE_getByProps } = renderWithProvider(); expect( - getVariant( - UNSAFE_getByProps, - MoneyBalanceCardTestIds.GET_STARTED_BUTTON, - ), + getVariant(UNSAFE_getByProps, MoneyBalanceCardTestIds.EARN_BUTTON), ).toBe(ButtonVariant.Secondary); }); }); @@ -837,20 +885,6 @@ describe('MoneyBalanceCard', () => { }); }); - it('calls trackButtonClicked with ADD_MONEY intent when the Add button is pressed', () => { - const { getByTestId } = renderWithProvider(); - - fireEvent.press(getByTestId(MoneyBalanceCardTestIds.ADD_BUTTON)); - - expect(mockTrackButtonClicked).toHaveBeenCalledWith( - expect.objectContaining({ - button_type: MONEY_BUTTON_TYPES.TEXT, - button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, - redirect_target: BOTTOM_SHEET_NAMES.MONEY_ADD_MONEY_SHEET, - }), - ); - }); - it('calls trackTooltipClicked with MONEY_BALANCE name and INFO type when the info button is pressed', () => { const { getByTestId } = renderWithProvider(); diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx index 341f8c4bf69a..5947652c9d4e 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx @@ -32,10 +32,10 @@ import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; import styleSheet from './MoneyBalanceCard.styles'; import { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; import { useMoneyNavigation } from '../../hooks/useMoneyNavigation'; +import { useMoneyAccountAddRouting } from '../../hooks/useMoneyAccountAddRouting'; import { SCREEN_NAMES, COMPONENT_NAMES, - BOTTOM_SHEET_NAMES, MONEY_BUTTON_INTENTS, MONEY_BUTTON_TYPES, MONEY_TOOLTIP_NAMES, @@ -60,6 +60,7 @@ const MoneyBalanceCard = () => { } = useMoneyAccountBalance(); const { hasMoneyAccount } = useMoneyAccountInfo(); const { navigateToMoneyHome } = useMoneyNavigation(); + const { hasMusdBalance, routeAddMoney } = useMoneyAccountAddRouting(); const hasSeenMoneyOnboarding = useSelector(selectMoneyOnboardingSeen); const hasOtherPrimaryCtaOnHome = useSelector( selectWalletHomeOnboardingFlowVisible, @@ -113,24 +114,15 @@ const MoneyBalanceCard = () => { buttonLabelKey = 'money.balance_card.add'; buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; containerTestId = MoneyBalanceCardTestIds.UNAVAILABLE_CONTAINER; - } else if (isNewUser) { - buttonVariant = hasOtherPrimaryCtaOnHome - ? ButtonVariant.Secondary - : ButtonVariant.Primary; - buttonLabelKey = hasOtherPrimaryCtaOnHome - ? 'homepage.sections.money_empty_state.get_started' - : 'homepage.sections.money_empty_state.earn'; - buttonTestId = hasOtherPrimaryCtaOnHome - ? MoneyBalanceCardTestIds.GET_STARTED_BUTTON - : MoneyBalanceCardTestIds.EARN_BUTTON; - containerTestId = MoneyBalanceCardTestIds.NEW_USER_CONTAINER; } else if (isEmpty) { buttonVariant = hasOtherPrimaryCtaOnHome ? ButtonVariant.Secondary : ButtonVariant.Primary; buttonLabelKey = 'homepage.sections.money_empty_state.earn'; buttonTestId = MoneyBalanceCardTestIds.EARN_BUTTON; - containerTestId = MoneyBalanceCardTestIds.EMPTY_CONTAINER; + containerTestId = isNewUser + ? MoneyBalanceCardTestIds.NEW_USER_CONTAINER + : MoneyBalanceCardTestIds.EMPTY_CONTAINER; } else { buttonVariant = hasOtherPrimaryCtaOnHome ? ButtonVariant.Secondary @@ -162,26 +154,13 @@ const MoneyBalanceCard = () => { button_type: MONEY_BUTTON_TYPES.TEXT, button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, label_key: buttonLabelKey, - redirect_target: BOTTOM_SHEET_NAMES.MONEY_ADD_MONEY_SHEET, + redirect_target: hasMusdBalance + ? SCREEN_NAMES.MONEY_DEPOSIT + : SCREEN_NAMES.RAMP_BUY, }); - navigation.navigate(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); - }, [buttonLabelKey, navigation, trackButtonClicked]); - - const handleGetStartedPress = useCallback(() => { - trackButtonClicked({ - button_type: MONEY_BUTTON_TYPES.TEXT, - button_intent: MONEY_BUTTON_INTENTS.GET_STARTED, - label_key: buttonLabelKey, - redirect_target: SCREEN_NAMES.MONEY_ONBOARDING, - }); - - navigateToMoneyHome(); - }, [buttonLabelKey, navigateToMoneyHome, trackButtonClicked]); - - const handleButtonPress = isNewUser ? handleGetStartedPress : handleAddPress; + routeAddMoney(); + }, [buttonLabelKey, hasMusdBalance, routeAddMoney, trackButtonClicked]); const handleInfoPress = useCallback(() => { trackTooltipClicked({ @@ -339,7 +318,7 @@ const MoneyBalanceCard = () => { testID={buttonTestId} variant={buttonVariant} size={ButtonSize.Md} - onPress={handleButtonPress} + onPress={handleAddPress} > {strings(buttonLabelKey)} diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx index b4caa5ee4df9..8d79b0e3a997 100644 --- a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx @@ -117,7 +117,7 @@ const MoneyTransferSheet = () => { const activeOptions: ActiveOption[] = [ { label: strings('money.transfer_sheet.between_accounts'), - icon: IconName.SwapHorizontal, + icon: IconName.Arrow2UpRight, onPress: handleBetweenAccounts, testID: MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION, }, diff --git a/app/components/UI/Money/constants/moneyEvents.ts b/app/components/UI/Money/constants/moneyEvents.ts index 2207c32c96ec..28e5cd60f36d 100644 --- a/app/components/UI/Money/constants/moneyEvents.ts +++ b/app/components/UI/Money/constants/moneyEvents.ts @@ -17,6 +17,7 @@ export enum SCREEN_NAMES { MONEY_ACTIVITY_DETAILS = 'money_activity_details', MONEY_POTENTIAL_EARNINGS = 'money_potential_earnings', ASSET_OVERVIEW = 'asset_overview', + RAMP_BUY = 'ramp_buy', } export enum BOTTOM_SHEET_NAMES { diff --git a/app/components/UI/Money/hooks/useMoneyAccountAddRouting.test.ts b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.test.ts new file mode 100644 index 000000000000..37c2048098eb --- /dev/null +++ b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.test.ts @@ -0,0 +1,317 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { useMusdBalance } from '../../Earn/hooks/useMusdBalance'; +import { useMusdConversionFlowData } from '../../Earn/hooks/useMusdConversionFlowData'; +import { useRampNavigation } from '../../Ramp/hooks/useRampNavigation'; +import { useMoneyAccountDeposit } from './useMoneyAccount'; +import { + MUSD_CONVERSION_DEFAULT_CHAIN_ID, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '../../Earn/constants/musd'; +import { useMoneyAccountAddRouting } from './useMoneyAccountAddRouting'; + +jest.mock('../../Earn/hooks/useMusdBalance', () => ({ + useMusdBalance: jest.fn(), +})); + +jest.mock('../../Earn/hooks/useMusdConversionFlowData', () => ({ + useMusdConversionFlowData: jest.fn(), +})); + +jest.mock('../../Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: jest.fn(), +})); + +jest.mock('./useMoneyAccount', () => ({ + useMoneyAccountDeposit: jest.fn(), +})); + +const mockedUseMusdBalance = useMusdBalance as jest.Mock; +const mockedUseMusdConversionFlowData = useMusdConversionFlowData as jest.Mock; +const mockedUseRampNavigation = useRampNavigation as jest.Mock; +const mockedUseMoneyAccountDeposit = useMoneyAccountDeposit as jest.Mock; + +const mockInitiateDeposit = jest.fn(() => Promise.resolve()); +const mockGoToBuy = jest.fn(); +const mockGetChainIdForBuyFlow = jest.fn(); + +const setupMocks = (overrides?: { + hasMusdBalanceOnAnyChain?: boolean; + fiatBalanceAggregated?: string; + tokenBalanceByChain?: Record | undefined; + getChainIdForBuyFlow?: jest.Mock | undefined; +}) => { + mockedUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: overrides?.hasMusdBalanceOnAnyChain ?? false, + fiatBalanceAggregated: overrides?.fiatBalanceAggregated, + tokenBalanceByChain: overrides?.tokenBalanceByChain, + }); + mockedUseMusdConversionFlowData.mockReturnValue({ + getChainIdForBuyFlow: + overrides && 'getChainIdForBuyFlow' in overrides + ? overrides.getChainIdForBuyFlow + : mockGetChainIdForBuyFlow, + }); + mockedUseRampNavigation.mockReturnValue({ + goToBuy: mockGoToBuy, + }); + mockedUseMoneyAccountDeposit.mockReturnValue({ + initiateDeposit: mockInitiateDeposit, + }); +}; + +describe('useMoneyAccountAddRouting', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetChainIdForBuyFlow.mockReturnValue(MUSD_CONVERSION_DEFAULT_CHAIN_ID); + }); + + describe('hasMusdBalance', () => { + it('is true when hasMusdBalanceOnAnyChain is true', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + fiatBalanceAggregated: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + + it('is true when the parsed fiat balance is positive', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '12.34', + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(true); + }); + + it('is false when there is no chain balance and the parsed fiat balance is zero', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '0', + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(false); + }); + + it('is false when the fiat balance is undefined and no on-chain balance', () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + expect(result.current.hasMusdBalance).toBe(false); + }); + }); + + describe('convertCrypto', () => { + it('calls initiateDeposit with no options', async () => { + setupMocks(); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.convertCrypto(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledTimes(1); + expect(mockInitiateDeposit).toHaveBeenCalledWith(); + }); + + it('swallows initiateDeposit failures', async () => { + setupMocks(); + mockInitiateDeposit.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await expect(result.current.convertCrypto()).resolves.toBeUndefined(); + }); + }); + + describe('depositFunds', () => { + it('routes to the Buy flow with the mUSD asset id for the chain returned by getChainIdForBuyFlow', () => { + mockGetChainIdForBuyFlow.mockReturnValue(CHAIN_IDS.LINEA_MAINNET); + setupMocks(); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.depositFunds(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET], + }); + }); + + it('falls back to MUSD_CONVERSION_DEFAULT_CHAIN_ID when getChainIdForBuyFlow is not provided', () => { + setupMocks({ getChainIdForBuyFlow: undefined }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.depositFunds(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + }); + }); + + it('falls back to the default chain mUSD asset id when the resolved chain has no mapped asset id', () => { + mockGetChainIdForBuyFlow.mockReturnValue(CHAIN_IDS.ARBITRUM); + setupMocks(); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.depositFunds(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + }); + }); + }); + + describe('moveMusd', () => { + it('picks the chain with the highest mUSD balance', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { + [CHAIN_IDS.MAINNET]: '500.00', + [CHAIN_IDS.LINEA_MAINNET]: '1000.00', + }, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.moveMusd(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.LINEA_MAINNET], + chainId: CHAIN_IDS.LINEA_MAINNET, + }, + }); + }); + + it('falls back to the default chain when no per-chain balances are available', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.moveMusd(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: + MUSD_TOKEN_ADDRESS_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + chainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID, + }, + }); + }); + + it('swallows initiateDeposit failures', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { + [CHAIN_IDS.MAINNET]: '1.00', + }, + }); + mockInitiateDeposit.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await expect(result.current.moveMusd()).resolves.toBeUndefined(); + }); + }); + + describe('routeAddMoney', () => { + it('delegates to moveMusd when hasMusdBalance is true', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { + [CHAIN_IDS.MAINNET]: '100.00', + }, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.routeAddMoney(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET], + chainId: CHAIN_IDS.MAINNET, + }, + }); + expect(mockGoToBuy).not.toHaveBeenCalled(); + }); + + it('delegates to moveMusd when only the parsed fiat balance is positive', async () => { + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '5.00', + tokenBalanceByChain: undefined, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + await act(async () => { + await result.current.routeAddMoney(); + }); + + expect(mockInitiateDeposit).toHaveBeenCalledWith({ + intent: 'addMusd', + preferredPaymentToken: { + address: + MUSD_TOKEN_ADDRESS_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID], + chainId: MUSD_CONVERSION_DEFAULT_CHAIN_ID, + }, + }); + expect(mockGoToBuy).not.toHaveBeenCalled(); + }); + + it('delegates to depositFunds when there is no mUSD balance', () => { + mockGetChainIdForBuyFlow.mockReturnValue(CHAIN_IDS.MAINNET); + setupMocks({ + hasMusdBalanceOnAnyChain: false, + fiatBalanceAggregated: '0', + tokenBalanceByChain: {}, + }); + + const { result } = renderHook(() => useMoneyAccountAddRouting()); + + act(() => { + result.current.routeAddMoney(); + }); + + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: MUSD_TOKEN_ASSET_ID_BY_CHAIN[CHAIN_IDS.MAINNET], + }); + expect(mockInitiateDeposit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Money/hooks/useMoneyAccountAddRouting.ts b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.ts new file mode 100644 index 000000000000..ae852b79cb3f --- /dev/null +++ b/app/components/UI/Money/hooks/useMoneyAccountAddRouting.ts @@ -0,0 +1,90 @@ +import { useCallback, useMemo } from 'react'; +import BigNumber from 'bignumber.js'; +import { Hex } from '@metamask/utils'; +import { useMusdBalance } from '../../Earn/hooks/useMusdBalance'; +import { useMusdConversionFlowData } from '../../Earn/hooks/useMusdConversionFlowData'; +import { useRampNavigation } from '../../Ramp/hooks/useRampNavigation'; +import { useMoneyAccountDeposit } from './useMoneyAccount'; +import { + MUSD_CONVERSION_DEFAULT_CHAIN_ID, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '../../Earn/constants/musd'; +export interface UseMoneyAccountAddRoutingResult { + hasMusdBalance: boolean; + convertCrypto: () => Promise; + depositFunds: () => void; + moveMusd: () => Promise; + routeAddMoney: () => Promise | void; +} + +export const useMoneyAccountAddRouting = + (): UseMoneyAccountAddRoutingResult => { + const { + fiatBalanceAggregated, + hasMusdBalanceOnAnyChain, + tokenBalanceByChain, + } = useMusdBalance(); + const { getChainIdForBuyFlow } = useMusdConversionFlowData(); + const { goToBuy } = useRampNavigation(); + const { initiateDeposit } = useMoneyAccountDeposit(); + + const hasMusdBalance = useMemo(() => { + const parsedMusdFiat = Number(fiatBalanceAggregated); + const hasParsedFiatBalance = + Number.isFinite(parsedMusdFiat) && parsedMusdFiat > 0; + return hasMusdBalanceOnAnyChain || hasParsedFiatBalance; + }, [fiatBalanceAggregated, hasMusdBalanceOnAnyChain]); + + const convertCrypto = useCallback( + () => initiateDeposit().catch(() => undefined), + [initiateDeposit], + ); + + const depositFunds = useCallback(() => { + const chainId = getChainIdForBuyFlow + ? getChainIdForBuyFlow() + : MUSD_CONVERSION_DEFAULT_CHAIN_ID; + const assetId = + MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId] ?? + MUSD_TOKEN_ASSET_ID_BY_CHAIN[MUSD_CONVERSION_DEFAULT_CHAIN_ID]; + goToBuy({ assetId }); + }, [getChainIdForBuyFlow, goToBuy]); + + const moveMusd = useCallback(() => { + let sourceChainId: Hex = MUSD_CONVERSION_DEFAULT_CHAIN_ID; + let bestBalance = new BigNumber(0); + for (const [chainId, balance] of Object.entries( + tokenBalanceByChain ?? {}, + )) { + const candidate = new BigNumber(balance ?? 0); + if (candidate.isGreaterThan(bestBalance)) { + sourceChainId = chainId as Hex; + bestBalance = candidate; + } + } + + return initiateDeposit({ + intent: 'addMusd', + preferredPaymentToken: { + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[sourceChainId], + chainId: sourceChainId, + }, + }).catch(() => undefined); + }, [initiateDeposit, tokenBalanceByChain]); + + const routeAddMoney = useCallback(() => { + if (hasMusdBalance) { + return moveMusd(); + } + return depositFunds(); + }, [depositFunds, hasMusdBalance, moveMusd]); + + return { + hasMusdBalance, + convertCrypto, + depositFunds, + moveMusd, + routeAddMoney, + }; + }; diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx index 39347a37e86b..1869a41c7150 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx @@ -431,18 +431,6 @@ describe('BuildQuote View', () => { expect(mockPop).toBeCalledTimes(1); }); - it('calls setOptions when rendering', () => { - render(BuildQuote); - expect(mockSetOptions).toHaveBeenCalled(); - - mockSetOptions.mockReset(); - - mockUseRampSDKValues.isBuy = false; - mockUseRampSDKValues.isSell = true; - render(BuildQuote); - expect(mockSetOptions).toHaveBeenCalled(); - }); - it('calls setIntent when params have intent', () => { mockUseParamsValues = { assetId: 'eip155:1/er20:0x6b175474e89094c44da98b954eedeac495271d0f', @@ -477,6 +465,14 @@ describe('BuildQuote View', () => { }); }); + it('hides back button when showBack is false', () => { + mockUseParamsValues = { showBack: false }; + render(BuildQuote); + expect( + screen.queryByTestId('deposit-back-navbar-button'), + ).not.toBeOnTheScreen(); + }); + it('calls endTrace when the conditions are met', () => { render(BuildQuote); expect(endTrace).toHaveBeenCalledWith({ diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index 9388928515f6..461ace708f6e 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -17,6 +17,7 @@ import BN4 from 'bnjs4'; import { AvatarToken, AvatarTokenSize, + HeaderStandard, } from '@metamask/design-system-react-native'; import { useRampSDK } from '../../sdk'; @@ -51,7 +52,7 @@ import BadgeWrapper, { import BadgeNetwork from '../../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork'; import { NATIVE_ADDRESS } from '../../../../../../constants/on-ramp'; -import { getDepositNavbarOptions } from '../../../../Navbar'; +import { NavbarSelectorsIDs } from '../../../../Navbar/Navbar.testIds'; import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, @@ -96,6 +97,7 @@ import Button, { ButtonVariants, ButtonWidthTypes, } from '../../../../../../component-library/components/Buttons/Button'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; import { BuildQuoteSelectors } from './BuildQuote.testIds'; import { isNonEvmAddress } from '../../../../../../core/Multichain/utils'; @@ -121,6 +123,7 @@ const BuildQuote = () => { const navigation = useNavigation(); const params = useParams(); const { showBack } = params; + const shouldShowBack = showBack !== false; // Memoize the intent object to prevent unnecessary re-renders const intent = useMemo(() => { @@ -483,30 +486,11 @@ const BuildQuote = () => { navigation.navigate(...createBuySettingsModalNavigationDetails()); }, [navigation]); - useEffect(() => { - navigation.setOptions( - getDepositNavbarOptions( - navigation, - { - title: isBuy - ? strings('fiat_on_ramp_aggregator.amount_to_buy') - : strings('fiat_on_ramp_aggregator.amount_to_sell'), - showBack: showBack ?? false, - showConfiguration: isBuy, - onConfigurationPress: handleConfigurationPress, - }, - theme, - handleCancelPress, - ), - ); - }, [ - navigation, - theme, - handleCancelPress, - showBack, - isBuy, - handleConfigurationPress, - ]); + const handleBackPress = useCallback(() => { + handleCancelPress(); + // @ts-expect-error navigation prop mismatch + navigation.pop(); + }, [handleCancelPress, navigation]); /** * * Keypad style, handlers and effects @@ -924,6 +908,31 @@ const BuildQuote = () => { return ( + { const [key, setKey] = useState(0); const navigation = useNavigation(); const params = useParams(); - const theme = useTheme(); const handleSuccessfulOrder = useHandleSuccessfulOrder(); const { styles } = useStyles(styleSheet, {}); @@ -95,17 +92,6 @@ const CheckoutWebView = () => { sheetRef.current?.onCloseBottomSheet(); }, [handleCancelPress]); - useEffect(() => { - navigation.setOptions( - getDepositNavbarOptions( - navigation, - { title: provider.name }, - theme, - handleCancelPress, - ), - ); - }, [navigation, theme, handleCancelPress, provider.name]); - useEffect(() => { if ( !customOrderId || diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx index afb107d261ec..ae17f23d961d 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx @@ -19,6 +19,7 @@ import { import Quotes, { QuotesParams } from './Quotes'; import { mockQuotesData } from './Quotes.constants'; +import { QuoteSelectors } from './Quotes.testIds'; import Timer from './Timer'; import LoadingQuotes from './LoadingQuotes'; @@ -242,25 +243,13 @@ describe('Quotes', () => { }; }); - it('calls setOptions when rendering', async () => { - mockUseQuotesAndCustomActionsValues = { - ...mockUseQuotesAndCustomActionsInitialValues, - isFetching: true, - quotes: undefined, - quotesWithoutError: [], - quotesWithError: [], - quotesByPriceWithoutError: [], - quotesByReliabilityWithoutError: [], - recommendedQuote: undefined, - }; - render(Quotes); - expect(mockSetOptions).toHaveBeenCalled(); - }); - - it('navigates and tracks event on back button press', async () => { + it('tracks event on close button press', async () => { render(Quotes); - fireEvent.press(screen.getByTestId('deposit-back-navbar-button')); - expect(mockPop).toHaveBeenCalled(); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + fireEvent.press(screen.getByTestId(QuoteSelectors.CLOSE_BUTTON)); expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', { chain_id_destination: '1', location: 'Quotes Screen', @@ -273,12 +262,16 @@ describe('Quotes', () => { }); }); - it('navigates and tracks event on SELL back button press', async () => { + it('tracks event on SELL close button press', async () => { mockUseRampSDKValues.rampType = RampType.SELL; mockUseRampSDKValues.isSell = true; mockUseRampSDKValues.isBuy = false; render(Quotes); - fireEvent.press(screen.getByTestId('deposit-back-navbar-button')); + act(() => { + jest.advanceTimersByTime(3000); + jest.clearAllTimers(); + }); + fireEvent.press(screen.getByTestId(QuoteSelectors.CLOSE_BUTTON)); expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', { chain_id_source: '1', location: 'Quotes Screen', diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts index ced479ddf73f..384b7a97ac67 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.testIds.ts @@ -7,4 +7,5 @@ export const QuoteSelectors = { EXPLORE_MORE_OPTIONS: enContent.fiat_on_ramp_aggregator.explore_more_options, EXPANDED_QUOTES_SECTION: 'expanded-quotes-section', CONTINUE_WITH_PROVIDER: enContent.fiat_on_ramp_aggregator.continue_with, + CLOSE_BUTTON: 'quotes-close-button', }; diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx index 5cc0c4724a9e..f9ab0eef5f9c 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx @@ -30,7 +30,6 @@ import Row from '../../components/Row'; import Quote from '../../components/Quote'; import CustomAction from '../../components/CustomAction'; import InfoAlert from '../../components/InfoAlert'; -import { getDepositNavbarOptions } from '../../../../Navbar'; import { ButtonSize, ButtonVariants, @@ -117,7 +116,7 @@ function Quotes() { const [remainingTime, setRemainingTime] = useState( appConfig.POLLING_INTERVAL, ); - const { styles, theme } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); const scrollOffsetY = useSharedValue(0); const scrollHandler = useAnimatedScrollHandler((event) => { @@ -581,17 +580,6 @@ function Quotes() { } }, [ErrorFetchingQuotes, pollingCyclesLeft]); - useEffect(() => { - navigation.setOptions( - getDepositNavbarOptions( - navigation, - { title: strings('fiat_on_ramp_aggregator.select_a_quote') }, - theme, - handleCancelPress, - ), - ); - }, [navigation, theme, handleCancelPress]); - useEffect(() => { if (isFetchingQuotes) return; setShouldFinishAnimation(true); @@ -967,6 +955,7 @@ function Quotes() { handleClosePress(bottomSheetRef)} + closeButtonProps={{ testID: QuoteSelectors.CLOSE_BUTTON }} /> {isInPolling && ( diff --git a/app/components/UI/Ramp/Aggregator/routes/index.tsx b/app/components/UI/Ramp/Aggregator/routes/index.tsx index d190cfcd4149..e985cf05c4ba 100644 --- a/app/components/UI/Ramp/Aggregator/routes/index.tsx +++ b/app/components/UI/Ramp/Aggregator/routes/index.tsx @@ -30,11 +30,15 @@ const overlayScreenOptions = { const MainRoutes = () => ( - + { nativeFlowError: 'error', }); }); - - it('forwards headlessSessionId for headless buy attempts', () => { - const result = createBuildQuoteNavDetails({ - assetId: 'eip155:1/slip44:60', - amount: 25, - headlessSessionId: 'headless-abc', - }); - expect(result[1].params.params).toEqual({ - assetId: 'eip155:1/slip44:60', - amount: 25, - headlessSessionId: 'headless-abc', - }); - }); }); const mockSetSelectedProvider = jest.fn(); diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 069204aafdf8..59d78bceac6e 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -69,7 +69,6 @@ import { import TruncatedError from '../../components/TruncatedError'; import { PROVIDER_LINKS } from '../../Aggregator/types'; -import { failSession } from '../../headless/sessionRegistry'; const BAILED_ORDER_STATUSES = new Set([ RampsOrderStatus.Precreated, RampsOrderStatus.IdExpired, @@ -97,16 +96,6 @@ export interface BuildQuoteParams { buyFlowOrigin?: BuyFlowOrigin; /** Pre-fill the amount input (e.g. when restoring state after a navigation reset). */ amount?: number; - /** - * Legacy param from Phase 3. The headless flow now navigates straight - * to `Routes.RAMP.HEADLESS_HOST` and never lands on BuildQuote, so the - * field is unused. Kept as `optional` for backward compatibility with - * any in-flight deeplinks; safe to remove once we're sure no callers - * pass it. - * - * @deprecated Use `Routes.RAMP.HEADLESS_HOST` instead. - */ - headlessSessionId?: string; } /** @@ -162,24 +151,10 @@ function BuildQuote() { useEffect(() => { if (params?.nativeFlowError) { - if ( - params.headlessSessionId && - failSession( - params.headlessSessionId, - { - code: 'AUTH_FAILED', - message: params.nativeFlowError, - }, - 'AUTH_FAILED', - ) - ) { - navigation.setParams({ nativeFlowError: undefined }); - return; - } setRampsError(params.nativeFlowError); navigation.setParams({ nativeFlowError: undefined }); } - }, [params?.headlessSessionId, params?.nativeFlowError, navigation]); + }, [params?.nativeFlowError, navigation]); const { userRegion, @@ -646,9 +621,6 @@ function BuildQuote() { assetId: selectedToken?.assetId ?? '', }); } catch (err) { - if (failSession(params?.headlessSessionId, err)) { - return; - } setRampsError((err as Error).message); } finally { setIsContinueLoading(false); @@ -664,7 +636,6 @@ function BuildQuote() { selectedPaymentMethod?.id, rampRoutingDecision, userRegion?.regionCode, - params?.headlessSessionId, trackEvent, createEventBuilder, continueWithQuote, diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx index fd965e88005e..8b6bc35c8f2a 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx @@ -190,27 +190,28 @@ const OndoLeaderboardView: React.FC = () => { > {/* User position */} {position && ( - - - + <> + + + + {/* Divider */} + + )} - {/* Divider */} - - {/* Tier selector + last updated row */} {selectedTier && ( { const { View } = jest.requireActual('react-native'); return { __esModule: true, - PREDICT_THE_PITCH_LEADERBOARD_TEST_IDS: { - TOTAL_PARTICIPANTS: 'predict-the-pitch-leaderboard-total-participants', - }, + PREDICT_THE_PITCH_LEADERBOARD_TEST_IDS: {}, default: (props: Record) => { mockPredictLeaderboard(props); return ReactActual.createElement(View, { @@ -125,15 +123,7 @@ jest.mock('../hooks/useGetCampaignParticipantStatus'); jest.mock('../hooks/useTrackRewardsPageView', () => jest.fn()); jest.mock('../../../../../locales/i18n', () => ({ - strings: (key: string, params?: { count?: string }) => { - if ( - key === - 'rewards.predict_the_pitch_campaign.leaderboard_total_participants' - ) { - return `${params?.count ?? '0'} participants`; - } - return key; - }, + strings: (key: string) => key, })); const mockUseSelector = useSelector as jest.MockedFunction; @@ -395,9 +385,4 @@ describe('PredictThePitchCampaignLeaderboardView', () => { }), ); }); - - it('shows total participants when count is greater than zero', () => { - const { getByText } = render(); - expect(getByText('50 participants')).toBeDefined(); - }); }); diff --git a/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx index 6e8d008c0618..1e93ca36404c 100644 --- a/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/PredictThePitchCampaignLeaderboardView.tsx @@ -91,7 +91,6 @@ const PredictThePitchCampaignLeaderboardView: React.FC = () => { const isCampaignComplete = campaign != null && getCampaignStatus(campaign) === 'complete'; - const totalParticipants = leaderboard?.totalParticipants ?? 0; return ( { )} - - {totalParticipants > 0 && ( - - {strings( - 'rewards.predict_the_pitch_campaign.leaderboard_total_participants', - { count: totalParticipants.toLocaleString() }, - )} - - )} - ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock( '../../../../selectors/multichainAccounts/accountTreeController', () => ({ @@ -65,6 +69,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; const mockSelectActiveTab = selectActiveTab as jest.MockedFunction< @@ -83,6 +88,10 @@ const mockSelectIsCurrentSubscriptionVipEnabled = selectIsCurrentSubscriptionVipEnabled as jest.MockedFunction< typeof selectIsCurrentSubscriptionVipEnabled >; +const mockSelectVipProgramEnabled = + selectVipProgramEnabled as jest.MockedFunction< + typeof selectVipProgramEnabled + >; const mockSelectHideUnlinkedAccountsBanner = selectHideUnlinkedAccountsBanner as jest.MockedFunction< typeof selectHideUnlinkedAccountsBanner @@ -265,6 +274,7 @@ describe('RewardsDashboard', () => { activeTab: 'campaigns' as const, subscriptionId: 'test-subscription-id', isVipEnabled: false, + isVipProgramEnabled: true, hideUnlinkedAccountsBanner: false, hideCurrentAccountNotOptedInBannerArray: [], selectedAccountGroup: mockSelectedAccountGroup, @@ -334,6 +344,9 @@ describe('RewardsDashboard', () => { mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue( defaultSelectorValues.isVipEnabled, ); + mockSelectVipProgramEnabled.mockReturnValue( + defaultSelectorValues.isVipProgramEnabled, + ); mockSelectHideUnlinkedAccountsBanner.mockReturnValue( defaultSelectorValues.hideUnlinkedAccountsBanner, ); @@ -366,6 +379,8 @@ describe('RewardsDashboard', () => { return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return defaultSelectorValues.isVipEnabled; + if (selector === selectVipProgramEnabled) + return defaultSelectorValues.isVipProgramEnabled; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -483,6 +498,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -498,6 +514,30 @@ describe('RewardsDashboard', () => { expect(getByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON)).toBeOnTheScreen(); }); + it('does not render the VIP button when the VIP program flag is off, even if the subscription is VIP', () => { + mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectActiveTab) + return defaultSelectorValues.activeTab; + if (selector === selectRewardsSubscriptionId) + return defaultSelectorValues.subscriptionId; + if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return false; + if (selector === selectHideUnlinkedAccountsBanner) + return defaultSelectorValues.hideUnlinkedAccountsBanner; + if (selector === selectHideCurrentAccountNotOptedInBannerArray) + return defaultSelectorValues.hideCurrentAccountNotOptedInBannerArray; + if (selector === selectSelectedAccountGroup) + return defaultSelectorValues.selectedAccountGroup; + if (selector === mockHasAcceptedVipInviteSelector) return false; + return undefined; + }); + + const { queryByTestId } = render(); + + expect(queryByTestId(REWARDS_VIEW_SELECTORS.VIP_BUTTON)).toBeNull(); + }); + it('navigates to VIP splash when the invite has not been accepted', () => { mockSelectIsCurrentSubscriptionVipEnabled.mockReturnValue(true); mockUseSelector.mockImplementation((selector) => { @@ -506,6 +546,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -531,6 +572,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) @@ -1295,6 +1337,7 @@ describe('RewardsDashboard', () => { if (selector === selectRewardsSubscriptionId) return defaultSelectorValues.subscriptionId; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; if (selector === selectHideUnlinkedAccountsBanner) return defaultSelectorValues.hideUnlinkedAccountsBanner; if (selector === selectHideCurrentAccountNotOptedInBannerArray) diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index 0b3a1766f394..c9e7cade7f88 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -26,6 +26,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals, @@ -53,6 +54,7 @@ const RewardsDashboard: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); const hasAcceptedVipInvite = useSelector( selectHasAcceptedVipInvite(subscriptionId), @@ -288,7 +290,7 @@ const RewardsDashboard: React.FC = () => { - {isVipEnabled && ( + {isVipProgramEnabled && isVipEnabled && ( = {}; @@ -117,6 +119,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => children, @@ -141,6 +147,7 @@ describe('RewardsVipSplashView', () => { beforeEach(() => { jest.clearAllMocks(); mockIsVipEnabled = true; + mockIsVipProgramEnabled = true; mockCanGoBack = true; mockVipSplashAccepted = {}; mockUseVipDashboard.mockReturnValue({ @@ -155,6 +162,9 @@ describe('RewardsVipSplashView', () => { if (selector === selectIsCurrentSubscriptionVipEnabled) { return mockIsVipEnabled; } + if (selector === selectVipProgramEnabled) { + return mockIsVipProgramEnabled; + } return ( selector as ( @@ -234,4 +244,15 @@ describe('RewardsVipSplashView', () => { StackActions.replace(Routes.REWARDS_DASHBOARD), ); }); + + it('replaces with dashboard when the VIP program flag is off, even if the subscription is VIP', () => { + mockIsVipProgramEnabled = false; + + const { queryByTestId } = render(); + + expect(queryByTestId(VIP_SPLASH_SCREEN_TEST_IDS.CONTAINER)).toBeNull(); + expect(mockNavigateDispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.REWARDS_DASHBOARD), + ); + }); }); diff --git a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx index 6e7896ecdae3..8c4f109fdf3c 100644 --- a/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipSplashView.tsx @@ -7,6 +7,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import VipSplashScreen from '../components/Vip/VipSplashScreen'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -14,11 +15,14 @@ import { useVipDashboard } from '../hooks/useVipDashboard'; const RewardsVipSplashViewContent: React.FC = () => { const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); const hasAcceptedVipInvite = useSelector( selectHasAcceptedVipInvite(subscriptionId), ); - const canViewVip = Boolean(subscriptionId && isVipEnabled); + const canViewVip = Boolean( + isVipProgramEnabled && subscriptionId && isVipEnabled, + ); useVipDashboard(); diff --git a/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx index 94256300c698..05b22f09e2a6 100644 --- a/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipTiersView.test.tsx @@ -7,6 +7,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; import type { VipDashboardState } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -171,6 +172,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, default: function MockErrorBoundary({ @@ -288,6 +293,7 @@ const mockSubscribed = () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return 'test-subscription-id'; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; return undefined; }); }; @@ -354,6 +360,25 @@ describe('RewardsVipTiersView', () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return 'sub'; if (selector === selectIsCurrentSubscriptionVipEnabled) return false; + if (selector === selectVipProgramEnabled) return true; + return undefined; + }); + + const { queryByTestId } = render(); + expect(queryByTestId(REWARDS_VIP_TIERS_VIEW_TEST_IDS.ROOT)).toBeNull(); + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.REWARDS_DASHBOARD), + ); + }); + }); + + it('redirects to the rewards dashboard when the VIP program flag is off, even for a VIP subscription', async () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) + return 'test-subscription-id'; + if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return false; return undefined; }); diff --git a/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx b/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx index 76dea62772fd..38d6705ee217 100644 --- a/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipTiersView.tsx @@ -15,6 +15,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -33,8 +34,11 @@ const RewardsVipTiersViewContent: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); - const canViewVip = Boolean(subscriptionId && isVipEnabled); + const canViewVip = Boolean( + isVipProgramEnabled && subscriptionId && isVipEnabled, + ); const { dashboard, diff --git a/app/components/UI/Rewards/Views/RewardsVipView.test.tsx b/app/components/UI/Rewards/Views/RewardsVipView.test.tsx index cea1386c26d0..20d18d19fac6 100644 --- a/app/components/UI/Rewards/Views/RewardsVipView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipView.test.tsx @@ -8,6 +8,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -186,6 +187,10 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/vipProgram', () => ({ + selectVipProgramEnabled: jest.fn(), +})); + jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, default: function MockErrorBoundary({ @@ -346,6 +351,7 @@ const mockSubscribed = () => { mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return 'test-subscription-id'; if (selector === selectIsCurrentSubscriptionVipEnabled) return true; + if (selector === selectVipProgramEnabled) return true; return ( selector as (state: ReturnType) => unknown )(getRewardsSelectorState()); @@ -667,6 +673,37 @@ describe('RewardsVipView', () => { if (selector === selectIsCurrentSubscriptionVipEnabled) { return false; } + if (selector === selectVipProgramEnabled) { + return true; + } + return undefined; + }); + + const { queryByTestId } = render(); + + expect(queryByTestId(REWARDS_VIEW_SELECTORS.VIP_VIEW)).toBeNull(); + expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({ + page_type: 'vip', + enabled: false, + }); + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.REWARDS_DASHBOARD), + ); + }); + }); + + it('redirects to the rewards dashboard when the VIP program flag is off, even for a VIP subscription', async () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return 'test-subscription-id'; + } + if (selector === selectIsCurrentSubscriptionVipEnabled) { + return true; + } + if (selector === selectVipProgramEnabled) { + return false; + } return undefined; }); diff --git a/app/components/UI/Rewards/Views/RewardsVipView.tsx b/app/components/UI/Rewards/Views/RewardsVipView.tsx index cae0a6e57ad3..a2805ef72a1f 100644 --- a/app/components/UI/Rewards/Views/RewardsVipView.tsx +++ b/app/components/UI/Rewards/Views/RewardsVipView.tsx @@ -24,6 +24,7 @@ import { selectIsCurrentSubscriptionVipEnabled, selectRewardsSubscriptionId, } from '../../../../selectors/rewards'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; import { useVipDashboard } from '../hooks/useVipDashboard'; @@ -66,8 +67,11 @@ const RewardsVipViewContent: React.FC = () => { const dispatch = useDispatch(); const navigation = useNavigation(); const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isVipProgramEnabled = useSelector(selectVipProgramEnabled); const isVipEnabled = useSelector(selectIsCurrentSubscriptionVipEnabled); - const canViewVip = Boolean(subscriptionId && isVipEnabled); + const canViewVip = Boolean( + isVipProgramEnabled && subscriptionId && isVipEnabled, + ); const referralCode = useSelector(selectReferralCode); const hasAcceptedVipInvite = useSelector( selectHasAcceptedVipInvite(subscriptionId), diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboardStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboardStatsHeader.tsx index 9024c9ac6c14..4018bdf0c451 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboardStatsHeader.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboardStatsHeader.tsx @@ -89,12 +89,12 @@ const CampaignLeaderboardStatsHeader: React.FC< {isLoading ? ( - <> + {showSubtextRow && ( - + )} - + ) : ( <> = ({ ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { return ( - + { - const ReactActual = jest.requireActual('react'); - const RN = jest.requireActual('react-native'); - return { - __esModule: true, - default: (props: { title: string; onConfirm: () => void }) => - ReactActual.createElement( - RN.View, - { testID: 'rewards-error-banner' }, - ReactActual.createElement(RN.Text, null, props.title), - ReactActual.createElement(RN.TouchableOpacity, { - testID: 'rewards-error-banner-retry', - onPress: props.onConfirm, - }), - ), +interface CapturedEndedStatsProps { + totalParticipants: { label: string; value: string; isLoading?: boolean }; + totalVolume: { label: string; value: string; isLoading?: boolean }; + topMetric: { + label: string; + value: string; + isLoading?: boolean; + valueColor?: unknown; }; -}); + totalWinners: { label: string; value: string; isLoading?: boolean }; + hasError?: boolean; + onRetry?: () => void; +} + +let latestProps: CapturedEndedStatsProps | null = null; -jest.mock('@metamask/design-system-react-native', () => { - const actual = jest.requireActual('@metamask/design-system-react-native'); +jest.mock('./CampaignEndedStats', () => { const ReactActual = jest.requireActual('react'); - const RN = jest.requireActual('react-native'); + const { View } = jest.requireActual('react-native'); return { - ...actual, - Text: (props: Record) => - ReactActual.createElement(RN.Text, props, props.children), - Skeleton: (props: Record) => - ReactActual.createElement(RN.View, { testID: 'skeleton', ...props }), + __esModule: true, + default: (props: CapturedEndedStatsProps) => { + latestProps = props; + return ReactActual.createElement(View, { + testID: 'campaign-ended-stats', + }); + }, }; }); -jest.mock('@metamask/design-system-twrnc-preset', () => { - const tw = (..._args: unknown[]) => ({}); - tw.style = jest.fn(() => ({})); - return { useTailwind: () => tw }; -}); - jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, })); @@ -87,38 +79,50 @@ const makeLeaderboard = ( }; describe('PerpsTradingCampaignEndedStats', () => { - it('renders all four stat cells with correct values when leaderboard has 20+ entries', () => { - const { getByTestId } = render( + beforeEach(() => { + latestProps = null; + jest.clearAllMocks(); + }); + + it('maps perps leaderboard and volume into the generic ended stats props', () => { + render( , ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.CONTAINER), - ).toBeTruthy(); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props - .children, - ).toBe((200).toLocaleString()); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props - .children, - ).toBe('$27.5M'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('+$80,000'); - // Leaderboard has 25 entries (>= 20) → fixed 20 winners - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('20'); + expect(latestProps).toMatchObject({ + totalParticipants: { + label: 'rewards.campaign_ended_stats.total_participants', + value: '200', + isLoading: false, + }, + totalVolume: { + label: 'rewards.campaign_ended_stats.total_volume', + value: '$27.5M', + isLoading: false, + }, + topMetric: { + label: 'rewards.campaign_ended_stats.top_pnl', + value: '+$80,000', + isLoading: false, + }, + totalWinners: { + label: 'rewards.campaign_ended_stats.total_winners', + value: '20', + isLoading: false, + }, + hasError: false, + }); }); it('shows dash for winners when leaderboard has fewer than 20 entries', () => { - const { getByTestId } = render( + render( { />, ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('-'); + expect(latestProps?.totalWinners.value).toBe('-'); }); it('shows dashes when leaderboard and volume are null', () => { - const { getByTestId } = render( + render( { />, ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props - .children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props - .children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('-'); + expect(latestProps).toMatchObject({ + totalParticipants: { value: '-', isLoading: false }, + totalVolume: { value: '-', isLoading: false }, + topMetric: { value: '-', isLoading: false }, + totalWinners: { value: '-', isLoading: false }, + }); }); - it('renders skeletons while data is loading', () => { - const { getAllByTestId } = render( + it('shows loading state while uncached data is loading', () => { + render( { />, ); - const skeletons = getAllByTestId('skeleton'); - expect(skeletons.length).toBeGreaterThanOrEqual(3); + expect(latestProps).toMatchObject({ + totalParticipants: { value: '-', isLoading: true }, + totalVolume: { value: '-', isLoading: true }, + topMetric: { value: '-', isLoading: true }, + totalWinners: { value: '-', isLoading: true }, + }); }); - it('handles a leaderboard with no entries (no top PnL)', () => { + it('handles a leaderboard with no entries', () => { const empty: PerpsTradingCampaignLeaderboardDto = { campaignId: 'perps-1', computedAt: '2026-01-01T00:00:00Z', @@ -181,7 +179,7 @@ describe('PerpsTradingCampaignEndedStats', () => { entries: [], }; - const { getByTestId } = render( + render( { />, ); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props - .children, - ).toBe('0'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('-'); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, - ).toBe('-'); + expect(latestProps).toMatchObject({ + totalParticipants: { value: '0' }, + topMetric: { value: '-' }, + totalWinners: { value: '-' }, + }); + }); + + it('uses error color for negative top PnL', () => { + const negativeTop: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 1, + minVolumeForEligibility: 25_000, + entries: [makeEntry(1, -5_000)], + }; + + render( + , + ); + + expect(latestProps?.topMetric.value).toBe('-$5,000'); + expect(latestProps?.topMetric.valueColor).toBe(TextColor.ErrorDefault); }); - it('shows error banner when both sources fail and triggers both retries', () => { + it('shows error and retries both data sources when uncached data fails', () => { const onRetryLeaderboard = jest.fn(); const onRetryVolume = jest.fn(); - const { getByTestId } = render( + render( { />, ); - expect(getByTestId('rewards-error-banner')).toBeTruthy(); - fireEvent.press(getByTestId('rewards-error-banner-retry')); + expect(latestProps?.hasError).toBe(true); + latestProps?.onRetry?.(); expect(onRetryLeaderboard).toHaveBeenCalledTimes(1); expect(onRetryVolume).toHaveBeenCalledTimes(1); }); - it('shows error banner when only leaderboard fails; volume still renders', () => { - const { getByTestId } = render( + it('shows error when only leaderboard fails while volume still renders', () => { + render( { />, ); - expect(getByTestId('rewards-error-banner')).toBeTruthy(); - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props - .children, - ).toBe('$27.5M'); + expect(latestProps?.hasError).toBe(true); + expect(latestProps?.totalVolume.value).toBe('$27.5M'); }); - it('does not render error banner when there are no errors', () => { - const { queryByTestId } = render( + it('does not show error when there are no errors', () => { + render( , ); - expect(queryByTestId('rewards-error-banner')).toBeNull(); - }); - - it('renders negative top PnL with error color and a minus sign', () => { - const negativeTop: PerpsTradingCampaignLeaderboardDto = { - campaignId: 'perps-1', - computedAt: '2026-01-01T00:00:00Z', - totalParticipants: 1, - minVolumeForEligibility: 25_000, - entries: [makeEntry(1, -5_000)], - }; - - const { getByTestId } = render( - , - ); - - expect( - getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, - ).toBe('-$5,000'); + expect(latestProps?.hasError).toBe(false); }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx index af3a16162882..e569a2a371d1 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx @@ -1,27 +1,10 @@ -import React, { useMemo } from 'react'; -import { - Box, - BoxFlexDirection, - FontWeight, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; +import React, { useCallback, useMemo } from 'react'; +import { TextColor } from '@metamask/design-system-react-native'; import type { PerpsTradingCampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { StatCell } from './OndoCampaignStatsSummary'; -import RewardsErrorBanner from '../RewardsErrorBanner'; import { strings } from '../../../../../../locales/i18n'; import { formatCompactUsd, formatSignedUsd } from '../../utils/formatUtils'; - -const PERPS_WINNERS_CAP = 20; - -export const PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS = { - CONTAINER: 'perps-campaign-ended-stats-container', - TOTAL_PARTICIPANTS: 'perps-campaign-ended-stats-total-participants', - TOTAL_VOLUME: 'perps-campaign-ended-stats-total-volume', - TOP_PNL: 'perps-campaign-ended-stats-top-pnl', - WINNERS: 'perps-campaign-ended-stats-winners', -} as const; +import { PERPS_TRADING_MAX_WINNERS } from '../../utils/perpsCampaignConstants'; +import CampaignEndedStats from './CampaignEndedStats'; interface PerpsTradingCampaignEndedStatsProps { leaderboard: PerpsTradingCampaignLeaderboardDto | null; @@ -53,100 +36,57 @@ const PerpsTradingCampaignEndedStats: React.FC< const totalParticipants = leaderboard.totalParticipants; const topPnl = entries.length > 0 ? Math.max(...entries.map((e) => e.pnl)) : null; - const hasFullLeaderboard = entries.length >= PERPS_WINNERS_CAP; + const hasFullLeaderboard = entries.length >= PERPS_TRADING_MAX_WINNERS; return { totalParticipants, topPnl, hasFullLeaderboard }; }, [leaderboard]); - const isLeaderboardSkeletonVisible = isLeaderboardLoading && !leaderboard; - const isVolumeSkeletonVisible = isVolumeLoading && !totalNotionalVolume; - + const hasStats = stats != null; + const hasTotalVolume = totalNotionalVolume != null; + const isStatsLoading = isLeaderboardLoading && !hasStats; + const isTotalVolumeLoading = isVolumeLoading && !hasTotalVolume; const hasError = - (hasLeaderboardError && !leaderboard) || - (hasVolumeError && !totalNotionalVolume); + (hasLeaderboardError && !hasStats) || (hasVolumeError && !hasTotalVolume); - const totalParticipantsValue = stats - ? stats.totalParticipants.toLocaleString() - : '-'; - - const totalVolumeValue = totalNotionalVolume - ? formatCompactUsd(parseFloat(totalNotionalVolume)) - : '-'; - - const topPnlValue = - stats?.topPnl != null ? formatSignedUsd(stats.topPnl) : '-'; + const retry = useCallback(() => { + onRetryLeaderboard?.(); + onRetryVolume?.(); + }, [onRetryLeaderboard, onRetryVolume]); const topPnlColor = stats?.topPnl != null && stats.topPnl >= 0 ? TextColor.SuccessDefault : TextColor.ErrorDefault; - const winnersValue = stats?.hasFullLeaderboard - ? String(PERPS_WINNERS_CAP) - : '-'; - return ( - - - {strings('rewards.perps_trading_campaign.stats_title')} - - {hasError && ( - { - onRetryLeaderboard?.(); - onRetryVolume?.(); - }} - confirmButtonLabel={strings( - 'rewards.perps_trading_campaign.stats_retry', - )} - /> - )} - - - - - - - - - + ); }; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx index e91f4a222c68..c83e305a76a0 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx @@ -100,7 +100,7 @@ const PerpsTradingCampaignLeaderboard: React.FC< ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { diff --git a/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx index 17d815d97020..73302178fb82 100644 --- a/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PredictThePitchLeaderboard.tsx @@ -16,6 +16,7 @@ import { CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, } from './CampaignLeaderboard'; import { useCampaignLeaderboardEntries } from '../../hooks/useCampaignLeaderboardEntries'; +import { PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS } from '../../utils/predictCampaignConstants'; export const PREDICT_THE_PITCH_LEADERBOARD_TEST_IDS = { CONTAINER: 'predict-the-pitch-leaderboard-container', @@ -75,7 +76,7 @@ const PredictThePitchLeaderboard: React.FC = ({ ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { @@ -145,6 +146,10 @@ const PredictThePitchLeaderboard: React.FC = ({ qualified: currentUser ? (isCurrentUserEligible ?? true) : true, }} isCurrentUser={currentUser} + showCrown={ + !isPreview && + entry.rank <= PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS + } isCampaignComplete={isCampaignComplete} formatPrimaryMetric={(e) => formatPercentChange(e.roi)} isPositivePrimaryMetric={(e) => e.roi >= 0} @@ -166,6 +171,10 @@ const PredictThePitchLeaderboard: React.FC = ({ : true, }} isCurrentUser={currentUser} + showCrown={ + !isPreview && + entry.rank <= PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS + } isCampaignComplete={isCampaignComplete} formatPrimaryMetric={(e) => formatPercentChange(e.roi)} isPositivePrimaryMetric={(e) => e.roi >= 0} diff --git a/app/components/UI/Rewards/utils/predictCampaignConstants.ts b/app/components/UI/Rewards/utils/predictCampaignConstants.ts new file mode 100644 index 000000000000..c3dc5cba8c7d --- /dev/null +++ b/app/components/UI/Rewards/utils/predictCampaignConstants.ts @@ -0,0 +1,2 @@ +/** Maximum winners for the predict the pitch campaign. */ +export const PREDICT_THE_PITCH_CAMPAIGN_MAX_WINNERS = 20; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index 5f7b5950a304..18a8a5568eb6 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -246,7 +246,7 @@ describe('TraderPositionView', () => { renderWithProvider(, { state: mockState }); - expect(screen.getByText('No trades for this interval')).toBeOnTheScreen(); + expect(screen.getByText('No trades yet')).toBeOnTheScreen(); }); it('calls goBack when the back button is pressed', () => { @@ -506,9 +506,8 @@ describe('TraderPositionView', () => { }); }); - it('filters trades when switching time periods', async () => { - const fixedNow = new Date('2026-04-21T12:00:00.000Z').getTime(); - const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(fixedNow); + it('shows all trades regardless of the active time period, but filters chart markers', async () => { + const now = Date.now(); mockRouteParams.position = { ...makeDefaultPosition(), @@ -518,7 +517,7 @@ describe('TraderPositionView', () => { direction: 'buy', tokenAmount: 1000, usdCost: 2200, - timestamp: fixedNow - 30 * 60 * 1000, + timestamp: now - 30 * 60 * 1000, transactionHash: '0xrecent', }, { @@ -526,7 +525,7 @@ describe('TraderPositionView', () => { direction: 'sell', tokenAmount: 500, usdCost: 1100, - timestamp: fixedNow - 2 * 24 * 60 * 60 * 1000, + timestamp: now - 2 * 24 * 60 * 60 * 1000, transactionHash: '0xolder', }, ], @@ -540,10 +539,15 @@ describe('TraderPositionView', () => { fireEvent.press(screen.getByText('1H')); await waitFor(() => { - expect(screen.queryByTestId('trade-row-0xolder')).not.toBeOnTheScreen(); + const chartTrades = + mockTraderPriceChart.mock.calls[ + mockTraderPriceChart.mock.calls.length - 1 + ]?.[0]?.trades; + expect(chartTrades).toHaveLength(1); + expect(chartTrades[0].transactionHash).toBe('0xrecent'); }); - - dateNowSpy.mockRestore(); + expect(screen.getByTestId('trade-row-0xrecent')).toBeOnTheScreen(); + expect(screen.getByTestId('trade-row-0xolder')).toBeOnTheScreen(); }); it('refetches position and profile on pull-to-refresh', async () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx index e2f96bfcdb79..eae222ac5c65 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.tsx @@ -117,7 +117,8 @@ const TraderPositionView = () => { pnlValue, pnlPercent, isPnlPositive, - trades, + allTrades, + chartTrades, activeTimePeriod, setActiveTimePeriod, timePeriods, @@ -339,7 +340,7 @@ const TraderPositionView = () => { priceDiff={priceDiff} isPricesLoading={isPricesLoading} onChartIndexChange={handleChartIndexChange} - trades={trades} + trades={chartTrades} /> { /> diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx index 3a2810c4dd5d..b06039aa3ada 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySelectQuoteScreen.tsx @@ -8,6 +8,8 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; +import { StyleSheet } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { fromTokenMinimalUnit } from '../../../../../../util/number/bigint'; import formatFiat from '../../../../../../util/formatFiat'; import { isGaslessQuote } from '../../../../../UI/Bridge/utils/isGaslessQuote'; @@ -16,6 +18,10 @@ import { strings } from '../../../../../../../locales/i18n'; import { useQuickBuyContext } from './useQuickBuyContext'; import QuickBuySubScreenHeader from './components/QuickBuySubScreenHeader'; +const styles = StyleSheet.create({ + scrollView: { flex: 1 }, +}); + const QuickBuySelectQuoteScreen: React.FC = () => { const { sortedQuotes, @@ -92,7 +98,11 @@ const QuickBuySelectQuoteScreen: React.FC = () => { ) : ( - <> + { ))} - + )} ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx index 07f9706a627c..6321ef269d1e 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx @@ -104,7 +104,6 @@ const QuickBuyActionFooter: React.FC = () => { flexDirection={BoxFlexDirection.Row} alignItems={BoxAlignItems.Center} gap={2} - twClassName="rounded-full bg-muted px-3 py-1" > {pickerToken ? ( networkImage ? ( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx index f41d23d29e91..90638bb5e631 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.test.tsx @@ -6,6 +6,7 @@ import { playImpact, ImpactMoment } from '../../../../../../../util/haptics'; // Capture gesture handlers so tests can invoke them directly. We re-assign // these in beforeEach via the mock factory below. let tapOnEnd: ((event: { x: number }) => void) | undefined; +let panOnStart: (() => void) | undefined; let panOnUpdate: ((event: { x: number }) => void) | undefined; let panOnEnd: ((event: { x: number }) => void) | undefined; @@ -23,6 +24,10 @@ jest.mock('react-native-gesture-handler', () => { }), Pan: () => { const panBuilder = { + onStart: (cb: () => void) => { + panOnStart = cb; + return panBuilder; + }, onUpdate: (cb: (event: { x: number }) => void) => { panOnUpdate = cb; return panBuilder; @@ -59,7 +64,7 @@ jest.mock('react-native-reanimated', () => { jest.mock('../../../../../../../util/haptics', () => ({ playImpact: jest.fn(), - ImpactMoment: { SliderTick: 'sliderTick' }, + ImpactMoment: { SliderTick: 'sliderTick', SliderGrip: 'sliderGrip' }, })); const SLIDER_WIDTH = 200; @@ -89,6 +94,7 @@ describe('QuickBuyPercentageSlider', () => { beforeEach(() => { jest.clearAllMocks(); tapOnEnd = undefined; + panOnStart = undefined; panOnUpdate = undefined; panOnEnd = undefined; }); @@ -278,6 +284,44 @@ describe('QuickBuyPercentageSlider', () => { }); }); + describe('grip haptics', () => { + it('fires a grip haptic when the drag starts and again when it ends', () => { + render( + , + ); + act(() => triggerLayout()); + + act(() => panOnStart?.()); + expect(playImpact).toHaveBeenCalledTimes(1); + expect(playImpact).toHaveBeenLastCalledWith(ImpactMoment.SliderGrip); + + act(() => panOnEnd?.({ x: 100 })); + expect(playImpact).toHaveBeenLastCalledWith(ImpactMoment.SliderGrip); + expect(playImpact).toHaveBeenCalledTimes(2); + }); + + it('does not fire grip haptics when disabled', () => { + render( + , + ); + act(() => triggerLayout()); + + act(() => panOnStart?.()); + act(() => panOnEnd?.({ x: 100 })); + + expect(playImpact).not.toHaveBeenCalled(); + }); + }); + describe('accessibility', () => { it('exposes the current value via accessibilityValue', () => { render( diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx index 09f71b208152..d22efd182704 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyPercentageSlider.tsx @@ -59,6 +59,14 @@ export function QuickBuyPercentageSlider({ [translateX], ); + // Fired when the user grabs the handle (pan start) and again when they let + // go (pan end) — a tactile "pick up / drop" pair distinct from the per-tick + // SliderTick crossings. + const handleGrip = useCallback(() => { + if (disabled) return; + playImpact(ImpactMoment.SliderGrip); + }, [disabled]); + const checkThresholdCrossing = useCallback((nextValue: number) => { const prevValue = previousValueRef.current; for (const threshold of HAPTIC_THRESHOLDS) { @@ -150,12 +158,18 @@ export function QuickBuyPercentageSlider({ runOnJS(commitFromPosition)(event.x, sliderWidth.value); }), Gesture.Pan() + .onStart(() => { + // Pick up — fire the grip haptic the moment the drag is recognized. + runOnJS(handleGrip)(); + }) .onUpdate((event) => { runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); }) .onEnd((event) => { // Commit the final position when the user lifts their finger. runOnJS(commitFromPosition)(event.x, sliderWidth.value); + // Drop — fire the grip haptic again on release. + runOnJS(handleGrip)(); }), ); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx index 3553ee12b726..92affc832ff4 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyRateTag.tsx @@ -56,8 +56,8 @@ const QuickBuyRateTag: React.FC = ({ diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx index 088f1d2f9220..1c468fff6512 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyTradeModeToggle.tsx @@ -20,12 +20,19 @@ import { useTheme } from '../../../../../../../util/theme'; import { playSelection } from '../../../../../../../util/haptics'; const styles = StyleSheet.create({ - container: { + // Inner row owns the relative positioning context. It carries no border or + // padding, so the absolute slider's insets line up 1:1 with the buttons' + // measured frames (the outer border would otherwise offset the slider by its + // width, leaving a larger gap at the top than the bottom). + row: { position: 'relative', }, slider: { position: 'absolute', - borderRadius: 10, + top: 0, + bottom: 0, + // Matches the buttons' rounded-lg (8px) so the fill tucks neatly behind them. + borderRadius: 8, }, }); @@ -53,7 +60,9 @@ const QuickBuyTradeModeToggle: React.FC = ({ if (!buyLayout) return; Animated.spring(slideAnim, { toValue: tradeMode === 'buy' ? 0 : buyLayout.width, - useNativeDriver: true, + // Color interpolation (backgroundColor below) is not supported by the + // native driver, so the slide must run on the JS driver too. + useNativeDriver: false, tension: 180, friction: 20, }).start(); @@ -61,74 +70,91 @@ const QuickBuyTradeModeToggle: React.FC = ({ const sliderWidth = tradeMode === 'buy' ? (buyLayout?.width ?? 0) : sellWidth; + // Transition the slider background from green (buy, translateX 0) to red + // (sell, translateX = buy button width) as it slides across. + const sliderBackgroundColor = + buyLayout && buyLayout.width > 0 + ? slideAnim.interpolate({ + inputRange: [0, buyLayout.width], + outputRange: [colors.success.default, colors.error.default], + }) + : colors.success.default; + return ( - {buyLayout && sliderWidth > 0 && ( - - )} + + {buyLayout && sliderWidth > 0 && ( + + )} - handlePress('buy')} - onLayout={(e) => setBuyLayout(e.nativeEvent.layout)} - accessibilityRole="button" - accessibilityState={{ selected: tradeMode === 'buy' }} - testID="quick-buy-trade-mode-buy" - > - - - {strings('social_leaderboard.quick_buy.buy_label')} - - - + handlePress('buy')} + onLayout={(e) => setBuyLayout(e.nativeEvent.layout)} + accessibilityRole="button" + accessibilityState={{ selected: tradeMode === 'buy' }} + testID="quick-buy-trade-mode-buy" + > + + + {strings('social_leaderboard.quick_buy.buy_label')} + + + - handlePress('sell')} - disabled={!hasSellableBalance} - onLayout={(e) => setSellWidth(e.nativeEvent.layout.width)} - accessibilityRole="button" - accessibilityState={{ - selected: tradeMode === 'sell', - disabled: !hasSellableBalance, - }} - testID="quick-buy-trade-mode-sell" - > - handlePress('sell')} + disabled={!hasSellableBalance} + onLayout={(e) => setSellWidth(e.nativeEvent.layout.width)} + accessibilityRole="button" + accessibilityState={{ + selected: tradeMode === 'sell', + disabled: !hasSellableBalance, + }} + testID="quick-buy-trade-mode-sell" > - - {strings('social_leaderboard.quick_buy.sell_label')} - - - + + {strings('social_leaderboard.quick_buy.sell_label')} + + + + ); }; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts index f3331f718d96..2f4541be09f6 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/useTraderPositionData.ts @@ -92,8 +92,8 @@ export interface TraderPositionData { pnlPercent: number | null; isPnlPositive: boolean; - // Trades (filtered by active period) - trades: Position['trades']; + allTrades: Position['trades']; + chartTrades: Position['trades']; // Time period activeTimePeriod: TimePeriod; @@ -285,18 +285,23 @@ export function useTraderPositionData( : (positionParam?.pnlPercent ?? null); const isPnlPositive = (pnlValue ?? 0) >= 0; - // ── Trades (filtered by period) ──────────────────────────────────────── + // ── Trades ───────────────────────────────────────────────────────────── - const trades = useMemo(() => { + const allTrades = useMemo( + () => positionParam?.trades ?? [], + [positionParam?.trades], + ); + + const chartTrades = useMemo(() => { const now = Date.now(); - return (positionParam?.trades ?? []).filter((t) => { + return allTrades.filter((t) => { const tsMs = t.timestamp > 0 && t.timestamp < 1e12 ? t.timestamp * 1000 : t.timestamp; return tsMs >= now - PERIOD_DURATION_MS[activeTimePeriod]; }); - }, [positionParam?.trades, activeTimePeriod]); + }, [allTrades, activeTimePeriod]); // ── Return ───────────────────────────────────────────────────────────── @@ -313,7 +318,8 @@ export function useTraderPositionData( pnlValue, pnlPercent, isPnlPositive, - trades, + allTrades, + chartTrades, activeTimePeriod, setActiveTimePeriod: setActiveTimePeriod as (period: TimePeriod) => void, timePeriods: TIME_PERIODS, diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx index 584cacd89a6f..549dae65a19b 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.test.tsx @@ -3,6 +3,7 @@ import { TransactionMeta, TransactionStatus, } from '@metamask/transaction-controller'; +import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import { strings } from '../../../../../../../locales/i18n'; import { selectBridgeHistoryForAccount } from '../../../../../../selectors/bridgeStatusController'; @@ -10,8 +11,16 @@ import { useBridgeTxHistoryData } from '../../../../../../util/bridge/hooks/useB import { useTokenAmount } from '../../../hooks/useTokenAmount'; import { useTransactionDetails } from '../../../hooks/activity/useTransactionDetails'; import { useFiatOrderStatus } from '../../../hooks/activity/useFiatOrderStatus'; +import Routes from '../../../../../../constants/navigation/Routes'; import { FiatOrderSummaryLine } from './fiat-order-summary-line'; +const mockNavigate = jest.fn(); +const mockGetParent = jest.fn(() => ({ navigate: mockNavigate })); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: jest.fn(), getParent: mockGetParent }), +})); jest.mock('../../../../../../selectors/bridgeStatusController'); jest.mock('../../../../../../util/bridge/hooks/useBridgeTxHistoryData'); jest.mock('../../../hooks/useTokenAmount'); @@ -52,6 +61,8 @@ describe('FiatOrderSummaryLine', () => { beforeEach(() => { jest.resetAllMocks(); + mockNavigate.mockClear(); + mockGetParent.mockReturnValue({ navigate: mockNavigate }); useFiatOrderStatusMock.mockReturnValue({ severity: 'success', @@ -147,4 +158,19 @@ describe('FiatOrderSummaryLine', () => { expect(queryByTestId('block-explorer-button')).toBeNull(); }); + + it('navigates to RampsOrderDetails inside TransactionsView when order details button is pressed', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('block-explorer-button')); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW, { + screen: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + orderId: '/providers/transak/orders/order-123', + showCloseButton: true, + }, + }); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx index aac3caa4bb81..1ae32d6b6409 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/fiat-order-summary-line.tsx @@ -42,9 +42,14 @@ export function FiatOrderSummaryLine({ const subtitle = formatSubtitle(parentTransaction.time, statusText); const handleOrderDetailsPress = useCallback(() => { - navigation.navigate(Routes.RAMP.RAMPS_ORDER_DETAILS, { - orderId: fiatOrderId, - showCloseButton: true, + // FiatOrderSummaryLine renders inside MoneyModalStack (a transparent modal + // nested navigator). useNavigation() returns the ModalStack navigator, which + // doesn't know about RAMPS_ORDER_DETAILS. We must escape to the root Stack + // navigator via getParent() to reach it. + const rootNav = navigation.getParent() ?? navigation; + rootNav.navigate(Routes.TRANSACTIONS_VIEW, { + screen: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { orderId: fiatOrderId, showCloseButton: true }, }); }, [navigation, fiatOrderId]); diff --git a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx index d6dd5d793587..621de65c719e 100644 --- a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx +++ b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.test.tsx @@ -71,16 +71,6 @@ describe('MoneyAccountDepositInfo', () => { expect(lastCall.supportAccountSelection).toBe(true); }); - it('passes hasMax=true to CustomAmountInfo', () => { - render(); - - const lastCall = - mockCustomAmountInfo.mock.calls[ - mockCustomAmountInfo.mock.calls.length - 1 - ][0]; - expect(lastCall.hasMax).toBe(true); - }); - it('passes autoSelectFiatPayment and hideAccountSelector from route params', () => { mockUseParams.mockReturnValue({ autoSelectFiatPayment: true }); diff --git a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx index 566064fa18d0..f0a05898b443 100644 --- a/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx +++ b/app/components/Views/confirmations/components/info/money-account-deposit-info/money-account-deposit-info.tsx @@ -17,7 +17,6 @@ export function MoneyAccountDepositInfo() { { it('renders money balance label without inline balance', () => { const { getByTestId, queryByTestId } = renderMoneyAccount(); - expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money balance'); + expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); expect(queryByTestId('pay-with-balance')).toBeNull(); }); @@ -357,7 +357,7 @@ describe('PayWithRow', () => { it('renders money account row regardless of isResultReady when paymentOverride is MoneyAccount', () => { const { getByTestId } = renderMoneyAccount({ isResultReady: true }); - expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money balance'); + expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); }); it('renders money account row for perps deposit', () => { @@ -369,7 +369,7 @@ describe('PayWithRow', () => { const { getByTestId } = renderMoneyAccount(); expect(getByTestId('pay-with')).toBeDefined(); - expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money balance'); + expect(getByTestId('pay-with-symbol')).toHaveTextContent('Money account'); }); }); diff --git a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx index e8774a591a95..588de9444224 100644 --- a/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx +++ b/app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.tsx @@ -327,7 +327,7 @@ function PayWithRowMoneyAccount() { color={TextColor.TextDefault} testID={TransactionPayComponentIDs.PAY_WITH_SYMBOL} > - {strings('confirm.pay_with_bottom_sheet.money_balance')} + {strings('confirm.pay_with_bottom_sheet.money_account')} { const expectedPreferred = { address: MUSD_TOKEN_ADDRESS, - chainId: CHAIN_IDS.MAINNET, + chainId: CHAIN_IDS.MONAD, }; expect(usePayWithPreferredTokenMock).toHaveBeenCalledWith({ preferredToken: expectedPreferred, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx index 11a6736ff1d1..6e1434fab73a 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.test.tsx @@ -28,7 +28,7 @@ jest.mock('../../../../../../../locales/i18n', () => ({ 'confirm.pay_with_bottom_sheet.available_balance': `${ params?.balance ?? '' } available`, - 'confirm.pay_with_bottom_sheet.money_balance': 'Money balance', + 'confirm.pay_with_bottom_sheet.money_account': 'Money account', }; return translations[key] ?? key; }, @@ -222,7 +222,7 @@ describe('usePayWithMoneyAccountSection', () => { expect(result.current?.rows[0]).toEqual( expect.objectContaining({ id: 'money-account-musd', - title: 'Money balance', + title: 'Money account', subtitle: '$100.00 available', isSelected: false, isLastUsed: false, diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx index 82dd5db3f406..1c9319e48540 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithMoneyAccountSection.tsx @@ -101,7 +101,7 @@ export function usePayWithMoneyAccountSection(): PayWithSectionConfig | null { source: MoneyIcon, style: styles.moneyIcon, }), - title: strings('confirm.pay_with_bottom_sheet.money_balance'), + title: strings('confirm.pay_with_bottom_sheet.money_account'), subtitle, isSelected: isMoneyAccountSelected, isLastUsed: false, diff --git a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts index 08a871f484c9..2792e24fe2bb 100644 --- a/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts +++ b/app/components/Views/confirmations/hooks/pay/usePayWithSections.test.ts @@ -55,8 +55,8 @@ const MONEY_ACCOUNT_SECTION_MOCK: PayWithSectionConfig = { rows: [ { id: 'money-account-musd', - icon: 'Money balance', - title: 'Money balance', + icon: 'Money account', + title: 'Money account', }, ], }; diff --git a/app/components/Views/confirmations/utils/transaction-pay.test.ts b/app/components/Views/confirmations/utils/transaction-pay.test.ts index 35201e13c70d..c39b432f386f 100644 --- a/app/components/Views/confirmations/utils/transaction-pay.test.ts +++ b/app/components/Views/confirmations/utils/transaction-pay.test.ts @@ -868,7 +868,7 @@ describe('Transaction Pay Utils', () => { expect(result).toBe(OVERRIDE); }); - it('returns the mUSD mainnet fallback for moneyAccountWithdraw transactions when no override is provided', () => { + it('returns the mUSD Monad fallback for moneyAccountWithdraw transactions when no override is provided', () => { const result = resolvePreferredPayToken({ override: undefined, transactionMeta: withdrawTransactionMeta, @@ -876,7 +876,7 @@ describe('Transaction Pay Utils', () => { expect(result).toStrictEqual({ address: MUSD_TOKEN_ADDRESS, - chainId: CHAIN_IDS.MAINNET, + chainId: CHAIN_IDS.MONAD, }); }); diff --git a/app/components/Views/confirmations/utils/transaction-pay.ts b/app/components/Views/confirmations/utils/transaction-pay.ts index 04582c6f2bee..cfa55c351b49 100644 --- a/app/components/Views/confirmations/utils/transaction-pay.ts +++ b/app/components/Views/confirmations/utils/transaction-pay.ts @@ -318,8 +318,8 @@ export function isMatchingPayToken( * Resolves the preferred pay token for a transaction. * * Returns the explicit override when provided. Otherwise, falls back to - * mUSD on mainnet for moneyAccountWithdraw transactions so the preferred-token - * row in the pay-with bottom sheet reflects the deposit-default asset. + * mUSD on Monad for moneyAccountWithdraw transactions so the preferred-token + * row in the pay-with bottom sheet reflects the withdraw-default asset. * */ export function resolvePreferredPayToken({ @@ -338,7 +338,7 @@ export function resolvePreferredPayToken({ ) { return { address: MUSD_TOKEN_ADDRESS, - chainId: CHAIN_IDS.MAINNET, + chainId: CHAIN_IDS.MONAD, }; } diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index 6e52d5677dec..82339272ff1e 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -276,6 +276,18 @@ export type RewardsControllerIsRewardsFeatureEnabledAction = { handler: RewardsController['isRewardsFeatureEnabled']; }; +/** + * Check if the VIP feature is enabled. + * VIP is a sub-feature of rewards, so it requires both the rewards feature + * and the dedicated VIP feature flag to be enabled. + * + * @returns boolean - True if the VIP feature is enabled, false otherwise + */ +export type RewardsControllerIsVipFeatureEnabledAction = { + type: `RewardsController:isVipFeatureEnabled`; + handler: RewardsController['isVipFeatureEnabled']; +}; + /** * Check if there is an active season. * Temporarily hardcoded to false while no season is configured. Callers @@ -864,6 +876,7 @@ export type RewardsControllerMethodActions = | RewardsControllerEstimatePointsAction | RewardsControllerAddPointsEstimateToHistoryAction | RewardsControllerIsRewardsFeatureEnabledAction + | RewardsControllerIsVipFeatureEnabledAction | RewardsControllerHasActiveSeasonAction | RewardsControllerGetSeasonMetadataAction | RewardsControllerGetSeasonStatusAction diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index c4a05c7bbb88..4e18fe724c38 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -3318,6 +3318,23 @@ describe('RewardsController', () => { expect(result).toBeNull(); }); + it('returns null when VIP is disabled via isVipDisabled callback', async () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + const result = await vipDisabledController.getPerpsDiscountForAccount( + CAIP_ACCOUNT_1, + 10, + ); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + it('returns null for accounts the controller has never seen (unhydrated)', async () => { const result = await controller.getPerpsDiscountForAccount( CAIP_ACCOUNT_2, @@ -3871,6 +3888,21 @@ describe('RewardsController', () => { expect(result).toBeNull(); }); + it('returns null when VIP is disabled via isVipDisabled callback', async () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + const result = + await vipDisabledController.getVipTierForAccount(CAIP_ACCOUNT_1); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + it('returns null for accounts the controller has never seen (unhydrated)', async () => { const result = await controller.getVipTierForAccount(CAIP_ACCOUNT_2); expect(result).toBeNull(); @@ -3923,6 +3955,51 @@ describe('RewardsController', () => { }); }); + describe('isVipFeatureEnabled', () => { + it('returns true when neither rewards nor VIP is disabled', () => { + const enabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => false, + }); + + expect(enabledController.isVipFeatureEnabled()).toBe(true); + }); + + it('returns false when VIP is disabled via isVipDisabled callback', () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + expect(vipDisabledController.isVipFeatureEnabled()).toBe(false); + }); + + it('returns false when rewards is disabled even if VIP is enabled', () => { + const rewardsDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + isVipDisabled: () => false, + }); + + expect(rewardsDisabledController.isVipFeatureEnabled()).toBe(false); + }); + + it('defaults to enabled when isVipDisabled is not provided', () => { + const defaultController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + }); + + expect(defaultController.isVipFeatureEnabled()).toBe(true); + }); + }); + describe('performSilentAuth', () => { let subscribeCallback: any; const mockInternalAccount: InternalAccount = { @@ -7040,6 +7117,24 @@ describe('RewardsController', () => { }); }); + it('returns null without fetching when VIP is disabled via isVipDisabled callback', async () => { + const vipDisabledController = new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => false, + isVipDisabled: () => true, + }); + + const result = + await vipDisabledController.getVIPDashboard(mockSubscriptionId); + + expect(result).toBeNull(); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'RewardsDataService:getVIPDashboard', + mockSubscriptionId, + ); + }); + it('persists fetched VIP dashboard to vipDashboard state', async () => { const apiDashboard = createMockVIPDashboard(); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 6bd3155c8f6e..a45133a23f35 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -552,6 +552,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'invalidateSubscriptionCache', 'isOptInSupported', 'isRewardsFeatureEnabled', + 'isVipFeatureEnabled', 'linkAccountsToSubscriptionCandidate', 'linkAccountToSubscriptionCandidate', 'logout', @@ -585,6 +586,7 @@ export class RewardsController extends BaseController< { payload: OndoGmCampaignParticipantOutcomeDto; lastFetched: number } > = new Map(); #isDisabled: () => boolean; + #isVipDisabled: () => boolean; #reauthPromises: Map> = new Map(); // Deduplicates concurrent /vip/fees fetches for the same subscriptionId. @@ -752,10 +754,12 @@ export class RewardsController extends BaseController< messenger, state, isDisabled, + isVipDisabled, }: { messenger: RewardsControllerMessenger; state?: Partial; isDisabled?: () => boolean; + isVipDisabled?: () => boolean; }) { super({ name: controllerName, @@ -768,6 +772,7 @@ export class RewardsController extends BaseController< }); this.#isDisabled = isDisabled ?? (() => false); + this.#isVipDisabled = isVipDisabled ?? (() => false); this.messenger.registerMethodActionHandlers( this, @@ -1798,8 +1803,7 @@ export class RewardsController extends BaseController< } async getVipTierForAccount(account: CaipAccountId): Promise { - const rewardsEnabled = this.isRewardsFeatureEnabled(); - if (!rewardsEnabled) return null; + if (!this.isVipFeatureEnabled()) return null; const subscriptionId = this.getActualSubscriptionId(account); if (!subscriptionId) return null; @@ -1841,8 +1845,7 @@ export class RewardsController extends BaseController< account: CaipAccountId, baseFeeBips: number, ): Promise { - const rewardsEnabled = this.isRewardsFeatureEnabled(); - if (!rewardsEnabled) return null; + if (!this.isVipFeatureEnabled()) return null; const vipDiscountBips = await this.#getVipPerpsDiscountBips( account, @@ -2319,6 +2322,18 @@ export class RewardsController extends BaseController< return true; } + /** + * Check if the VIP feature is enabled. + * VIP is a sub-feature of rewards, so it requires both the rewards feature + * and the dedicated VIP feature flag to be enabled. + * @returns boolean - True if the VIP feature is enabled, false otherwise + */ + isVipFeatureEnabled(): boolean { + if (!this.isRewardsFeatureEnabled()) return false; + if (this.#isVipDisabled()) return false; + return true; + } + /** * Check if there is an active season. * Temporarily hardcoded to false while no season is configured. Callers @@ -4490,6 +4505,8 @@ export class RewardsController extends BaseController< async getVIPDashboard( subscriptionId: string, ): Promise { + if (!this.isVipFeatureEnabled()) return null; + return await wrapWithCache({ key: subscriptionId, ttl: VIP_DASHBOARD_CACHE_THRESHOLD_MS, diff --git a/app/core/Engine/controllers/rewards-controller/index.test.ts b/app/core/Engine/controllers/rewards-controller/index.test.ts index 0166c1c552e1..f54273a83a1b 100644 --- a/app/core/Engine/controllers/rewards-controller/index.test.ts +++ b/app/core/Engine/controllers/rewards-controller/index.test.ts @@ -9,11 +9,13 @@ import { import { rewardsControllerInit } from '.'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import { isVersionGatedFeatureFlag } from '../../../../util/remoteFeatureFlag'; import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; jest.mock('./RewardsController'); jest.mock('../../../../selectors/settings'); +jest.mock('../../../../selectors/featureFlagController/vipProgram'); jest.mock('../../../../util/remoteFeatureFlag'); describe('rewardsControllerInit', () => { @@ -21,6 +23,7 @@ describe('rewardsControllerInit', () => { const selectBasicFunctionalityEnabledMock = jest.mocked( selectBasicFunctionalityEnabled, ); + const selectVipProgramEnabledMock = jest.mocked(selectVipProgramEnabled); const isVersionGatedFeatureFlagMock = jest.mocked(isVersionGatedFeatureFlag); let initRequestMock: jest.Mocked< @@ -72,6 +75,7 @@ describe('rewardsControllerInit', () => { // Default mock return values selectBasicFunctionalityEnabledMock.mockReturnValue(true); + selectVipProgramEnabledMock.mockReturnValue(true); isVersionGatedFeatureFlagMock.mockReturnValue(false); }); @@ -135,4 +139,29 @@ describe('rewardsControllerInit', () => { expect(isDisabledFn()).toBe(true); }); }); + + describe('isVipDisabled function', () => { + it('returns false when the VIP program is enabled', () => { + selectVipProgramEnabledMock.mockReturnValue(true); + + rewardsControllerInit(initRequestMock); + + const constructorArgs = rewardsControllerClassMock.mock.calls[0][0]; + const isVipDisabledFn = constructorArgs.isVipDisabled as () => boolean; + expect(isVipDisabledFn()).toBe(false); + expect(selectVipProgramEnabledMock).toHaveBeenCalledWith( + initRequestMock.getState(), + ); + }); + + it('returns true when the VIP program is disabled', () => { + selectVipProgramEnabledMock.mockReturnValue(false); + + rewardsControllerInit(initRequestMock); + + const constructorArgs = rewardsControllerClassMock.mock.calls[0][0]; + const isVipDisabledFn = constructorArgs.isVipDisabled as () => boolean; + expect(isVipDisabledFn()).toBe(true); + }); + }); }); diff --git a/app/core/Engine/controllers/rewards-controller/index.ts b/app/core/Engine/controllers/rewards-controller/index.ts index e33ebaf0f3b5..171dacfc4cb4 100644 --- a/app/core/Engine/controllers/rewards-controller/index.ts +++ b/app/core/Engine/controllers/rewards-controller/index.ts @@ -1,4 +1,5 @@ import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; +import { selectVipProgramEnabled } from '../../../../selectors/featureFlagController/vipProgram'; import type { MessengerClientInitFunction } from '../../types'; import { RewardsController, @@ -27,6 +28,10 @@ export const rewardsControllerInit: MessengerClientInitFunction< const isEnabled = selectBasicFunctionalityEnabled(getState()); return !isEnabled; }, + isVipDisabled: () => { + const isVipEnabled = selectVipProgramEnabled(getState()); + return !isVipEnabled; + }, }); return { controller }; @@ -80,6 +85,7 @@ export type { RewardsControllerInvalidateSubscriptionCacheAction, RewardsControllerIsOptInSupportedAction, RewardsControllerIsRewardsFeatureEnabledAction, + RewardsControllerIsVipFeatureEnabledAction, RewardsControllerLinkAccountsToSubscriptionCandidateAction, RewardsControllerLinkAccountToSubscriptionCandidateAction, RewardsControllerLogoutAction, diff --git a/docs/perps/perps-metametrics-reference.md b/docs/perps/perps-metametrics-reference.md index 9c778b5bf0e1..2c2268d1d23f 100644 --- a/docs/perps/perps-metametrics-reference.md +++ b/docs/perps/perps-metametrics-reference.md @@ -2,7 +2,7 @@ ## Overview -MetaMetrics uses **8 consolidated events** with discriminating properties (vs 38+ Sentry traces). Optimizes Segment costs by grouping similar actions into generic events with type properties. +MetaMetrics uses **9 consolidated events** with discriminating properties (vs 38+ Sentry traces). Optimizes Segment costs by grouping similar actions into generic events with type properties. **Example:** `PERPS_SCREEN_VIEWED` with `screen_type: 'trading' | 'withdrawal' | ...` instead of 9 separate screen events. @@ -61,7 +61,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { }); ``` -## 8 Events +## 9 Events ### 1. PERPS_SCREEN_VIEWED @@ -74,7 +74,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **Deposit screens:** `'deposit_input'` | `'deposit_review'` - **Market list screens:** `'market_list'` | `'market_list_all'` | `'market_list_crypto'` | `'market_list_stocks'` - **Position management:** `'close_all_positions'` | `'cancel_all_orders'` | `'increase_exposure'` | `'add_margin'` | `'remove_margin'` - - **Other screens:** `'pnl_hero_card'` | `'order_book'` | `'full_screen_chart'` | `'activity'` | `'geo_block_notif'` | `'compliance_block_notif'` + - **Other screens:** `'pnl_hero_card'` | `'order_book'` | `'full_screen_chart'` | `'activity'` | `'geo_block_notif'` | `'compliance_block_notif'` | `'cancel_trade_with_token_toast'` - `asset` (optional): Asset symbol (e.g., `'BTC'`, `'ETH'`) - `direction` (optional): `'long' | 'short'` - `source` (optional): Where user came from @@ -84,8 +84,8 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **Market list sources:** `'perps_market_list_all'` | `'perps_market_list_crypto'` | `'perps_market_list_stocks'` - **Trade/Position sources:** `'trade_screen'` | `'position_screen'` | `'tp_sl_view'` | `'trade_menu_action'` | `'open_position'` | `'trade_details'` - **Explore source:** `'explore'` (from Explore/Trending page) - - **Other sources:** `'tutorial'` | `'perps_tutorial'` | `'close_toast'` | `'position_close_toast'` | `'tooltip'` | `'magnifying_glass'` | `'crypto_button'` | `'stocks_button'` | `'order_book'` | `'full_screen_chart'` | `'stop_loss_prompt_banner'` | `'wallet_home'` | `'wallet_main_action_menu'` | `'homescreen_tab'` | `'perps_asset_screen_no_funds'` - - **Geo-block sources:** `'deposit_button'` | `'withdraw_button'` | `'trade_action'` | `'add_funds_action'` | `'cancel_order'` | `'asset_detail_screen'` | `'close_position_action'` | `'modify_position_action'` | `'order_book_long_button'` | `'order_book_short_button'` | `'order_book_close_button'` | `'order_book_modify_button'` | `'auto_close_action'` | `'adjust_margin_action'` | `'stop_loss_prompt_add_margin'` | `'stop_loss_prompt_set_sl'` | `'close_all_positions_button'` + - **Other sources:** `'tutorial'` | `'perps_tutorial'` | `'close_toast'` | `'position_close_toast'` | `'tooltip'` | `'magnifying_glass'` | `'crypto_button'` | `'stocks_button'` | `'order_book'` | `'full_screen_chart'` | `'stop_loss_prompt_banner'` | `'wallet_home'` | `'wallet_main_action_menu'` | `'homescreen_tab'` | `'perps_asset_screen_no_funds'` | `'home_section'` | `'market_insights'` + - **Geo-block sources:** `'deposit_button'` | `'withdraw_button'` | `'trade_action'` | `'add_funds_action'` | `'cancel_order'` | `'asset_detail_screen'` | `'close_position_action'` | `'modify_position_action'` | `'order_book_long_button'` | `'order_book_short_button'` | `'order_book_close_button'` | `'order_book_modify_button'` | `'auto_close_action'` | `'adjust_margin_action'` | `'stop_loss_prompt_add_margin'` | `'stop_loss_prompt_set_sl'` | `'close_all_positions_button'` | `'cancel_all_orders_button'` - `open_position` (optional): Number of open positions (used for homepage_perps_tab, perps_home, asset_details, order_book, trading, close_all_positions screens; number) - `open_order` (optional): Number of open orders (used for wallet_home_perps_tab, perps_home, asset_details screens; number) - `market_category` (optional): Currently active market filter tab (e.g., `'All'`, `'Crypto'`, `'Stocks'`, `'Commodities'`, `'Forex'`; used for market_list screen) @@ -98,6 +98,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `button_clicked` (optional): Button that led to this screen (entry point tracking, see [Entry Point Tracking](#entry-point-tracking)) - `button_location` (optional): Location of the button clicked (entry point tracking, see [Entry Point Tracking](#entry-point-tracking)) - `outage_banner_shown` (optional): Whether the service interruption banner is displayed (boolean, used for perps_home, asset_details, trading screens) +- `market_insights_displayed` (optional): Whether market insights content is displayed on the screen (boolean, used for asset_details screen) - `ab_test_button_color` (optional): Button color test variant (`'control' | 'monochrome'`), only included when test is enabled (for baseline exposure tracking) - Future AB tests: `ab_test_{test_name}` (see [Multiple Concurrent Tests](#multiple-concurrent-tests)) @@ -114,8 +115,9 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - **Watchlist interactions:** `'favorite_toggled'` (add/remove from watchlist) - **Position management:** `'add_margin'` | `'remove_margin'` | `'increase_exposure'` | `'reduce_exposure'` | `'flip_position'` | `'contact_support'` | `'stop_loss_one_click_prompt'` - **Hero card interactions:** `'display_hero_card'` | `'share_pnl_hero_card'` - - **Pay-with interactions:** `'payment_token_selector'` | `'payment_method_changed'` -- `action` (optional): Specific action performed: `'connection_retry'` | `'share'` | `'add_margin'` | `'remove_margin'` | `'edit_tp_sl'` | `'create_tp_sl'` | `'create_position'` | `'increase_exposure'` | `'flip_long_to_short'` | `'flip_short_to_long'` + - **Pay-with interactions:** `'payment_token_selector'` | `'payment_method_changed'` | `'cancel_trade_with_token'` + - **Slippage interactions:** `'slippage_config_opened'` | `'slippage_config_changed'` | `'slippage_limit_blocked_order'` +- `action` (optional): Specific action performed: `'connection_retry'` | `'connection_go_back'` | `'share'` | `'add_margin'` | `'remove_margin'` | `'edit_tp_sl'` | `'create_tp_sl'` | `'create_position'` | `'increase_exposure'` | `'flip_long_to_short'` | `'flip_short_to_long'` - `attempt_number` (optional): Retry attempt number when action is 'connection_retry' (number) - `action_type` (optional): `'start_trading'` | `'skip'` | `'stop_loss_set'` | `'take_profit_set'` | `'adl_learn_more'` | `'learn_more'` | `'favorite_market'` | `'unfavorite_market'` (Note: `favorite_market` = add to watchlist, `unfavorite_market` = remove from watchlist) - `asset` (optional): Asset symbol (e.g., `'BTC'`, `'ETH'`) @@ -123,7 +125,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `order_size` (optional): Size of the order in tokens (number) - `leverage_used` (optional): Leverage value being used (number) - `order_type` (optional): `'market' | 'limit'` -- `setting_type` (optional): Type of setting changed (e.g., `'leverage'`) +- `setting_type` (optional): Type of setting changed: `'leverage'` | `'slippage'` - `input_method` (optional): How value was entered: `'slider' | 'keyboard' | 'preset' | 'manual' | 'percentage_button'` - `candle_period` (optional): Selected candle period - `favorites_count` (optional): Total number of markets in watchlist after toggle (number, used with `favorite_toggled`) @@ -131,6 +133,11 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { - `button_location` (optional): Location of the button for entry point tracking (see [Entry Point Tracking](#entry-point-tracking)): `'perps_home'` | `'perps_tutorial'` | `'perps_home_empty_state'` | `'perps_asset_screen'` | `'perps_tab'` | `'trade_menu_action'` | `'wallet_home'` | `'market_list'` | `'screen'` | `'tooltip'` | `'perp_market_details'` | `'order_book'` | `'full_screen_chart'` - `initial_payment_method` (optional): Payment method before change (e.g. `'perps_balance'` or token symbol; used with `payment_method_changed`) - `new_payment_method` (optional): Payment method after change (e.g. `'perps_balance'` or token symbol; used with `payment_method_changed`) +- `max_slippage_pct` (optional): Current max slippage percentage (number, used with slippage interactions) +- `max_slippage_source` (optional): How the slippage value was set: `'default' | 'user_configured'` (used with slippage interactions) +- `estimated_slippage_pct` (optional): Estimated slippage percentage (number, used with `slippage_limit_blocked_order`) +- `section_viewed` (optional): Home section scrolled into view (e.g., `'perps_home_explore_crypto'`, `'perps_home_explore_stocks'`, `'perps_home_activity'`; used with `slide` interaction) +- `location` (optional): Location context for scroll tracking (e.g., `'perps_home'`) - `source` (optional): Source context for favorites (e.g., `'perp_asset_screen'`) - `tab_name` (optional): Tab being viewed (e.g., `'trades'` | `'orders'` | `'funding'` | `'deposits'`) - `screen_name` (optional): Screen name context (e.g., `'connection_error'` | `'perps_hero_card'` | `'perps_activity_history'`) @@ -252,9 +259,36 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { **Note:** This event is used for both errors (with `error_type` + `error_message`) and warnings (with `warning_message`). Use `PERPS_EVENT_VALUE.ERROR_MESSAGE_KEY` for standardized error message keys (e.g., `insufficient_balance`, `order_failed`, `geo_restriction`). +### 9. PERPS_ACCOUNT_SETUP + +Tracks unified account (HIP-3) migration lifecycle on HyperLiquid. Fired from the controller's `HyperLiquidProvider` during account abstraction mode transitions. Not used directly from UI components. + +**Properties:** + +- `status` (required): Migration outcome + - `'not_applicable'` - Wallet has no HyperLiquid account yet (nothing to migrate) + - `'already_enabled'` - Account is already in a compatible mode (`unifiedAccount` or `portfolioMargin`) + - `'migration_required'` - Account needs migration from a legacy mode + - `'success'` - Migration completed successfully + - `'failed'` - Migration failed (user rejected signature or network error) +- `abstraction_mode` (required): Current or target account abstraction mode (string, e.g., `'unifiedAccount'`, `'dexAbstraction'`, `'default'`, `'disabled'`) +- `previous_abstraction_mode` (optional): Account mode before migration attempt (string, included on success/failure) +- `error_message` (optional): Error description when status is `'failed'` or `'not_applicable'` (e.g., `'no_hl_account'`) + +**Usage (controller-side only):** + +```typescript +// Inside HyperLiquidProvider (via trackPerpsEvent) +this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, { + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUCCESS, + [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode, + [PERPS_EVENT_PROPERTY.ABSTRACTION_MODE]: 'unifiedAccount', +}); +``` + ## Quick Reference -> **Note:** In code, property names and values are accessed via `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` from `@metamask/perps-controller` (defined in `app/controllers/perps/constants/eventNames.ts`). The string values shown in the event sections above are what actually gets sent to Segment. +> **Note:** In code, property names and values are accessed via `PERPS_EVENT_PROPERTY` and `PERPS_EVENT_VALUE` from `@metamask/perps-controller` (source: `packages/perps-controller/src/constants/eventNames.ts` in the [MetaMask/core](https://github.com/MetaMask/core) monorepo). The string values shown in the event sections above are what actually gets sent to Segment. ## Adding Events @@ -446,24 +480,24 @@ Entry point tracking captures how users navigate to screens, enabling analysis o ### Button Clicked Values -| Value | Description | -| ----------------------------- | --------------------------------------------------------- | -| `'deposit'` | Add funds / deposit button | -| `'withdraw'` | Withdraw funds button | -| `'perps_home'` | Navigate to perps home button | -| `'tutorial'` | Learn more / tutorial button | -| `'tooltip'` | Got it button in tooltip bottom sheets | -| `'market_list'` | Market list navigation button | -| `'open_position'` | Tap on a position card | -| `'magnifying_glass'` | Search icon button | -| `'crypto'` | Crypto tab in market list | -| `'stocks'` | Stocks & Commodities tab in market list | -| `'commodities'` | Commodities tab | -| `'forex'` | Forex tab | -| `'new'` | New markets | -| `'give_feedback'` | Give feedback button | -| `'competition_banner_engage'` | User tapped the competition banner to navigate to rewards | -| `'competition_banner_close'` | User dismissed the competition banner | +| Value | Description | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `'deposit'` | Add funds / deposit button | +| `'withdraw'` | Withdraw funds button | +| `'perps_home'` | Navigate to perps home button | +| `'tutorial'` | Learn more / tutorial button | +| `'tooltip'` | Got it button in tooltip bottom sheets | +| `'market_list'` | Market list navigation button | +| `'open_position'` | Tap on a position card | +| `'magnifying_glass'` | Search icon button | +| `'crypto'` | Crypto tab in market list | +| `'stocks'` | Stocks & Commodities tab in market list | +| `'commodities'` | Commodities tab | +| `'forex'` | Forex tab | +| `'new'` | New markets | +| `'give_feedback'` | Give feedback button | +| `'competition_banner_engage'` | User tapped the competition banner to navigate to rewards _(local constant, pending addition to `PERPS_EVENT_VALUE`)_ | +| `'competition_banner_close'` | User dismissed the competition banner _(local constant, pending addition to `PERPS_EVENT_VALUE`)_ | ### Button Location Values @@ -622,7 +656,7 @@ const showAccessRestrictedModal = useCallback(() => { | Sentry | MetaMetrics | | --------------------- | ----------------------- | -| 38+ traces | 8 events | +| 38+ traces | 9 events | | Performance | Behavior | | Technical metrics | Business metrics | | `usePerpsMeasurement` | `usePerpsEventTracking` | @@ -631,7 +665,7 @@ const showAccessRestrictedModal = useCallback(() => { - **Event Tracking Hook**: `app/components/UI/Perps/hooks/usePerpsEventTracking.ts` - **Events**: `app/core/Analytics/MetaMetrics.events.ts` -- **Properties & Values**: `app/controllers/perps/constants/eventNames.ts` (exported via `@metamask/perps-controller` as `PERPS_EVENT_PROPERTY`, `PERPS_EVENT_VALUE`) +- **Properties & Values**: Exported from `@metamask/perps-controller` as `PERPS_EVENT_PROPERTY`, `PERPS_EVENT_VALUE` (source: `packages/perps-controller/src/constants/eventNames.ts` in the [MetaMask/core](https://github.com/MetaMask/core) monorepo) - **Metrics Adapter**: `app/components/UI/Perps/adapters/mobileInfrastructure.ts` (maps `trackPerpsEvent` to MetaMetrics) -- **Controller**: `app/controllers/perps/PerpsController.ts` -- **Trading Service**: `app/controllers/perps/services/TradingService.ts` +- **Controller**: `@metamask/perps-controller` (`PerpsController`, `HyperLiquidProvider`, `TradingService`, `AccountService`) +- **Asset Viewed Funnel**: `app/core/Analytics/trade-transaction-funnel/assetViewedAnalytics.ts` (parallel `ASSET_VIEWED` emission for Perps) diff --git a/locales/languages/de.json b/locales/languages/de.json index 3c3fd64b52df..24cae42bbd2c 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Handeln Sie {{notionalRemaining}} mehr an Nominalvolumen, um berechtigt zu sein.", "stats_error_title": "Statistiken konnten nicht geladen werden", "stats_error_description": "Wir hatten ein Problem beim Laden Ihrer Statistik. Bitte versuchen Sie es erneut.", - "stats_retry": "Erneut versuchen", - "completed_label_total_participants": "Gesamteilnehmer", - "completed_label_total_volume": "Gesamtvolumen", - "completed_label_top_pnl": "Top-GuV", - "completed_label_winners": "Gewinner" + "stats_retry": "Erneut versuchen" }, "campaigns_preview": { "title": "Kampagnen", diff --git a/locales/languages/el.json b/locales/languages/el.json index 2b7fd5fad708..44e2a4dab8b2 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Κάντε συναλλαγές επιπλέον {{notionalRemaining}} σε ονομαστικό όγκο για να πληροίτε τα κριτήρια.", "stats_error_title": "Δεν ήταν δυνατή η φόρτωση των στατιστικών", "stats_error_description": "Παρουσιάστηκε πρόβλημα κατά τη φόρτωση των στατιστικών σας. Προσπαθήστε ξανά.", - "stats_retry": "Επανάληψη", - "completed_label_total_participants": "Συνολικοί συμμετέχοντες", - "completed_label_total_volume": "Συνολικός όγκος συναλλαγών", - "completed_label_top_pnl": "Κορυφαία Κ&Ζ", - "completed_label_winners": "Νικητές" + "stats_retry": "Επανάληψη" }, "campaigns_preview": { "title": "Καμπάνιες", diff --git a/locales/languages/en.json b/locales/languages/en.json index 7f57b5a59112..78185653a368 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1094,7 +1094,7 @@ "buy": "Buy", "bought": "{{name}} bought", "sold": "{{name}} sold", - "no_trades": "No trades for this interval", + "no_trades": "No trades yet", "closed_position": "Closed position", "fallback_title": "Position not available", "fallback_subtitle": "We couldn't load this position right now.", @@ -6911,7 +6911,7 @@ }, "action": { "add": "Add", - "transfer": "Transfer", + "transfer": "Send", "card": "Card" }, "earnings": { @@ -6992,13 +6992,10 @@ "add_money_sheet": { "title": "Add funds", "convert_crypto": "Convert crypto", - "convert_crypto_description": "From any account", - "deposit_funds": "Deposit funds", - "deposit_funds_description": "From debit card or bank", - "move_musd": "Add your {{amount}} mUSD", + "deposit_funds": "Debit card or bank", + "move_musd": "Your {{amount}} mUSD", "add_musd": "Add mUSD", - "move_musd_description": "From your balance", - "receive_external": "Receive from external wallet", + "receive_external": "External wallet", "coming_soon": "Coming soon" }, "more_sheet": { @@ -7008,12 +7005,12 @@ "contact_support": "Contact support" }, "transfer_sheet": { - "title": "Transfer funds to", + "title": "Send funds to", "between_accounts": "Another account", "perps_account": "Perps account", "predictions_account": "Predictions account", - "send_external": "Send to external address", - "withdraw_to_bank": "Withdraw to bank" + "send_external": "External address", + "withdraw_to_bank": "Bank" }, "toasts": { "in_progress_title": "Transaction in progress", @@ -7397,7 +7394,7 @@ "other_assets": "Other assets", "other_assets_description": "Select from your tokens", "other_assets_receive_description": "Select the token you want to receive", - "money_balance": "Money balance" + "money_account": "Money account" }, "staking_footer": { "part1": "By continuing, you agree to our ", @@ -9086,6 +9083,7 @@ "total_participants": "Total participants", "total_volume": "Total volume", "top_return": "Top return", + "top_pnl": "Top PnL", "total_winners": "Total winners" }, "campaign_prize_pool": { @@ -9215,11 +9213,7 @@ "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", "stats_error_title": "Unable to load stats", "stats_error_description": "We had a problem loading your stats. Please try again.", - "stats_retry": "Retry", - "completed_label_total_participants": "Total participants", - "completed_label_total_volume": "Total volume", - "completed_label_top_pnl": "Top PnL", - "completed_label_winners": "Winners" + "stats_retry": "Retry" }, "predict_the_pitch_campaign": { "title": "Predict The Pitch", diff --git a/locales/languages/es.json b/locales/languages/es.json index ef5903338002..cf3d0c9f6226 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Opera {{notionalRemaining}} más en volumen nocional para poder participar.", "stats_error_title": "No se pueden cargar las estadísticas", "stats_error_description": "Hubo un problema al cargar tus estadísticas. Inténtalo de nuevo.", - "stats_retry": "Reintentar", - "completed_label_total_participants": "Participantes totales", - "completed_label_total_volume": "Volumen total", - "completed_label_top_pnl": "Mejor P&L", - "completed_label_winners": "Ganadores" + "stats_retry": "Reintentar" }, "campaigns_preview": { "title": "Campañas", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index c13d5ce2f299..d35c7b31959c 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8901,11 +8901,7 @@ "stats_qualify_for_rank_description": "Tradez {{notionalRemaining}} de plus en volume notionnel pour devenir admissible.", "stats_error_title": "Impossible de charger les statistiques", "stats_error_description": "Nous avons rencontré un problème lors du chargement de vos statistiques. Veuillez réessayer.", - "stats_retry": "Réessayer", - "completed_label_total_participants": "Nombre total de participants", - "completed_label_total_volume": "Volume total", - "completed_label_top_pnl": "Meilleur résultat net", - "completed_label_winners": "Gagnants" + "stats_retry": "Réessayer" }, "campaigns_preview": { "title": "Campagnes", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 9954d05ef4c0..1d6d19f44ddf 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "योग्य बनने के लिए नोशनल वॉल्यूम में {{notionalRemaining}} और ट्रेड करें।", "stats_error_title": "स्टैट्स लोड नहीं हो पा रहे हैं", "stats_error_description": "हमें आपके स्टैट्स लोड करने में दिक्कत हुई। कृपया फिर से कोशिश करें।", - "stats_retry": "फिर से प्रयास करें", - "completed_label_total_participants": "कुल प्रतिभागी", - "completed_label_total_volume": "कुल वॉल्यूम", - "completed_label_top_pnl": "शीर्ष PnL", - "completed_label_winners": "विनर" + "stats_retry": "फिर से प्रयास करें" }, "campaigns_preview": { "title": "कैंपेन", diff --git a/locales/languages/id.json b/locales/languages/id.json index c765e891c298..745a8ae7bb3b 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Lakukan perdagangan {{notionalRemaining}} lagi dalam volume notional untuk memenuhi syarat.", "stats_error_title": "Tidak dapat memuat statistik", "stats_error_description": "Terjadi masalah saat memuat statistik. Coba lagi.", - "stats_retry": "Coba lagi", - "completed_label_total_participants": "Total peserta", - "completed_label_total_volume": "Volume total", - "completed_label_top_pnl": "PnL Tertinggi", - "completed_label_winners": "Pemenang" + "stats_retry": "Coba lagi" }, "campaigns_preview": { "title": "Kampanye", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 296fe1c8c9b5..cce8e2f46042 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "名目取引量があと{{notionalRemaining}}で条件が満たされます。", "stats_error_title": "統計を読み込めません", "stats_error_description": "統計の読み込み中に問題が発生しました。もう一度お試しください。", - "stats_retry": "再試行", - "completed_label_total_participants": "参加総数", - "completed_label_total_volume": "総取引量", - "completed_label_top_pnl": "最高損益", - "completed_label_winners": "入賞者" + "stats_retry": "再試行" }, "campaigns_preview": { "title": "キャンペーン", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index d2d3b34ec921..c250f397cfb1 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "참가 자격을 획득하려면 명목 거래량 기준으로 {{notionalRemaining}}만큼 더 거래해야 합니다.", "stats_error_title": "통계를 불러올 수 없음", "stats_error_description": "통계를 불러오는 중에 문제가 발생했습니다. 다시 시도하세요.", - "stats_retry": "다시 시도", - "completed_label_total_participants": "총 참가자 수", - "completed_label_total_volume": "총 거래량", - "completed_label_top_pnl": "손익 상위", - "completed_label_winners": "수상자" + "stats_retry": "다시 시도" }, "campaigns_preview": { "title": "캠페인", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 19978e381230..4295260b661d 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Negocie mais {{notionalRemaining}} em volume nocional para se qualificar.", "stats_error_title": "Não foi possível carregar estatísticas", "stats_error_description": "Tivemos um problema ao carregar suas estatísticas. Tente novamente.", - "stats_retry": "Tentar novamente", - "completed_label_total_participants": "Total de participantes", - "completed_label_total_volume": "Valor total", - "completed_label_top_pnl": "Melhor PnL", - "completed_label_winners": "Ganhadores" + "stats_retry": "Tentar novamente" }, "campaigns_preview": { "title": "Campanhas", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 59fde5147a23..3af27aafe870 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Наторгуйте еще {{notionalRemaining}} номинального объема, чтобы получить право на участие.", "stats_error_title": "Не удалось загрузить статистику", "stats_error_description": "У нас возникла проблема с загрузкой вашей статистики. Пожалуйста, попробуйте еще раз.", - "stats_retry": "Повтор", - "completed_label_total_participants": "Всего участников", - "completed_label_total_volume": "Общий объем", - "completed_label_top_pnl": "Лучший показатель П/У", - "completed_label_winners": "Победители" + "stats_retry": "Повтор" }, "campaigns_preview": { "title": "Кампании", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index a21330226c7e..46df25de030a 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Mag-trade ng {{notionalRemaining}} pang notional na dami para maging kwalipikado.", "stats_error_title": "Hindi nai-load ang mga stat", "stats_error_description": "Nagkaproblema kami sa pag-load ng mga stat mo. Subukan ulit.", - "stats_retry": "Subukang muli", - "completed_label_total_participants": "Kabuuang bilang ng mga kalahok", - "completed_label_total_volume": "Kabuuang dami", - "completed_label_top_pnl": "Nangungunang PnL", - "completed_label_winners": "Mga nanalo" + "stats_retry": "Subukang muli" }, "campaigns_preview": { "title": "Mga campaign", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index d59ed2c3b67a..6469bbff26b5 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Uygunluk kazanmak için {{notionalRemaining}} daha nominal hacimli işlem yapın.", "stats_error_title": "İstatistikler yüklenemiyor", "stats_error_description": "İstatistikleriniz yüklenirken bir problem yaşadık. Lütfen tekrar deneyin.", - "stats_retry": "Tekrar Dene", - "completed_label_total_participants": "Toplam katılımcı", - "completed_label_total_volume": "Toplam hacim", - "completed_label_top_pnl": "Toplam Kazanç/Zarar", - "completed_label_winners": "Kazananlar" + "stats_retry": "Tekrar Dene" }, "campaigns_preview": { "title": "Kampanyalar", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 81295bc2f9c5..c487a147fb88 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "Giao dịch thêm {{notionalRemaining}} khối lượng danh nghĩa để đủ điều kiện tham gia.", "stats_error_title": "Không thể tải số liệu thống kê", "stats_error_description": "Chúng tôi đã gặp sự cố khi tải số liệu thống kê của bạn. Vui lòng thử lại.", - "stats_retry": "Thử lại", - "completed_label_total_participants": "Tổng số người tham gia", - "completed_label_total_volume": "Tổng khối lượng", - "completed_label_top_pnl": "Lãi/Lỗ cao nhất", - "completed_label_winners": "Người chiến thắng" + "stats_retry": "Thử lại" }, "campaigns_preview": { "title": "Chiến dịch", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index c205df459d94..192f91f86a1b 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8902,11 +8902,7 @@ "stats_qualify_for_rank_description": "还需完成{{notionalRemaining}}名义交易量即可获得资格。", "stats_error_title": "无法加载统计数据", "stats_error_description": "加载您的统计数据时遇到问题。请重试。", - "stats_retry": "重试", - "completed_label_total_participants": "总参与人数", - "completed_label_total_volume": "总交易量", - "completed_label_top_pnl": "最高盈亏(PnL)", - "completed_label_winners": "获胜者" + "stats_retry": "重试" }, "campaigns_preview": { "title": "活动", diff --git a/tests/api-mocking/mock-responses/feature-flags-mocks.ts b/tests/api-mocking/mock-responses/feature-flags-mocks.ts index 3a4221b167e1..7367345a830c 100644 --- a/tests/api-mocking/mock-responses/feature-flags-mocks.ts +++ b/tests/api-mocking/mock-responses/feature-flags-mocks.ts @@ -134,6 +134,13 @@ export const remoteFeatureFlagPredictEnabled = (enabled = true) => ({ }, }); +export const remoteFeatureFlagHomepageSectionsV1Enabled = (enabled = true) => ({ + homepageSectionsV1: { + enabled, + minimumVersion: '0.0.0', + }, +}); + export const remoteFeatureFlagRampsUnifiedV1Enabled = (active = true) => ({ rampsUnifiedBuyV1: { active, diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 058d638486ee..ffa642495ae4 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -62,7 +62,7 @@ export interface FeatureFlagRegistryEntry { * Remote flag values are stored in the exact format returned by the production * client-config API, so they can be served directly by the E2E mock server. * - * Production defaults last synced: 2026-05-19 + * Production defaults last synced: 2026-06-02 * Source: https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=prod */ export const FEATURE_FLAG_REGISTRY: Record = { @@ -112,8 +112,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - enabled: false, - minimumVersion: '7.71.0', + enabled: true, + minimumVersion: '7.78.0', }, status: FeatureFlagStatus.Active, }, @@ -122,9 +122,22 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'socialAiTSA531AbtestWhatsHappeningExplore', type: FeatureFlagType.Remote, inProd: true, - productionDefault: { - enabled: false, - }, + productionDefault: [ + { + scope: { + value: 0.5, + type: 'percentage_rollout', + }, + name: 'control', + }, + { + scope: { + type: 'percentage_rollout', + value: 1, + }, + name: 'treatment', + }, + ], status: FeatureFlagStatus.Active, }, @@ -1470,95 +1483,96 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - slippage: 0.02, - relayQuoteUrl: 'https://intents.api.cx.metamask.io/relay/quote', - perpsWithdrawAnyToken: false, - allowedPredictWithdrawTokens: { - '0x38': [ - '0x0000000000000000000000000000000000000000', - '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - ], - '0x89': [ - '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - '0x0000000000000000000000000000000000000000', - '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619', - ], - '0x1': [ - '0x0000000000000000000000000000000000000000', - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - ], + relayExecuteUrl: 'https://intents.api.cx.metamask.io/relay/execute', + bufferSubsequent: 0.05, + payStrategies: { + relay: { + gaslessEnabled: true, + originGasOverhead: '500000', + pollingTimeout: 180000, + enabled: true, + }, + across: { + fallbackGas: { + max: 1500001, + estimate: 900001, + }, + apiBase: 'https://intents.api.cx.metamask.io/across', + enabled: false, + }, }, - strategyOrder: ['relay'], - stxDisabled: false, + relayQuoteUrl: 'https://intents.api.cx.metamask.io/relay/quote', bufferStep: 0.015, - attemptsMax: 4, slippageTokens: { '0x1': { - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 0.005, - '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': 0.005, - '0xacA92E438df0B2401fF60dA7E4337B687a2435DA': 0.005, '0xdAC17F958D2ee523a2206206994597C13D831ec7': 0.005, '0x0000000000000000000000000000000000000000': 0.005, '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599': 0.005, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 0.005, + '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2': 0.005, + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA': 0.005, }, '0x2105': { - '0x0000000000000000000000000000000000000000': 0.005, '0x4200000000000000000000000000000000000006': 0.005, '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913': 0.005, '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2': 0.005, + '0x0000000000000000000000000000000000000000': 0.005, }, '0x38': { + '0x55d398326f99059fF775485246999027B3197955': 0.005, '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d': 0.005, '0x0000000000000000000000000000000000000000': 0.005, '0x0555E30da8f98308EdB960aa94C0Db47230d2B9c': 0.005, '0x2170Ed0880ac9A755fd29B2688956BD959F933F8': 0.005, - '0x55d398326f99059fF775485246999027B3197955': 0.005, }, '0x89': { + '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174': 0.005, '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359': 0.005, '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619': 0.005, '0xc2132D05D31c914a87C6611C10748AEb04B58e8F': 0.005, '0x0000000000000000000000000000000000001010': 0.005, - '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174': 0.005, }, '0xa4b1': { + '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1': 0.005, '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9': 0.005, '0xaf88d065e77c8cC2239327C5EDb3A432268e5831': 0.005, '0x0000000000000000000000000000000000000000': 0.005, - '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1': 0.005, }, '0xe708': { - '0x176211869cA2b568f2A7D4EE941E073a821EE1ff': 0.005, '0xA219439258ca9da29E9Cc4cE5596924745e12B93': 0.005, '0xacA92E438df0B2401fF60dA7E4337B687a2435DA': 0.005, '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f': 0.005, '0x0000000000000000000000000000000000000000': 0.005, + '0x176211869cA2b568f2A7D4EE941E073a821EE1ff': 0.005, }, }, + stxDisabled: false, bufferInitial: 0.015, + relayDisabledGasStationChains: [], + slippage: 0.02, + allowedPredictWithdrawTokens: { + '0x89': [ + '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + '0x0000000000000000000000000000000000000000', + '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619', + ], + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ], + '0x38': [ + '0x0000000000000000000000000000000000000000', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + ], + }, relayFallbackGas: { - max: '1500001', estimate: '900001', - }, - relayDisabledGasStationChains: [], - relayExecuteUrl: 'https://intents.api.cx.metamask.io/relay/execute', - bufferSubsequent: 0.05, - payStrategies: { - relay: { - enabled: true, - gaslessEnabled: true, - originGasOverhead: '500000', - }, - across: { - apiBase: 'https://intents.api.cx.metamask.io/across', - enabled: false, - fallbackGas: { - estimate: 900001, - max: 1500001, - }, - }, + max: '1500001', }, predictWithdrawAnyToken: true, + attemptsMax: 4, + strategyOrder: ['relay'], + perpsWithdrawAnyToken: false, }, status: FeatureFlagStatus.Active, }, @@ -1569,14 +1583,23 @@ export const FEATURE_FLAG_REGISTRY: Record = { inProd: true, productionDefault: { versions: { - '7.70.0': { + '7.78.0': { default: { enabled: true, tokens: {}, }, overrides: { predictWithdraw: { + enabled: true, tokens: { + '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + ], '0x2105': [ '0x0000000000000000000000000000000000000000', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', @@ -1586,8 +1609,61 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0x55d398326f99059fF775485246999027B3197955', '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', ], - '0x89': ['0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'], + '0x89': [ + '0x0000000000000000000000000000000000001010', + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', + '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', + ], '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], + }, + }, + perpsWithdraw: { + enabled: true, + tokens: { + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + ], + '0x2105': [ + '0x0000000000000000000000000000000000000000', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + ], + '0x38': [ + '0x0000000000000000000000000000000000000000', + '0x55d398326f99059fF775485246999027B3197955', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + ], + '0x89': [ + '0x0000000000000000000000000000000000001010', + '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + ], + '0xa4b1': [ + '0x0000000000000000000000000000000000000000', + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + ], + '0xe708': [ + '0x0000000000000000000000000000000000000000', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + ], + }, + }, + }, + }, + '7.70.0': { + default: { + enabled: true, + tokens: {}, + }, + overrides: { + predictWithdraw: { + tokens: { '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], '0x1': [ '0x0000000000000000000000000000000000000000', @@ -1596,6 +1672,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', ], + '0x2105': [ + '0x0000000000000000000000000000000000000000', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + ], + '0x38': [ + '0x0000000000000000000000000000000000000000', + '0x55d398326f99059fF775485246999027B3197955', + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + ], + '0x89': ['0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'], + '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], }, enabled: true, }, @@ -1605,10 +1692,23 @@ export const FEATURE_FLAG_REGISTRY: Record = { }, }, '7.74.0': { + default: { + enabled: true, + tokens: {}, + }, overrides: { predictWithdraw: { enabled: true, tokens: { + '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], + '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], + '0x1': [ + '0x0000000000000000000000000000000000000000', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xdAC17F958D2ee523a2206206994597C13D831ec7', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + ], '0x2105': [ '0x0000000000000000000000000000000000000000', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', @@ -1619,8 +1719,15 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', ], '0x89': ['0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'], - '0xa4b1': ['0xaf88d065e77c8cC2239327C5EDb3A432268e5831'], - '0xe708': ['0xacA92E438df0B2401fF60dA7E4337B687a2435DA'], + }, + }, + perpsWithdraw: { + enabled: true, + tokens: { + '0xe708': [ + '0x0000000000000000000000000000000000000000', + '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + ], '0x1': [ '0x0000000000000000000000000000000000000000', '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', @@ -1628,10 +1735,6 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', ], - }, - }, - perpsWithdraw: { - tokens: { '0x2105': [ '0x0000000000000000000000000000000000000000', '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', @@ -1651,25 +1754,9 @@ export const FEATURE_FLAG_REGISTRY: Record = { '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', ], - '0xe708': [ - '0x0000000000000000000000000000000000000000', - '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', - ], - '0x1': [ - '0x0000000000000000000000000000000000000000', - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - '0xdAC17F958D2ee523a2206206994597C13D831ec7', - '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', - '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', - ], }, - enabled: true, }, }, - default: { - tokens: {}, - enabled: true, - }, }, }, }, @@ -3040,7 +3127,12 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'earnMoneyPaymentTokensBlocklist', type: FeatureFlagType.Remote, inProd: true, - productionDefault: {}, + productionDefault: { + '0x1': ['USDC', 'USDT', 'DAI', 'aUSDC', 'aUSDT', 'aDAI'], + '0x2105': ['USDC'], + '0x38': ['USDC', 'USDT'], + '0xa4b1': ['USDC'], + }, status: FeatureFlagStatus.Active, }, @@ -3048,7 +3140,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'earnMoneyTokensSortMode', type: FeatureFlagType.Remote, inProd: true, - productionDefault: 'fiatBalanceDesc', + productionDefault: 'noFeePriority', status: FeatureFlagStatus.Active, }, @@ -3222,8 +3314,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { enableFiatToggle: { name: 'enableFiatToggle', type: FeatureFlagType.Remote, - inProd: false, - productionDefault: false, + inProd: true, + productionDefault: true, status: FeatureFlagStatus.Active, }, @@ -3316,9 +3408,22 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'homeTMCU610AbtestWalletHomePostOnboardingSteps', type: FeatureFlagType.Remote, inProd: true, - productionDefault: { - enabled: false, - }, + productionDefault: [ + { + name: 'control', + scope: { + type: 'percentage_rollout', + value: 0, + }, + }, + { + name: 'postOnboardingSteps', + scope: { + type: 'percentage_rollout', + value: 1, + }, + }, + ], status: FeatureFlagStatus.Active, }, @@ -3335,8 +3440,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '7.64.0', - enabled: true, + enabled: false, + minimumVersion: '0.0.0', }, status: FeatureFlagStatus.Active, }, @@ -3380,11 +3485,11 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - boringVault: '0x', - chainId: '0x', - lensAddress: '0x', - tellerAddress: '0x', - accountantAddress: '0x', + chainId: '0xa4b1', + lensAddress: '0x846a7832022350434B5cC006d07cc9c782469660', + tellerAddress: '0x86821F179eaD9F0b3C79b2f8deF0227eEBFDc9f9', + accountantAddress: '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173', + boringVault: '0xB5F07d769dD60fE54c97dd53101181073DDf21b2', }, status: FeatureFlagStatus.Active, }, @@ -3517,8 +3622,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', enabled: false, + minimumVersion: '7.0.0', }, status: FeatureFlagStatus.Active, }, @@ -3747,7 +3852,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { inProd: true, productionDefault: { enabled: true, - minimumVersion: '0.0.0', + minimumVersion: '7.77.0', }, status: FeatureFlagStatus.Active, }, @@ -3758,18 +3863,18 @@ export const FEATURE_FLAG_REGISTRY: Record = { inProd: true, productionDefault: [ { - name: 'control', scope: { type: 'percentage_rollout', - value: 0.5, + value: 0, }, + name: 'control', }, { - name: 'treatment', scope: { type: 'percentage_rollout', value: 1, }, + name: 'treatment', }, ], status: FeatureFlagStatus.Active, @@ -3844,8 +3949,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', - enabled: false, + minimumVersion: '7.78.0', + enabled: true, }, status: FeatureFlagStatus.Active, }, @@ -3855,8 +3960,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', - enabled: false, + enabled: true, + minimumVersion: '7.79.0', }, status: FeatureFlagStatus.Active, }, @@ -3866,8 +3971,8 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - minimumVersion: '0.0.0', - enabled: false, + minimumVersion: '7.78.0', + enabled: true, }, status: FeatureFlagStatus.Active, }, @@ -4109,8 +4214,10 @@ export const FEATURE_FLAG_REGISTRY: Record = { socialAiAssetDetailsQuickBuy: { name: 'socialAiAssetDetailsQuickBuy', type: FeatureFlagType.Remote, - inProd: false, - productionDefault: false, + inProd: true, + productionDefault: { + enabled: false, + }, status: FeatureFlagStatus.Active, }, @@ -4301,7 +4408,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { vipProgramEnabled: { name: 'vipProgramEnabled', type: FeatureFlagType.Remote, - inProd: false, + inProd: true, productionDefault: { enabled: false, minimumVersion: '0.0.0', @@ -4498,9 +4605,22 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - payStrategies: { - relay: { - gaslessEnabled: true, + versions: { + '0.0.0': { + enableDepositWalletWithdraw: false, + payStrategies: { + relay: { + gaslessEnabled: true, + }, + }, + }, + '7.77.0': { + payStrategies: { + relay: { + gaslessEnabled: true, + }, + }, + enableDepositWalletWithdraw: true, }, }, }, @@ -4539,22 +4659,9 @@ export const FEATURE_FLAG_REGISTRY: Record = { name: 'homeTMCU470AbtestTrendingSections', type: FeatureFlagType.Remote, inProd: true, - productionDefault: [ - { - scope: { - value: 0.9, - type: 'percentage_rollout', - }, - name: 'control', - }, - { - scope: { - value: 1, - type: 'percentage_rollout', - }, - name: 'trendingSections', - }, - ], + productionDefault: { + enabled: false, + }, status: FeatureFlagStatus.Active, }, @@ -4825,6 +4932,79 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + explorePageV2Enabled: { + name: 'explorePageV2Enabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: true, + status: FeatureFlagStatus.Active, + }, + + exploreSearchV2: { + name: 'exploreSearchV2', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + minimumVersion: '7.79.0', + enabled: true, + }, + status: FeatureFlagStatus.Active, + }, + + exploreSectionsOrder: { + name: 'exploreSectionsOrder', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + home: ['predictions', 'tokens', 'perps', 'stocks', 'sites'], + quickActions: ['tokens', 'perps', 'stocks', 'predictions', 'sites'], + search: ['tokens', 'perps', 'stocks', 'predictions', 'sites'], + }, + status: FeatureFlagStatus.Active, + }, + + homeTMCU828AbtestOnboardingChecklistStepper: { + name: 'homeTMCU828AbtestOnboardingChecklistStepper', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + + moneyHomeScreenEnabled: { + name: 'moneyHomeScreenEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + minimumVersion: '0.0.0', + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + + predictHomeRedesign: { + name: 'predictHomeRedesign', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + + predictPortfolio: { + name: 'predictPortfolio', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + minimumVersion: '0.0.0', + enabled: false, + }, + status: FeatureFlagStatus.Active, + }, + smartTransactionsAllowedRpcHosts: { name: 'smartTransactionsAllowedRpcHosts', type: FeatureFlagType.Remote, @@ -4833,6 +5013,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + swapsSWAPS4543AbtestPostTradeModal: { + name: 'swapsSWAPS4543AbtestPostTradeModal', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: [], + status: FeatureFlagStatus.Active, + }, + prePushPromptEnabled: { name: 'prePushPromptEnabled', type: FeatureFlagType.Remote, diff --git a/tests/page-objects/Perps/PerpsView.ts b/tests/page-objects/Perps/PerpsView.ts index d1d2027a212c..b098c79e0562 100644 --- a/tests/page-objects/Perps/PerpsView.ts +++ b/tests/page-objects/Perps/PerpsView.ts @@ -289,11 +289,35 @@ class PerpsView { } async tapClosePositionButton() { - await Gestures.waitAndTap(this.closePositionButton, { - elemDescription: 'Close position button', - checkStability: true, - timeout: 15000, - }); + await Utilities.waitUntil( + async () => { + if ( + await Utilities.isElementVisible( + this.closePositionBottomSheetButton, + 400, + ) + ) { + return true; + } + + if (await Utilities.isElementVisible(this.closePositionButton, 400)) { + await Gestures.waitAndTap(this.closePositionButton, { + elemDescription: 'Close position button', + checkStability: true, + timeout: 5000, + }); + } + + return Utilities.isElementVisible( + this.closePositionBottomSheetButton, + 800, + ); + }, + { + interval: 500, + timeout: 15000, + }, + ); } async tapConfirmClosePositionButton() { diff --git a/tests/smoke/predict/predict-cash-out.spec.ts b/tests/smoke/predict/predict-cash-out.spec.ts index f73194cba860..b0f10dec1b6a 100644 --- a/tests/smoke/predict/predict-cash-out.spec.ts +++ b/tests/smoke/predict/predict-cash-out.spec.ts @@ -40,6 +40,15 @@ const positionDetails = { const PredictionMarketFeature = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { ...remoteFeatureFlagPredictEnabled(true), + // TODO: Fix this test to support the FF-enabled Predict bottom sheet / any-token flow. + predictBottomSheet: { + enabled: false, + minimumVersion: '0.0.0', + }, + predictWithAnyToken: { + enabled: false, + minimumVersion: '0.0.0', + }, carouselBanners: false, }); await POLYMARKET_COMPLETE_MOCKS(mockServer); diff --git a/tests/smoke/predict/predict-open-position.spec.ts b/tests/smoke/predict/predict-open-position.spec.ts index 0cd5ebcbd579..0080c92b22d7 100644 --- a/tests/smoke/predict/predict-open-position.spec.ts +++ b/tests/smoke/predict/predict-open-position.spec.ts @@ -5,7 +5,10 @@ import { loginToApp } from '../../flows/wallet.flow'; import PredictMarketList from '../../page-objects/Predict/PredictMarketList'; import PredictDetailsPage from '../../page-objects/Predict/PredictDetailsPage'; import Assertions from '../../framework/Assertions'; -import { remoteFeatureFlagPredictEnabled } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import { + remoteFeatureFlagHomepageSectionsV1Enabled, + remoteFeatureFlagPredictEnabled, +} from '../../api-mocking/mock-responses/feature-flags-mocks'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { @@ -38,6 +41,16 @@ const positionDetails = { const PredictionMarketFeature = async (mockServer: Mockttp) => { await setupRemoteFeatureFlagsMock(mockServer, { ...remoteFeatureFlagPredictEnabled(true), + ...remoteFeatureFlagHomepageSectionsV1Enabled(), + // TODO: Fix this test to support the FF-enabled Predict bottom sheet / any-token flow. + predictBottomSheet: { + enabled: false, + minimumVersion: '0.0.0', + }, + predictWithAnyToken: { + enabled: false, + minimumVersion: '0.0.0', + }, carouselBanners: false, }); await POLYMARKET_COMPLETE_MOCKS(mockServer); diff --git a/tests/smoke/trending/trending-feed.spec.ts b/tests/smoke/trending/trending-feed.spec.ts index 8a93cb0cef6a..31442022168c 100644 --- a/tests/smoke/trending/trending-feed.spec.ts +++ b/tests/smoke/trending/trending-feed.spec.ts @@ -37,6 +37,11 @@ describe(SmokeWalletPlatform('Trending Feed View All Navigation'), () => { await setupRemoteFeatureFlagsMock(mockServer, { ...remoteFeatureFlagTrendingTokensEnabled(), ...remoteFeatureFlagPredictEnabled(), + // TODO: Fix this test to support the FF-enabled What's Happening Explore section. + aiSocialWhatsHappeningEnabled: { + enabled: false, + minimumVersion: '0.0.0', + }, }); // Setup API mocks using centralized definition