Skip to content

Commit 7635667

Browse files
CassioMGclaude
andcommitted
Fix Blockaid destination-token fail-open on the swap flow
When a destination token was picked before its Blockaid scan landed and the recovery scan then failed (transient error → scanAssetBulk returns null), the hook bailed without dispatching, so securityLevel stayed undefined and the review showed the token as clean with a normal confirm — the malicious/unable warning never reached the user. Drop the `|| !scan` early-return: a missing scan now flows through getAssetSecurityLevel, which maps absent data to UNABLE_TO_SCAN on a Blockaid-enabled network (matching the picker's unscanned-row handling), so the review shows the 'couldn't be scanned' banner + 'Confirm anyway' gate. A successful scan still flags malicious/suspicious; a superseded scan still writes nothing. Adds useSwapDestinationScan unit tests (fail-open, flagged, superseded). Verified: test:ci passes (1345). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e5126d3 commit 7635667

2 files changed

Lines changed: 105 additions & 1 deletion

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { renderHook, waitFor, act } from "@testing-library/react";
2+
3+
// Keep the real verdict logic (getAssetSecurityLevel / isBlockaidEnabled) so we
4+
// exercise the actual UNABLE_TO_SCAN mapping; stub only the network scan.
5+
jest.mock("popup/helpers/blockaid", () => ({
6+
...jest.requireActual("popup/helpers/blockaid"),
7+
scanAssetBulk: jest.fn(),
8+
}));
9+
// The hook reads the picker's in-session scan cache from this module.
10+
jest.mock("popup/components/swap/SwapAsset/hooks/useSwapTokenLookup", () => ({
11+
getCachedAssetScan: jest.fn(),
12+
}));
13+
14+
import { scanAssetBulk } from "popup/helpers/blockaid";
15+
import { getCachedAssetScan } from "popup/components/swap/SwapAsset/hooks/useSwapTokenLookup";
16+
import {
17+
DestinationTokenDetails,
18+
saveDestinationTokenDetails,
19+
} from "popup/ducks/transactionSubmission";
20+
import { SecurityLevel } from "popup/constants/blockaid";
21+
import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar";
22+
import { AppDispatch } from "popup/App";
23+
import { useSwapDestinationScan } from "../useSwapDestinationScan";
24+
25+
const details = {
26+
tokenCode: "EVIL",
27+
issuer: "GEVIL",
28+
requiresTrustline: true,
29+
decimals: 7,
30+
securityLevel: undefined,
31+
} as DestinationTokenDetails;
32+
const id = "EVIL-GEVIL";
33+
34+
const renderScan = (dispatch: jest.Mock) =>
35+
renderHook(() =>
36+
useSwapDestinationScan({
37+
destinationTokenDetails: details,
38+
networkDetails: MAINNET_NETWORK_DETAILS,
39+
dispatch: dispatch as unknown as AppDispatch,
40+
}),
41+
);
42+
43+
describe("useSwapDestinationScan", () => {
44+
afterEach(() => jest.clearAllMocks());
45+
46+
it("resolves a failed recovery scan to UNABLE_TO_SCAN (no fail-open)", async () => {
47+
(getCachedAssetScan as jest.Mock).mockReturnValue(undefined);
48+
(scanAssetBulk as jest.Mock).mockResolvedValue(null);
49+
const dispatch = jest.fn();
50+
51+
renderScan(dispatch);
52+
53+
await waitFor(() => expect(dispatch).toHaveBeenCalled());
54+
expect(dispatch).toHaveBeenCalledWith(
55+
saveDestinationTokenDetails({
56+
...details,
57+
securityLevel: SecurityLevel.UNABLE_TO_SCAN,
58+
}),
59+
);
60+
});
61+
62+
it("resolves a flagged recovery scan to MALICIOUS", async () => {
63+
(getCachedAssetScan as jest.Mock).mockReturnValue(undefined);
64+
(scanAssetBulk as jest.Mock).mockResolvedValue({
65+
results: { [id]: { result_type: "Malicious" } },
66+
});
67+
const dispatch = jest.fn();
68+
69+
renderScan(dispatch);
70+
71+
await waitFor(() => expect(dispatch).toHaveBeenCalled());
72+
expect(dispatch).toHaveBeenCalledWith(
73+
saveDestinationTokenDetails({
74+
...details,
75+
securityLevel: SecurityLevel.MALICIOUS,
76+
}),
77+
);
78+
});
79+
80+
it("does not write a verdict for a superseded scan (unmounted mid-flight)", async () => {
81+
(getCachedAssetScan as jest.Mock).mockReturnValue(undefined);
82+
let resolveScan: (value: unknown) => void = () => {};
83+
(scanAssetBulk as jest.Mock).mockReturnValue(
84+
new Promise((resolve) => {
85+
resolveScan = resolve;
86+
}),
87+
);
88+
const dispatch = jest.fn();
89+
90+
const { unmount } = renderScan(dispatch);
91+
unmount();
92+
93+
await act(async () => {
94+
resolveScan({ results: { [id]: { result_type: "Malicious" } } });
95+
});
96+
97+
expect(dispatch).not.toHaveBeenCalled();
98+
});
99+
});

extension/src/popup/components/swap/SwapAmount/hooks/useSwapDestinationScan.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,14 @@ export const useSwapDestinationScan = ({
7373
);
7474
scan = bulk?.results?.[id];
7575
}
76-
if (cancelled || !scan) {
76+
if (cancelled) {
7777
return;
7878
}
79+
// A missing scan (cache miss + failed/empty bulk scan) must NOT fail open:
80+
// getAssetSecurityLevel maps absent data to UNABLE_TO_SCAN on a
81+
// Blockaid-enabled network, matching the picker's unscanned-row handling,
82+
// so the review shows the "couldn't be scanned" banner + acknowledgement
83+
// gate instead of a clean confirm.
7984
const securityLevel = getAssetSecurityLevel({
8085
blockaidData: scan,
8186
blockaidOverrideState,

0 commit comments

Comments
 (0)