@@ -164,10 +164,14 @@ jest.mock("ducks/swap", () => ({
164164} ) ) ;
165165
166166const 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
173177jest . mock ( "ducks/transactionBuilder" , ( ) => ( {
@@ -212,6 +216,43 @@ jest.mock("components/screens/SwapScreen/hooks/useSwapTransaction", () => ({
212216} ) ) ;
213217jest . 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+
215256jest . 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" : {
0 commit comments