Skip to content

Commit a3d3dda

Browse files
authored
feat(swap): default the You receive token to USDC (#986)
* feat(swap): default the You receive token to USDC * fix(swap): gate default-USDC seeding on a balances snapshot for the active account/network * test(e2e): match the pre-seeded Receive pill in SwapClassicToken * fix(swap): stamp the seeded default destination with a real Blockaid scan * chore: revert accidental CFPropertyList downgrade in Gemfile.lock * refactor(swap): extract default-destination seeding into useDefaultSwapDestination * fix(swap): read the destination from the store when stamping the scan result
1 parent a56ba96 commit a3d3dda

7 files changed

Lines changed: 491 additions & 13 deletions

File tree

‎__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx‎

Lines changed: 314 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,14 @@ jest.mock("ducks/swap", () => ({
164164
}));
165165

166166
const setSwapStoreState = (patch: Partial<SwapStoreState>): void => {
167-
(useSwapStore as unknown as jest.Mock).mockImplementation(() => ({
168-
...makeDefaultSwapState(),
169-
...patch,
170-
}));
167+
const state = { ...makeDefaultSwapState(), ...patch };
168+
const mock = useSwapStore as unknown as jest.Mock & {
169+
getState: () => SwapStoreState;
170+
};
171+
mock.mockImplementation(() => state);
172+
// The scan-stamping callback reads the destination synchronously via
173+
// useSwapStore.getState(), so mirror the hook-call state there too.
174+
mock.getState = () => state;
171175
};
172176

173177
jest.mock("ducks/transactionBuilder", () => ({
@@ -212,6 +216,43 @@ jest.mock("components/screens/SwapScreen/hooks/useSwapTransaction", () => ({
212216
}));
213217
jest.mock("hooks/useBalancesList");
214218

219+
// Single-token scan used to stamp the seeded default destination with a real
220+
// securityLevel. Controllable per-test; defaults to Benign in beforeEach.
221+
const mockScanToken = jest.fn();
222+
jest.mock("services/blockaid/api", () => ({
223+
...jest.requireActual("services/blockaid/api"),
224+
scanToken: (...args: unknown[]) => mockScanToken(...args),
225+
}));
226+
227+
// The default-destination seeding effect reads the raw balances map and its
228+
// fetched stamps straight from the balances store (useBalancesList is mocked
229+
// above and doesn't carry them). Tests drive them through these holders; the
230+
// global beforeEach resets to "hydrated for the test account on PUBLIC".
231+
let mockRawBalances: Record<string, unknown> = {};
232+
let mockFetchedPublicKey: string | null = null;
233+
let mockFetchedNetwork: NETWORKS | null = null;
234+
jest.mock("ducks/balances", () => ({
235+
useBalancesStore: (selector?: (s: Record<string, unknown>) => unknown) => {
236+
const state = {
237+
balances: mockRawBalances,
238+
fetchedPublicKey: mockFetchedPublicKey,
239+
fetchedNetwork: mockFetchedNetwork,
240+
};
241+
return selector ? selector(state) : state;
242+
},
243+
}));
244+
245+
// Stamp the balances store as hydrated for the test account ("abc", per the
246+
// useGetActiveAccount mock) on PUBLIC, holding the fixture balances plus any
247+
// extra token ids.
248+
const hydrateBalancesStore = (extraIds: string[] = []) => {
249+
mockRawBalances = Object.fromEntries(
250+
[...mockBalances.map((b) => b.id), ...extraIds].map((id) => [id, {}]),
251+
);
252+
mockFetchedPublicKey = "abc";
253+
mockFetchedNetwork = NETWORKS.PUBLIC;
254+
};
255+
215256
jest.mock("@react-navigation/elements", () => ({
216257
useHeaderHeight: () => 0,
217258
}));
@@ -353,6 +394,9 @@ describe("SwapAmountScreen", () => {
353394
mockSaveSwapFee.mockClear();
354395
setSwapStoreState({});
355396
mockBalancesListReturn();
397+
hydrateBalancesStore();
398+
mockScanToken.mockReset();
399+
mockScanToken.mockResolvedValue({ result_type: "Benign" });
356400
});
357401

358402
it("initializes source token from route params", () => {
@@ -368,6 +412,272 @@ describe("SwapAmountScreen", () => {
368412
expect(mockSetDestinationToken).toHaveBeenCalledWith(null);
369413
});
370414

415+
describe("default destination (USDC) seeding", () => {
416+
// The auth store defaults to PUBLIC in tests, so the seeded default is
417+
// mainnet USDC (Circle issuer).
418+
const MAINNET_USDC_ID =
419+
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
420+
421+
it("seeds USDC as the destination when none is set", () => {
422+
setSwapStoreState({ destinationToken: null });
423+
424+
renderWithProviders(
425+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
426+
);
427+
428+
expect(mockSetDestinationToken).toHaveBeenCalledWith(
429+
expect.objectContaining({
430+
id: MAINNET_USDC_ID,
431+
tokenCode: "USDC",
432+
// The fixture balances hold a different USDC issuer, so the
433+
// default is unheld and needs a trustline.
434+
requiresTrustline: true,
435+
}),
436+
);
437+
});
438+
439+
it("seeds native XLM instead when the swap starts from the default USDC", () => {
440+
setSwapStoreState({ destinationToken: null });
441+
const route = {
442+
key: "swap-amount",
443+
name: SWAP_ROUTES.SWAP_AMOUNT_SCREEN,
444+
params: { tokenId: MAINNET_USDC_ID, tokenSymbol: "USDC" },
445+
} as unknown as Props["route"];
446+
447+
renderWithProviders(
448+
<SwapAmountScreen navigation={makeNavigation()} route={route} />,
449+
);
450+
451+
expect(mockSetDestinationToken).toHaveBeenCalledWith(
452+
expect.objectContaining({
453+
id: "XLM",
454+
tokenCode: "XLM",
455+
requiresTrustline: false,
456+
}),
457+
);
458+
});
459+
460+
it("derives requiresTrustline=false when the account already holds the default", () => {
461+
setSwapStoreState({ destinationToken: null });
462+
hydrateBalancesStore([MAINNET_USDC_ID]);
463+
464+
renderWithProviders(
465+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
466+
);
467+
468+
expect(mockSetDestinationToken).toHaveBeenCalledWith(
469+
expect.objectContaining({
470+
id: MAINNET_USDC_ID,
471+
requiresTrustline: false,
472+
}),
473+
);
474+
});
475+
476+
it("does not seed before a snapshot for this account/network lands, then seeds once it does", () => {
477+
setSwapStoreState({ destinationToken: null });
478+
mockRawBalances = {};
479+
mockFetchedPublicKey = null;
480+
mockFetchedNetwork = null;
481+
482+
const { rerender } = renderWithProviders(
483+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
484+
);
485+
486+
expect(mockSetDestinationToken).not.toHaveBeenCalledWith(
487+
expect.objectContaining({ tokenCode: "USDC" }),
488+
);
489+
490+
hydrateBalancesStore();
491+
rerender(
492+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
493+
);
494+
495+
expect(mockSetDestinationToken).toHaveBeenCalledWith(
496+
expect.objectContaining({
497+
id: MAINNET_USDC_ID,
498+
tokenCode: "USDC",
499+
requiresTrustline: true,
500+
}),
501+
);
502+
});
503+
504+
it("does not seed from a stale snapshot left over from another network", () => {
505+
setSwapStoreState({ destinationToken: null });
506+
mockFetchedNetwork = NETWORKS.TESTNET;
507+
508+
renderWithProviders(
509+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
510+
);
511+
512+
expect(mockSetDestinationToken).not.toHaveBeenCalledWith(
513+
expect.objectContaining({ tokenCode: "USDC" }),
514+
);
515+
});
516+
517+
it("seeds even when the hydrated snapshot is empty (unfunded account)", () => {
518+
setSwapStoreState({ destinationToken: null });
519+
mockRawBalances = {};
520+
521+
renderWithProviders(
522+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
523+
);
524+
525+
expect(mockSetDestinationToken).toHaveBeenCalledWith(
526+
expect.objectContaining({
527+
id: MAINNET_USDC_ID,
528+
requiresTrustline: true,
529+
}),
530+
);
531+
});
532+
533+
it("kicks off a token scan for a non-held seeded default and stamps the result", async () => {
534+
setSwapStoreState({ destinationToken: null });
535+
let resolveScan!: (value: unknown) => void;
536+
mockScanToken.mockImplementation(
537+
() =>
538+
new Promise((resolve) => {
539+
resolveScan = resolve;
540+
}),
541+
);
542+
543+
const { rerender } = renderWithProviders(
544+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
545+
);
546+
547+
expect(mockScanToken).toHaveBeenCalledWith({
548+
tokenCode: "USDC",
549+
tokenIssuer: MAINNET_USDC_ID.split(":")[1],
550+
network: NETWORKS.PUBLIC,
551+
});
552+
553+
// Simulate the store applying the seeded descriptor, so the scan
554+
// callback sees the still-untouched default as the current selection.
555+
const seededDescriptor = mockSetDestinationToken.mock.calls.at(
556+
-1,
557+
)?.[0] as Record<string, unknown>;
558+
setSwapStoreState({
559+
destinationToken:
560+
seededDescriptor as SwapStoreState["destinationToken"],
561+
});
562+
rerender(
563+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
564+
);
565+
566+
await act(async () => {
567+
resolveScan({ result_type: "Benign" });
568+
await Promise.resolve();
569+
});
570+
571+
expect(mockSetDestinationToken).toHaveBeenCalledWith(
572+
expect.objectContaining({
573+
id: MAINNET_USDC_ID,
574+
securityLevel: "SAFE",
575+
securityWarnings: [],
576+
}),
577+
);
578+
});
579+
580+
it("does not stamp the scan result when the user picked another token meanwhile", async () => {
581+
setSwapStoreState({ destinationToken: null });
582+
let resolveScan!: (value: unknown) => void;
583+
mockScanToken.mockImplementation(
584+
() =>
585+
new Promise((resolve) => {
586+
resolveScan = resolve;
587+
}),
588+
);
589+
590+
renderWithProviders(
591+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
592+
);
593+
594+
// The user picks FTT (the default swap-store fixture) before the scan
595+
// resolves. The picker writes to the store synchronously; deliberately
596+
// no rerender here, so the hook's render state still lags the store —
597+
// the callback must read the store, not render state, to see the pick.
598+
setSwapStoreState({});
599+
600+
await act(async () => {
601+
resolveScan({ result_type: "Benign" });
602+
await Promise.resolve();
603+
});
604+
605+
expect(mockSetDestinationToken).not.toHaveBeenCalledWith(
606+
expect.objectContaining({ securityLevel: expect.anything() }),
607+
);
608+
});
609+
610+
it("does not write the scan result into the store after unmount", async () => {
611+
setSwapStoreState({ destinationToken: null });
612+
let resolveScan!: (value: unknown) => void;
613+
mockScanToken.mockImplementation(
614+
() =>
615+
new Promise((resolve) => {
616+
resolveScan = resolve;
617+
}),
618+
);
619+
620+
const { rerender, unmount } = renderWithProviders(
621+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
622+
);
623+
624+
// The store applies the seeded descriptor, then the user leaves the
625+
// screen before the scan resolves. Unmount runs resetSwap, which
626+
// nulls the store destination (simulated below, since the mocked
627+
// store doesn't mutate) — a late write would repopulate the reset
628+
// store and break the next visit's seeding.
629+
const seededDescriptor = mockSetDestinationToken.mock.calls.at(
630+
-1,
631+
)?.[0] as Record<string, unknown>;
632+
setSwapStoreState({
633+
destinationToken:
634+
seededDescriptor as SwapStoreState["destinationToken"],
635+
});
636+
rerender(
637+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
638+
);
639+
act(() => {
640+
unmount();
641+
});
642+
expect(mockResetSwap).toHaveBeenCalled();
643+
setSwapStoreState({ destinationToken: null });
644+
645+
await act(async () => {
646+
resolveScan({ result_type: "Benign" });
647+
await Promise.resolve();
648+
});
649+
650+
expect(mockSetDestinationToken).not.toHaveBeenCalledWith(
651+
expect.objectContaining({ securityLevel: expect.anything() }),
652+
);
653+
});
654+
655+
it("does not scan when the account already holds the default", () => {
656+
setSwapStoreState({ destinationToken: null });
657+
hydrateBalancesStore([MAINNET_USDC_ID]);
658+
659+
renderWithProviders(
660+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
661+
);
662+
663+
expect(mockSetDestinationToken).toHaveBeenCalledWith(
664+
expect.objectContaining({ id: MAINNET_USDC_ID }),
665+
);
666+
expect(mockScanToken).not.toHaveBeenCalled();
667+
});
668+
669+
it("does not override an existing destination", () => {
670+
// Default store state carries a picked FTT destination.
671+
renderWithProviders(
672+
<SwapAmountScreen navigation={makeNavigation()} route={makeRoute()} />,
673+
);
674+
675+
expect(mockSetDestinationToken).not.toHaveBeenCalledWith(
676+
expect.objectContaining({ tokenCode: "USDC" }),
677+
);
678+
});
679+
});
680+
371681
it("renders security warnings for malicious states", () => {
372682
mockBalancesListReturn({
373683
"USDC-GBDQOFC6SKCNBHPLZ7NXQ6MCKFIYUUFVOWYGNWQCXC2F4AYZ27EUWYWH": {

‎e2e/flows/transactions/SwapClassicToken.yaml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,13 @@ tags:
5252
- tapOn:
5353
id: token-option-XLM
5454

55-
# Select "to" (Receive) token (USDC). No destination chosen yet → choose pill.
55+
# Select "to" (Receive) token (USDC). The Receive side is pre-seeded with the
56+
# network's USDC once balances hydrate, so the pill renders as
57+
# swap-receive-pill; match the empty-state choose pill too in case seeding
58+
# hasn't landed yet. Still go through the picker (rather than trusting the
59+
# seeded default) so the flow keeps exercising destination selection.
5660
- tapOn:
57-
id: swap-receive-choose-pill
61+
id: "swap-receive-pill|swap-receive-choose-pill"
5862
# Wait for the token picker, then select USDC
5963
- extendedWaitUntil:
6064
visible:

‎src/components/AmountCard.tsx‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,7 @@ const PickerChip: React.FC<{
204204
// already shown as the adjacent label, so one initial reads cleaner.
205205
singleLetterFallback
206206
/>
207-
) : (
208-
// Empty-state affordance: a Plus-in-circle to signal "tap to add a
209-
// token" when no token has been picked yet.
210-
<View className="w-[20px] h-[20px] rounded-full items-center justify-center bg-gray-3">
211-
<Icon.Plus size={16} themeColor="gray" />
212-
</View>
213-
)}
207+
) : null}
214208
<Text md medium>
215209
{label ?? fallbackLabel ?? ""}
216210
</Text>

‎src/components/screens/SwapScreen/hooks/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { useDefaultSwapDestination } from "./useDefaultSwapDestination";
12
export { useSwapAmountError, SWAP_TOAST_IDS } from "./useSwapAmountError";
23
export { useSwapBalances } from "./useSwapBalances";
34
export { useSwapCtaState, type SwapCtaState } from "./useSwapCtaState";

0 commit comments

Comments
 (0)