Skip to content

Commit

Permalink
Validate all Send Addresses against stellar.expert's list of maliciou…
Browse files Browse the repository at this point in the history
…s/unsafe addresses (#245)

* Validate all Send Addresses against stellar.expert's list of malicious/unsafe addresses

* remove debugger

* no need to remove space

* forgot to remove ts nocheck

* move flaggedAccounts to redux and address UI issues

* Rename warning message component

* only show "malicious" warning in case it's both "unsafe" && "malicious"

* default to the localStorage and replace with API if applicable

* matching error message spacing to createTransaction

* update UI of warning messages

* update stellar.expert url
  • Loading branch information
piyalbasu committed Jan 11, 2021
1 parent d544f30 commit 4bac41e
Show file tree
Hide file tree
Showing 13 changed files with 1,142 additions and 483 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
"trezor-connect": "^8.1.16",
"typescript": "~4.0.5"
},
"resolutions": {
"**/@typescript-eslint/eslint-plugin": "^4.1.1",
"**/@typescript-eslint/parser": "^4.1.1"
},
"scripts": {
"install-if-package-changed": "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep --quiet yarn.lock && yarn install || exit 0",
"start": "react-scripts start",
Expand Down
13 changes: 9 additions & 4 deletions src/components/BalanceInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,18 @@ const WarningEl = styled.p`
export const BalanceInfo = () => {
const dispatch = useDispatch();
const { account } = useRedux("account");
const { status, data, isAccountWatcherStarted } = account;
const { flaggedAccounts } = useRedux("flaggedAccounts");
const { status: accountStatus, data, isAccountWatcherStarted } = account;
const { status: flaggedAccountsStatus } = flaggedAccounts;
const [isSendTxModalVisible, setIsSendTxModalVisible] = useState(false);
const [isReceiveTxModalVisible, setIsReceiveTxModalVisible] = useState(false);
const publicAddress = data.id;

useEffect(() => {
if (status === ActionStatus.SUCCESS && !isAccountWatcherStarted) {
if (accountStatus === ActionStatus.SUCCESS && !isAccountWatcherStarted) {
dispatch(startAccountWatcherAction(publicAddress));
}
}, [dispatch, publicAddress, status, isAccountWatcherStarted]);
}, [dispatch, publicAddress, accountStatus, isAccountWatcherStarted]);

let nativeBalance = 0;

Expand Down Expand Up @@ -146,7 +148,10 @@ export const BalanceInfo = () => {
logEvent("send: clicked start send");
}}
icon={<IconSend />}
disabled={data.isUnfunded}
disabled={
data.isUnfunded ||
flaggedAccountsStatus !== ActionStatus.SUCCESS
}
>
Send
</Button>
Expand Down
12 changes: 12 additions & 0 deletions src/components/SendTransaction/ConfirmTransaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { sendTxAction } from "ducks/sendTx";
import { useRedux } from "hooks/useRedux";
import { ActionStatus, AuthType, PaymentFormData } from "types/types.d";

import { AccountIsUnsafe } from "./WarningMessages/AccountIsUnsafe";

const TableEl = styled.table`
width: 100%;
Expand Down Expand Up @@ -88,6 +90,10 @@ const InlineLoadingTextEl = styled.div`
margin-left: 0.5rem;
`;

const WarningMessageEl = styled.div`
margin: 1.5rem 0 0.5rem;
`;

interface ConfirmTransactionProps {
formData: PaymentFormData;
maxFee: string;
Expand Down Expand Up @@ -205,6 +211,12 @@ export const ConfirmTransaction = ({
<span>{formData.toAccountId}</span>
</AddressWrapperEl>
</td>

{formData.isAccountUnsafe && (
<WarningMessageEl>
<AccountIsUnsafe />
</WarningMessageEl>
)}
</tr>
<tr>
<th>Amount</th>
Expand Down
57 changes: 56 additions & 1 deletion src/components/SendTransaction/CreateTransaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
import { PALETTE } from "constants/styles";
import { knownAccounts } from "constants/knownAccounts";

import { AccountIsUnsafe } from "./WarningMessages/AccountIsUnsafe";

const RowEl = styled.div`
display: flex;
flex-wrap: wrap;
Expand Down Expand Up @@ -147,6 +149,11 @@ export const CreateTransaction = ({
initialFormData.isAccountFunded,
);

const [isAccountUnsafe, setIsAccountUnsafe] = useState(
initialFormData.isAccountUnsafe,
);
const [isAccountMalicious, setIsAccountMalicious] = useState(false);

const knownAccount =
knownAccounts[toAccountId] || knownAccounts[federationAddress || ""];
const [prevAddress, setPrevAddress] = useState(
Expand Down Expand Up @@ -251,6 +258,24 @@ export const CreateTransaction = ({
}
};

const { flaggedAccounts } = useRedux("flaggedAccounts");

const checkIfAccountIsFlagged = (accountId: string) => {
const flaggedTags = flaggedAccounts.data.reduce(
(prev: string[], { address, tags }) => {
return address === accountId ? [...prev, ...tags] : prev;
},
[],
);
setIsAccountUnsafe(flaggedTags.includes("unsafe"));
setIsAccountMalicious(flaggedTags.includes("malicious"));
};

const resetAccountIsFlagged = () => {
setIsAccountUnsafe(false);
setIsAccountMalicious(false);
};

const checkAndSetIsAccountFunded = async (accountId: string) => {
if (!accountId || !StrKey.isValidEd25519PublicKey(accountId)) {
setIsAccountFunded(true);
Expand Down Expand Up @@ -411,6 +436,7 @@ export const CreateTransaction = ({
memoType,
memoContent,
isAccountFunded,
isAccountUnsafe,
});
}
};
Expand All @@ -420,7 +446,9 @@ export const CreateTransaction = ({
headlineText="Send Lumens"
buttonFooter={
<>
<Button onClick={onSubmit}>Continue</Button>
<Button disabled={isAccountMalicious} onClick={onSubmit}>
Continue
</Button>
<Button onClick={onCancel} variant={ButtonVariant.secondary}>
Cancel
</Button>
Expand Down Expand Up @@ -464,6 +492,8 @@ export const CreateTransaction = ({

// Reset all errors (to make sure unfunded account error is cleared)
setInputErrors(initialInputErrors);

resetAccountIsFlagged();
}}
onBlur={(e) => {
validate(e);
Expand All @@ -478,6 +508,7 @@ export const CreateTransaction = ({

setPrevAddress(e.target.value);
setIsAccountIdTouched(false);
checkIfAccountIsFlagged(e.target.value);
}}
error={inputErrors[SendFormIds.SEND_TO]}
value={toAccountId}
Expand Down Expand Up @@ -515,6 +546,30 @@ export const CreateTransaction = ({
</RowEl>
)}

{isAccountUnsafe && !isAccountMalicious && (
<RowEl>
<AccountIsUnsafe />
</RowEl>
)}
{isAccountMalicious && (
<RowEl>
<InfoBlock variant={InfoBlockVariant.error}>
<p>
The account you’re sending to is tagged as{" "}
<strong>#malicious</strong> on{" "}
<a
href="https://stellar.expert/directory"
target="_blank"
rel="noopener noreferrer"
>
stellar.expert’s directory
</a>
. For your safety, sending to this account is disabled.
</p>
</InfoBlock>
</RowEl>
)}

<RowEl>
<CellEl>
<Input
Expand Down
1 change: 1 addition & 0 deletions src/components/SendTransaction/SendTransactionFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const initialFormData: PaymentFormData = {
memoType: MemoNone,
memoContent: "",
isAccountFunded: true,
isAccountUnsafe: false,
};

export const SendTransactionFlow = ({ onCancel }: { onCancel: () => void }) => {
Expand Down
19 changes: 19 additions & 0 deletions src/components/SendTransaction/WarningMessages/AccountIsUnsafe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from "react";

import { InfoBlock, InfoBlockVariant } from "components/basic/InfoBlock";

export const AccountIsUnsafe = () => (
<InfoBlock variant={InfoBlockVariant.warning}>
<p>
The account you’re sending to is tagged as <strong>#unsafe</strong> on{" "}
<a
href="https://stellar.expert/directory"
target="_blank"
rel="noopener noreferrer"
>
stellar.expert’s directory
</a>
. Proceed with caution.
</p>
</InfoBlock>
);
2 changes: 2 additions & 0 deletions src/config/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import BigNumber from "bignumber.js";
import { RESET_STORE_ACTION_TYPE } from "constants/settings";

import { reducer as account } from "ducks/account";
import { reducer as flaggedAccounts } from "ducks/flaggedAccounts";
import { reducer as keyStore } from "ducks/keyStore";
import { reducer as sendTx } from "ducks/sendTx";
import { reducer as settings } from "ducks/settings";
Expand All @@ -36,6 +37,7 @@ const isSerializable = (value: any) =>

const reducers = combineReducers({
account,
flaggedAccounts,
keyStore,
sendTx,
settings,
Expand Down
2 changes: 2 additions & 0 deletions src/constants/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import StellarSdk from "stellar-sdk";
export const TX_HISTORY_LIMIT = 100;
export const TX_HISTORY_MIN_AMOUNT = 0.5;
export const RESET_STORE_ACTION_TYPE = "RESET";
export const FLAGGED_ACCOUNT_STORAGE_ID = "flaggedAcounts";
export const FLAGGED_ACCOUNT_DATE_STORAGE_ID = "flaggedAcountDate";

interface NetworkItemConfig {
url: string;
Expand Down
68 changes: 68 additions & 0 deletions src/ducks/flaggedAccounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

import {
FLAGGED_ACCOUNT_STORAGE_ID,
FLAGGED_ACCOUNT_DATE_STORAGE_ID,
} from "constants/settings";
import { getFlaggedAccounts } from "helpers/getFlaggedAccounts";
import { ActionStatus, FlaggedAccounts } from "types/types.d";

const initialState: FlaggedAccounts = {
data: [{ address: "", tags: [""] }],
status: undefined,
};

export const fetchFlaggedAccountsAction = createAsyncThunk(
"action/fetchFlaggedAccountsAction",
async () => {
let accounts;
const date = new Date();
const time = date.getTime();
const sevenDaysAgo = time - 7 * 24 * 60 * 60 * 1000;
const flaggedAccountsCacheDate = Number(
localStorage.getItem(FLAGGED_ACCOUNT_DATE_STORAGE_ID),
);

accounts = JSON.parse(
localStorage.getItem(FLAGGED_ACCOUNT_STORAGE_ID) || "[]",
);

// if flaggedAccounts were last cached over seven days ago, make the request
// flaggedAccountsCacheDate is coerced to 0 if not found in storage
if (flaggedAccountsCacheDate < sevenDaysAgo) {
try {
accounts = await getFlaggedAccounts();
// store the accounts plus the date we've acquired them
localStorage.setItem(
FLAGGED_ACCOUNT_STORAGE_ID,
JSON.stringify(accounts),
);
localStorage.setItem(FLAGGED_ACCOUNT_DATE_STORAGE_ID, time.toString());
} catch (e) {
console.error("Flagged account API did not respond");
}
}

return accounts;
},
);

const flaggedAccountsSlice = createSlice({
name: "flaggedAccounts",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(
fetchFlaggedAccountsAction.pending,
(state = initialState) => {
state.status = ActionStatus.PENDING;
},
);
builder.addCase(fetchFlaggedAccountsAction.fulfilled, (state, action) => {
state.status = ActionStatus.SUCCESS;
state.data = action.payload;
});
},
});

export const { reducer } = flaggedAccountsSlice;
24 changes: 24 additions & 0 deletions src/helpers/getFlaggedAccounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const UNSAFE_ACCOUNTS_URL =
"https://api.stellar.expert/explorer/directory?limit=20000000&tag[]=malicious&tag[]=unsafe";
// setting limit very high as there doesn't appear to be a better way to get all entries from API
const RESPONSE_TIMEOUT = 5000;
// if API doesn't respond in this amount of time, we'll cancel the request

export const getFlaggedAccounts = async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RESPONSE_TIMEOUT);

const flaggedAccountsRes = await fetch(UNSAFE_ACCOUNTS_URL, {
signal: controller.signal,
});
clearTimeout(timeoutId);
const flaggedAccountsJson = await flaggedAccountsRes.json();

const {
_embedded: { records: unsafeAccountsData },
} = flaggedAccountsJson;

return unsafeAccountsData.map(
({ address, tags }: { address: string; tags: [] }) => ({ address, tags }),
);
};
6 changes: 5 additions & 1 deletion src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import { BalanceInfo } from "components/BalanceInfo";
import { TransactionHistory } from "components/TransactionHistory";
import { logEvent } from "helpers/tracking";
import { fetchFlaggedAccountsAction } from "ducks/flaggedAccounts";

const WrapperEl = styled.div`
width: 100%;
`;

export const Dashboard = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchFlaggedAccountsAction());
logEvent("page: saw account main screen");
}, []);
}, [dispatch]);

return (
<WrapperEl>
Expand Down
12 changes: 12 additions & 0 deletions src/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ export interface AccountInitialState {
errorString?: string;
}

interface FlaggedAccount {
address: string;
tags: string[];
}

export interface FlaggedAccounts {
data: [FlaggedAccount];
status: ActionStatus | undefined;
}

export interface KeyStoreInitialState {
keyStoreId: string;
password: string;
Expand Down Expand Up @@ -107,6 +117,7 @@ export interface WalletInitialState {

export interface Store {
account: AccountInitialState;
flaggedAccounts: FlaggedAccounts;
keyStore: KeyStoreInitialState;
knownAccounts: KnownAccountsInitialState;
sendTx: SendTxInitialState;
Expand Down Expand Up @@ -141,4 +152,5 @@ export interface PaymentFormData {
memoType: MemoType;
memoContent: MemoValue;
isAccountFunded: boolean;
isAccountUnsafe: boolean;
}
Loading

0 comments on commit 4bac41e

Please sign in to comment.