Skip to content

Commit 714d6b6

Browse files
authored
feat(card): Card <> Money Account linkage bottom sheet (#30189)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** This branch delivers Money Account ↔ MetaMask Card linkage improvements: a confirmation bottom sheet before on-chain delegation, a canonical “already delegated” signal for the Money feature, post-link refresh of card home data, concurrent-linkage protection, a developer-only unlink control, and a small Money deposit-address fix that landed alongside the merge. **Branch history (first-parent vs `main`):** 1. `feat(card): add card linkage bottomsheet and selectors` — core sheet, hook split, delegated selector/helper, controller refresh, tests. 2. `merge with main` — brings current `main` into the branch (reviewers may see a large merge diff in GitHub; the **feature surface** is the scoped paths listed under “What changed” below). 3. `feat(card): undo money account enabled feature flag hardcoded` — restores normal `isMoneyAccountEnabled` behaviour (remote + local flag). 4. `feat(card): remove comments and solve lint issues` — e.g. `no-void` fix on the link sheet confirm handler; APY interpolation on the sheet; shared `mm_usd.png` illustration instead of a dedicated asset. 5. `feat(card): add developer section to remove delegation and prevent duplicates` — Developer Options “Unlink Money Account from Card”; `CardLinkageInProgressError` + `linkMoneyAccountCard` singleflight + `isLinkageInProgress()`; hook guard and tests. ### Why - **UX**: Users should confirm “Spend and earn” before any SIWE / `approve` / Baanx post-approval runs; sheet dismisses first, then existing pending / success / error toasts apply. - **State**: Money views need one place to know the MA is already a card funding source; `selectIsMoneyAccountDelegatedForCard` + `canLink` coupling avoids re-link and stranded error toasts. - **Freshness**: After a successful link, `fetchCardHomeData()` runs so the new Monad USDC row appears without waiting for another refresh. - **Safety**: A second “Link card” while the first flow is in flight must not double-submit; controller singleflight + hook sync check + silent second tap. - **Dev tooling**: QA/dev need a way to revoke delegation (`approve(0)` path via `linkMoneyAccountCard` with `delegationAmountHuman: '0'`). ### What changed (scoped paths vs `main`) | Area | Files / behaviour | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Link Card sheet** | [`MoneyLinkCardSheet`](app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx) + styles, testIds, tests, barrel export; [`Routes`](app/constants/navigation/Routes.ts) `LINK_CARD_SHEET`; [`Money` modal stack](app/components/UI/Money/routes/index.tsx) + [`index.test.tsx`](app/components/UI/Money/routes/index.test.tsx). Illustration: [`mm_usd.png`](app/images/mm_usd.png) (same as condensed info cards). Description uses `{{apy}}` via [`useMoneyAccountBalance`](app/components/UI/Money/hooks/useMoneyAccountBalance.tsx). CTA uses `.catch(() => undefined)` instead of `void` (ESLint `no-void`). | | **Hook** | [`useMoneyAccountCardLinkage`](app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx): `openLinkCardSheet` / `confirmLinkInBackground`, `canLink` includes `!isAlreadyDelegated`, sync `isLinkageInProgress()` guard, silent `CardLinkageInProgressError` handling; [`useMoneyAccountCardLinkage.test.tsx`](app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx). | | **Money home** | [`MoneyHomeView`](app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx) uses `canLink` + `openLinkCardSheet`; tests updated. | | **Delegated indicator** | [`moneyAccountCardToken.ts`](app/core/Engine/controllers/card-controller/utils/moneyAccountCardToken.ts) + tests: `isMoneyAccountDelegatedForCard`, CAIP chain constant; [`cardController.ts`](app/selectors/cardController.ts) `selectIsMoneyAccountDelegatedForCard` + tests. | | **Controller** | [`CardController`](app/core/Engine/controllers/card-controller/CardController.ts): post-`approveFunding` `fetchCardHomeData()` (errors swallowed + logged); `linkMoneyAccountCard` singleflight (`#linkMoneyAccountCardUnsafe`), `isLinkageInProgress()`; [`CardLinkageInProgressError`](app/core/Engine/controllers/card-controller/provider-types.ts); extensive [`CardController.test.ts`](app/core/Engine/controllers/card-controller/CardController.test.ts) coverage. | | **Locales** | [`en.json`](locales/languages/en.json): `money.metamask_card.link_card_sheet_*` (description with `{{apy}}`); `app_settings.developer_options.card.unlink_money_account_*`. | | **Developer Options** | [`CardDeveloperOptionsSection`](app/components/UI/Card/components/CardDeveloperOptionsSection/CardDeveloperOptionsSection.tsx): unlink row (disabled when not delegated + hint), calls `linkMoneyAccountCard` with `'0'`; [`CardDeveloperOptionsSection.test.tsx`](app/components/UI/Card/components/CardDeveloperOptionsSection/CardDeveloperOptionsSection.test.tsx). | | **Feature flag** | [`feature-flags.ts`](app/lib/Money/feature-flags.ts): restore `validatedVersionGatedFeatureFlag` for Money Account enablement (reverts debug hardcode). | | **Money transactions util** | [`moneyAccountTransactions.ts`](app/components/UI/Money/utils/moneyAccountTransactions.ts): `getMoneyAccountDepositAssetAddress` uses `MUSD_TOKEN_ADDRESS_BY_CHAIN` instead of a hardcoded USDC address (came in with branch merge / related Money work). | ### Out of scope (intentional) - Replacing Money home onboarding / MetaMask Card copy when already delegated (selector is ready; UI follow-up optional). - B3 SpendingLimit MA-mode wiring (separate branch / ticket). ## **Changelog** CHANGELOG entry: Added a "Spend and earn" confirmation step before linking your MetaMask Card to your Money Account; prevented duplicate in-flight linkage submissions; added a developer option to unlink the Money Account from the card (revoke USDC allowance). ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Money Account ↔ MetaMask Card linkage Background: Given the Money Account feature flag is enabled for my version And my wallet has a Money Account And I am authenticated with the MetaMask Card backend And delegation settings expose the Monad USDC card token Scenario: confirm linkage from the sheet Given my Money Account is NOT yet delegated for card usage And I am on Money home When I tap the link CTA Then the "Spend and earn" bottom sheet appears with APY matching Money home When I tap "Link card" on the sheet Then the sheet dismisses immediately And a pending "Linking card" toast appears When the approval flow completes Then a success toast appears And I cannot trigger a second concurrent linkage from a re-opened sheet without duplicate pending toasts (second submit is a silent no-op while the first run is in flight) Scenario: dismiss sheet without linking Given the link sheet is open When I close the sheet without confirming Then no transaction runs and no pending toast appears Scenario: already delegated user Given my Money Account is already delegated for card When I tap the link CTA on Money home Then I am taken to Card home without the sheet (no error toast) Scenario: developer unlink (dev build only) Given Developer Options are enabled (MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS) And my Money Account is delegated for card When I tap "Unlink Money Account from Card" Then approve(0) / revoke path runs (no confirmation dialog in dev row) And the unlink control becomes disabled when no active linkage remains ``` ## **Screenshots/Recordings** ### **Before** <!-- Money home link CTA starts linkage without confirmation sheet; duplicate submits possible while in flight; no dev unlink row. --> ### **After** <!-- "Spend and earn" sheet → Link card → dismiss → toasts; optional: Developer Options Card section with unlink row. --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches the card linking flow and `CardController.linkMoneyAccountCard`, including new concurrency guards and post-link refresh; mistakes could cause failed/duplicated linkage attempts or confusing UX, though changes are bounded and well-tested. > > **Overview** > Introduces a new Money modal `MoneyLinkCardSheet` and route (`Routes.MONEY.MODALS.LINK_CARD_SHEET`) so tapping “Link card” on Money home opens a confirmation sheet before executing the on-chain delegation. > > Refactors `useMoneyAccountCardLinkage` into `openLinkCardSheet` (navigation-only) and `confirmLinkInBackground` (runs linkage + toasts), and makes `canLink` fail closed when `selectIsMoneyAccountDelegatedForCard` reports the primary Money Account is already delegated. > > Adds a canonical delegated signal via `isMoneyAccountDelegatedForCard` + `selectIsMoneyAccountDelegatedForCard`, updates `CardController.linkMoneyAccountCard` to (1) prevent concurrent submissions with a new `CardLinkageInProgressError`/`isLinkageInProgress()` and (2) refresh card home data post-link (errors swallowed + logged), and adds a Developer Options “Unlink Money Account from Card” action that submits the revoke (`delegationAmountHuman: '0'`). > > Updates locales and extends/adjusts tests across the Money home view, linkage hook, controller, selector, and developer options. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7970343. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a55aeb3 commit 714d6b6

21 files changed

Lines changed: 1411 additions & 55 deletions
Lines changed: 222 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import React from 'react';
2-
import { fireEvent } from '@testing-library/react-native';
2+
import { fireEvent, waitFor } from '@testing-library/react-native';
33
import renderWithProvider from '../../../../../util/test/renderWithProvider';
44
import CardDeveloperOptionsSection from './CardDeveloperOptionsSection';
55
import { resetOnboardingState } from '../../../../../core/redux/slices/card';
6+
import Engine from '../../../../../core/Engine';
7+
import Logger from '../../../../../util/Logger';
8+
import { selectIsMoneyAccountDelegatedForCard } from '../../../../../selectors/cardController';
9+
import { selectPrimaryMoneyAccount } from '../../../../../selectors/moneyAccountController';
610

711
jest.mock('../../../../../core/redux/slices/card', () => ({
812
resetOnboardingState: jest.fn(() => ({ type: 'card/resetOnboardingState' })),
@@ -14,40 +18,236 @@ jest.mock('react-redux', () => ({
1418
useDispatch: () => mockDispatch,
1519
}));
1620

21+
jest.mock('../../../../../selectors/cardController', () => ({
22+
...jest.requireActual('../../../../../selectors/cardController'),
23+
selectIsMoneyAccountDelegatedForCard: jest.fn(),
24+
}));
25+
26+
jest.mock('../../../../../selectors/moneyAccountController', () => ({
27+
...jest.requireActual('../../../../../selectors/moneyAccountController'),
28+
selectPrimaryMoneyAccount: jest.fn(),
29+
}));
30+
31+
jest.mock('../../../../../core/Engine', () => ({
32+
__esModule: true,
33+
default: {
34+
context: {
35+
CardController: {
36+
linkMoneyAccountCard: jest.fn(),
37+
},
38+
},
39+
},
40+
}));
41+
42+
jest.mock('../../../../../util/Logger', () => ({
43+
__esModule: true,
44+
default: {
45+
error: jest.fn(),
46+
},
47+
}));
48+
49+
const mockSelectIsMoneyAccountDelegatedForCard =
50+
selectIsMoneyAccountDelegatedForCard as unknown as jest.Mock;
51+
const mockSelectPrimaryMoneyAccount =
52+
selectPrimaryMoneyAccount as unknown as jest.Mock;
53+
const mockLinkMoneyAccountCard = Engine.context.CardController
54+
.linkMoneyAccountCard as jest.Mock;
55+
const mockLoggerError = Logger.error as jest.Mock;
56+
57+
const MONEY_ACCOUNT_ADDRESS = '0xma000000000000000000000000000000000000aa';
58+
const UNLINK_BUTTON_TEST_ID = 'card-dev-unlink-money-account-button';
59+
const UNLINK_DISABLED_HINT_TEST_ID =
60+
'card-dev-unlink-money-account-disabled-hint';
61+
62+
const setSelectorState = ({
63+
isAlreadyDelegated,
64+
moneyAccountAddress = MONEY_ACCOUNT_ADDRESS,
65+
}: {
66+
isAlreadyDelegated: boolean;
67+
moneyAccountAddress?: string | null;
68+
}) => {
69+
mockSelectIsMoneyAccountDelegatedForCard.mockReturnValue(isAlreadyDelegated);
70+
mockSelectPrimaryMoneyAccount.mockReturnValue(
71+
moneyAccountAddress ? { address: moneyAccountAddress } : undefined,
72+
);
73+
};
74+
1775
describe('CardDeveloperOptionsSection', () => {
1876
beforeEach(() => {
1977
jest.clearAllMocks();
78+
setSelectorState({ isAlreadyDelegated: false });
79+
mockLinkMoneyAccountCard.mockResolvedValue(undefined);
2080
});
2181

22-
it('renders the Card heading', () => {
23-
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
82+
describe('reset onboarding', () => {
83+
it('renders the Card heading', () => {
84+
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
2485

25-
expect(getByText('Card')).toBeDefined();
26-
});
86+
expect(getByText('Card')).toBeDefined();
87+
});
2788

28-
it('renders the description text', () => {
29-
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
89+
it('renders the description text', () => {
90+
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
3091

31-
expect(
32-
getByText(
33-
'Reset Card onboarding state to start the onboarding flow from the beginning.',
34-
),
35-
).toBeDefined();
36-
});
92+
expect(
93+
getByText(
94+
'Reset Card onboarding state to start the onboarding flow from the beginning.',
95+
),
96+
).toBeDefined();
97+
});
98+
99+
it('renders the Reset Onboarding State button', () => {
100+
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
101+
102+
expect(getByText('Reset Onboarding State')).toBeDefined();
103+
});
37104

38-
it('renders the Reset Onboarding State button', () => {
39-
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
105+
it('dispatches resetOnboardingState when button is pressed', () => {
106+
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
40107

41-
expect(getByText('Reset Onboarding State')).toBeDefined();
108+
const button = getByText('Reset Onboarding State');
109+
fireEvent.press(button);
110+
111+
expect(mockDispatch).toHaveBeenCalledTimes(1);
112+
expect(resetOnboardingState).toHaveBeenCalledTimes(1);
113+
});
42114
});
43115

44-
it('dispatches resetOnboardingState when button is pressed', () => {
45-
const { getByText } = renderWithProvider(<CardDeveloperOptionsSection />);
116+
describe('unlink Money Account from Card', () => {
117+
it('renders the unlink description and button', () => {
118+
setSelectorState({ isAlreadyDelegated: true });
119+
120+
const { getByText, getByTestId } = renderWithProvider(
121+
<CardDeveloperOptionsSection />,
122+
);
123+
124+
expect(
125+
getByText(
126+
/Revoke the USDC spending-limit allowance that authorises Card/,
127+
),
128+
).toBeDefined();
129+
expect(getByText('Unlink Money Account from Card')).toBeDefined();
130+
expect(getByTestId(UNLINK_BUTTON_TEST_ID)).toBeDefined();
131+
});
132+
133+
it('disables the unlink button and shows the hint when the Money Account is not delegated', () => {
134+
setSelectorState({ isAlreadyDelegated: false });
135+
136+
const { getByTestId } = renderWithProvider(
137+
<CardDeveloperOptionsSection />,
138+
);
139+
140+
// Design-system Button forwards `isDisabled` to the underlying
141+
// Pressable via `accessibilityState.disabled` (and/or the raw
142+
// `disabled` prop). Mirror the canonical pattern from
143+
// `ClaimBanner.test.tsx`.
144+
const button = getByTestId(UNLINK_BUTTON_TEST_ID);
145+
expect(
146+
button.props.accessibilityState?.disabled ?? button.props.disabled,
147+
).toBe(true);
148+
expect(getByTestId(UNLINK_DISABLED_HINT_TEST_ID)).toBeDefined();
149+
150+
fireEvent.press(button);
151+
expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled();
152+
});
153+
154+
it('disables the unlink button when there is no primary Money Account address', () => {
155+
setSelectorState({
156+
isAlreadyDelegated: true,
157+
moneyAccountAddress: null,
158+
});
159+
160+
const { getByTestId } = renderWithProvider(
161+
<CardDeveloperOptionsSection />,
162+
);
163+
164+
const button = getByTestId(UNLINK_BUTTON_TEST_ID);
165+
expect(
166+
button.props.accessibilityState?.disabled ?? button.props.disabled,
167+
).toBe(true);
168+
expect(getByTestId(UNLINK_DISABLED_HINT_TEST_ID)).toBeDefined();
169+
170+
fireEvent.press(button);
171+
expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled();
172+
});
173+
174+
it('enables the unlink button and hides the hint when the Money Account is delegated', () => {
175+
setSelectorState({ isAlreadyDelegated: true });
176+
177+
const { getByTestId, queryByTestId } = renderWithProvider(
178+
<CardDeveloperOptionsSection />,
179+
);
180+
181+
const button = getByTestId(UNLINK_BUTTON_TEST_ID);
182+
expect(
183+
button.props.accessibilityState?.disabled ??
184+
button.props.disabled ??
185+
false,
186+
).toBe(false);
187+
expect(queryByTestId(UNLINK_DISABLED_HINT_TEST_ID)).toBeNull();
188+
});
189+
190+
it('calls linkMoneyAccountCard with amount "0" when the unlink button is pressed', async () => {
191+
setSelectorState({ isAlreadyDelegated: true });
192+
193+
const { getByTestId } = renderWithProvider(
194+
<CardDeveloperOptionsSection />,
195+
);
196+
197+
fireEvent.press(getByTestId(UNLINK_BUTTON_TEST_ID));
198+
199+
await waitFor(() => {
200+
expect(mockLinkMoneyAccountCard).toHaveBeenCalledTimes(1);
201+
});
202+
expect(mockLinkMoneyAccountCard).toHaveBeenCalledWith({
203+
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
204+
delegationAmountHuman: '0',
205+
});
206+
expect(mockLoggerError).not.toHaveBeenCalled();
207+
});
208+
209+
it('logs via Logger.error and does not propagate when linkMoneyAccountCard rejects', async () => {
210+
setSelectorState({ isAlreadyDelegated: true });
211+
const failure = new Error('post-approval refused');
212+
mockLinkMoneyAccountCard.mockRejectedValueOnce(failure);
213+
214+
const { getByTestId } = renderWithProvider(
215+
<CardDeveloperOptionsSection />,
216+
);
217+
218+
// Pressing must not throw, even though the underlying call rejects.
219+
expect(() =>
220+
fireEvent.press(getByTestId(UNLINK_BUTTON_TEST_ID)),
221+
).not.toThrow();
222+
223+
await waitFor(() => {
224+
expect(mockLoggerError).toHaveBeenCalledTimes(1);
225+
});
226+
expect(mockLoggerError).toHaveBeenCalledWith(
227+
failure,
228+
'CardDeveloperOptionsSection: unlink Money Account failed',
229+
);
230+
});
231+
232+
it('wraps non-Error rejections in an Error before passing them to Logger.error', async () => {
233+
setSelectorState({ isAlreadyDelegated: true });
234+
mockLinkMoneyAccountCard.mockRejectedValueOnce('boom');
235+
236+
const { getByTestId } = renderWithProvider(
237+
<CardDeveloperOptionsSection />,
238+
);
46239

47-
const button = getByText('Reset Onboarding State');
48-
fireEvent.press(button);
240+
fireEvent.press(getByTestId(UNLINK_BUTTON_TEST_ID));
49241

50-
expect(mockDispatch).toHaveBeenCalledTimes(1);
51-
expect(resetOnboardingState).toHaveBeenCalledTimes(1);
242+
await waitFor(() => {
243+
expect(mockLoggerError).toHaveBeenCalledTimes(1);
244+
});
245+
const [loggedError, loggedContext] = mockLoggerError.mock.calls[0];
246+
expect(loggedError).toBeInstanceOf(Error);
247+
expect((loggedError as Error).message).toBe('boom');
248+
expect(loggedContext).toBe(
249+
'CardDeveloperOptionsSection: unlink Money Account failed',
250+
);
251+
});
52252
});
53253
});

app/components/UI/Card/components/CardDeveloperOptionsSection/CardDeveloperOptionsSection.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useCallback } from 'react';
2-
import { useDispatch } from 'react-redux';
2+
import { useDispatch, useSelector } from 'react-redux';
33
import { useTailwind } from '@metamask/design-system-twrnc-preset';
44
import {
55
Box,
@@ -13,15 +13,40 @@ import Text, {
1313
TextColor,
1414
} from '../../../../../component-library/components/Texts/Text';
1515
import { strings } from '../../../../../../locales/i18n';
16+
import Engine from '../../../../../core/Engine';
17+
import Logger from '../../../../../util/Logger';
18+
import { selectIsMoneyAccountDelegatedForCard } from '../../../../../selectors/cardController';
19+
import { selectPrimaryMoneyAccount } from '../../../../../selectors/moneyAccountController';
1620

1721
const CardDeveloperOptionsSection = () => {
1822
const dispatch = useDispatch();
1923
const tw = useTailwind();
24+
const isAlreadyDelegated = useSelector(selectIsMoneyAccountDelegatedForCard);
25+
const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount);
26+
const isUnlinkable =
27+
isAlreadyDelegated && Boolean(primaryMoneyAccount?.address);
2028

2129
const handleResetOnboardingState = useCallback(() => {
2230
dispatch(resetOnboardingState());
2331
}, [dispatch]);
2432

33+
const handleUnlinkMoneyAccount = useCallback(async () => {
34+
if (!primaryMoneyAccount?.address) {
35+
return;
36+
}
37+
try {
38+
await Engine.context.CardController.linkMoneyAccountCard({
39+
moneyAccountAddress: primaryMoneyAccount.address,
40+
delegationAmountHuman: '0',
41+
});
42+
} catch (error) {
43+
Logger.error(
44+
error instanceof Error ? error : new Error(String(error)),
45+
'CardDeveloperOptionsSection: unlink Money Account failed',
46+
);
47+
}
48+
}, [primaryMoneyAccount?.address]);
49+
2550
return (
2651
<Box twClassName="mt-2 gap-2">
2752
<Text
@@ -49,6 +74,40 @@ const CardDeveloperOptionsSection = () => {
4974
>
5075
{strings('app_settings.developer_options.card.reset_onboarding_button')}
5176
</Button>
77+
<Text
78+
color={TextColor.Alternative}
79+
variant={TextVariant.BodyMD}
80+
style={tw.style('mt-6')}
81+
>
82+
{strings(
83+
'app_settings.developer_options.card.unlink_money_account_description',
84+
)}
85+
</Text>
86+
<Button
87+
variant={ButtonVariant.Secondary}
88+
size={ButtonSize.Lg}
89+
onPress={handleUnlinkMoneyAccount}
90+
isFullWidth
91+
isDisabled={!isUnlinkable}
92+
style={tw.style('mt-4')}
93+
testID="card-dev-unlink-money-account-button"
94+
>
95+
{strings(
96+
'app_settings.developer_options.card.unlink_money_account_button',
97+
)}
98+
</Button>
99+
{!isUnlinkable && (
100+
<Text
101+
color={TextColor.Muted}
102+
variant={TextVariant.BodySM}
103+
style={tw.style('mt-2')}
104+
testID="card-dev-unlink-money-account-disabled-hint"
105+
>
106+
{strings(
107+
'app_settings.developer_options.card.unlink_money_account_disabled_hint',
108+
)}
109+
</Text>
110+
)}
52111
</Box>
53112
);
54113
};

0 commit comments

Comments
 (0)