Skip to content

Commit

Permalink
Checks for sufficient balances after uploading a csv (#54)
Browse files Browse the repository at this point in the history
* warn about insufficient balance for the transfer

* new util methods to
** create summary map covering all transfers
** check for sufficient  balances of all tokens in a summary map

This commit is the first to use the web3 connection using the SafeProvider.
We do not need an Infuria key anymore because we connect with the SafeProvider.

Todo: Tests

Small changes:
* Header placement and gap between multiple warnings

* add testcases

* testcase for creating transfer summary
* testcase for invalid csvs for 100% coverage on parser.ts

* Fix linter error

* fixe review issues

* comparing balances in wei to avoid precision loss
* some format issues / tiny refactorings

* handle errors while fetching balance for erc20

* Errors are logged and balances are returned as invalid (-1)

Important change:
* typings for our web3 calls using TypeChain project
* types are generated in postinstall yarn script

Co-authored-by: schmanu <[email protected]>
  • Loading branch information
schmanu and schmanu authored May 17, 2021
1 parent 541f41b commit 917cf5a
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 25 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ yarn-error.log*

#vscode environment
/.vscode

# auto-generated code
/src/contracts
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"eject": "react-scripts eject",
"fmt": "prettier --check '**/*.ts'",
"fmt:write": "prettier --write '**/*.ts'",
"prepare": "husky install"
"prepare": "husky install",
"generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/IERC20.json'",
"postinstall": "yarn generate-types"
},
"dependencies": {
"@fast-csv/parse": "^4.3.6",
Expand Down Expand Up @@ -40,12 +42,16 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^5.11.4",
"@typechain/ethers-v5": "^7.0.0",
"@types/chai": "^4.2.18",
"@types/chai-as-promised": "^7.1.4",
"@types/node": "^14.14.45",
"@types/react": "^17.0.5",
"@types/react-dom": "^17.0.5",
"@types/styled-components": "^5.1.2",
"babel-eslint": "^10.1.0",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"eslint-config-prettier": "^8.3.0",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-flowtype": "^5.7.2",
Expand All @@ -56,7 +62,8 @@
"eslint-plugin-react-hooks": "^4.2.0",
"husky": "^6.0.0",
"prettier": "^2.3.0",
"pretty-quick": "^3.1.0"
"pretty-quick": "^3.1.0",
"typechain": "^5.0.0"
},
"browserslist": {
"production": [
Expand Down
38 changes: 25 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider";
import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk";
// TODO - Will need for web3Provider
// import { useMemo } from "react";
// import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider";
// import { ethers } from "ethers";
import { Loader, Text } from "@gnosis.pm/safe-react-components";
import React, { useCallback, useState, useContext } from "react";
import { ethers } from "ethers";
import React, { useCallback, useState, useContext, useMemo } from "react";
import styled from "styled-components";

import { CSVForm } from "./components/CSVForm";
Expand All @@ -13,37 +11,51 @@ import { MessageContext } from "./contexts/MessageContextProvider";
import { useTokenList } from "./hooks/tokenList";
import { parseCSV, Payment } from "./parser";
import { buildTransfers } from "./transfers";
import { checkAllBalances, transfersToSummary } from "./utils";

const App: React.FC = () => {
const { sdk } = useSafeAppsSDK();
const { safe, sdk } = useSafeAppsSDK();

const { tokenList, isLoading } = useTokenList();
const [submitting, setSubmitting] = useState(false);
const [transferContent, setTransferContent] = useState<Payment[]>([]);
const [csvText, setCsvText] = useState<string>(
"token_address,receiver,amount,decimals"
);
const { addMessage, setCodeWarnings } = useContext(MessageContext);
const { addMessage, setCodeWarnings, setMessages } =
useContext(MessageContext);

// const web3Provider = useMemo(
// () => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)),
// [sdk, safe]
// );
const web3Provider = useMemo(
() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)),
[sdk, safe]
);

const onChangeTextHandler = useCallback(
async (csvText: string) => {
(csvText: string) => {
setCsvText(csvText);
// Parse CSV
const parsePromise = parseCSV(csvText, tokenList);
parsePromise
.then(([transfers, warnings]) => {
console.log("CSV parsed!");
const summary = transfersToSummary(transfers);
checkAllBalances(summary, web3Provider, safe, tokenList).then(
(insufficientBalances) =>
setMessages(
insufficientBalances.map((insufficientBalanceInfo) => ({
message: `Insufficient Balance: ${insufficientBalanceInfo.transferAmount} of ${insufficientBalanceInfo.token}`,
severity: "warning",
}))
)
);
setTransferContent(transfers);
setCodeWarnings(warnings);
})
.catch((reason: any) =>
addMessage({ severity: "error", message: reason.message })
);
},
[addMessage, setCodeWarnings, tokenList]
[addMessage, safe, setCodeWarnings, setMessages, tokenList, web3Provider]
);

const submitTx = useCallback(async () => {
Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
import { TokenInfo } from "@uniswap/token-lists";
import { BigNumber } from "bignumber.js";
import * as chai from "chai";
import { expect } from "chai";
import chaiAsPromised from "chai-as-promised";

import { TokenMap, fetchTokenList } from "../hooks/tokenList";
import { parseCSV } from "../parser";
Expand All @@ -14,6 +16,9 @@ let listedToken: TokenInfo;
const validReceiverAddress = testData.addresses.receiver1;
const unlistedTokenAddress = testData.unlistedToken.address;

// this lets us handle expectations on Promises.
chai.use(chaiAsPromised);

/**
* concatenates csv row arrays into one string.
* @param rows array of row-arrays
Expand All @@ -29,6 +34,15 @@ describe("Parsing CSVs ", () => {
listedTokens = Array.from(tokenList.keys());
listedToken = tokenList.get(listedTokens[0]);
});

it("should throw errors for invalid CSVs", async () => {
// thins csv contains more values than headers in row1
const invalidCSV = "head1,header2\nvalue1,value2,value3";
expect(parseCSV(invalidCSV, tokenList)).to.be.rejectedWith(
"column header mismatch expected: 2 columns got: 3"
);
});

it("should transform simple, valid CSVs correctly", async () => {
const rowWithoutDecimal = [listedToken.address, validReceiverAddress, "1"];
const rowWithDecimal = [
Expand Down
133 changes: 132 additions & 1 deletion src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { BigNumber } from "bignumber.js";
import { expect } from "chai";

import { fromWei, toWei, TEN, ONE, ZERO } from "../utils";
import { Payment } from "../parser";
import { testData } from "../test/util";
import { fromWei, toWei, TEN, ONE, ZERO, transfersToSummary } from "../utils";

// TODO - this is super ugly at the moment and is probably missing some stuff.
describe("toWei()", () => {
Expand Down Expand Up @@ -40,3 +42,132 @@ describe("fromWei()", () => {
expect(fromWei(oneETH, 20).toFixed()).to.be.equal("0.01");
});
});

describe("transerToSummary()", () => {
it("works for integer native currency", () => {
const transfers: Payment[] = [
{
tokenAddress: null,
amount: new BigNumber(1),
receiver: testData.addresses.receiver1,
},
{
tokenAddress: null,
amount: new BigNumber(2),
receiver: testData.addresses.receiver2,
},
{
tokenAddress: null,
amount: new BigNumber(3),
receiver: testData.addresses.receiver3,
},
];
const summary = transfersToSummary(transfers);
expect(summary.get(null).amount.toFixed()).to.equal("6");
});

it("works for decimals in native currency", () => {
const transfers: Payment[] = [
{
tokenAddress: null,
amount: new BigNumber(0.1),
receiver: testData.addresses.receiver1,
},
{
tokenAddress: null,
amount: new BigNumber(0.01),
receiver: testData.addresses.receiver2,
},
{
tokenAddress: null,
amount: new BigNumber(0.001),
receiver: testData.addresses.receiver3,
},
];
const summary = transfersToSummary(transfers);
expect(summary.get(null).amount.toFixed()).to.equal("0.111");
});

it("works for decimals in erc20", () => {
const transfers: Payment[] = [
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(0.1),
receiver: testData.addresses.receiver1,
},
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(0.01),
receiver: testData.addresses.receiver2,
},
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(0.001),
receiver: testData.addresses.receiver3,
},
];
const summary = transfersToSummary(transfers);
expect(
summary.get(testData.unlistedToken.address).amount.toFixed()
).to.equal("0.111");
});

it("works for integer in erc20", () => {
const transfers: Payment[] = [
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(1),
receiver: testData.addresses.receiver1,
},
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(2),
receiver: testData.addresses.receiver2,
},
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(3),
receiver: testData.addresses.receiver3,
},
];
const summary = transfersToSummary(transfers);
expect(
summary.get(testData.unlistedToken.address).amount.toFixed()
).to.equal("6");
});

it("works for mixed payments", () => {
const transfers: Payment[] = [
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(1.1),
receiver: testData.addresses.receiver1,
},
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(2),
receiver: testData.addresses.receiver2,
},
{
tokenAddress: testData.unlistedToken.address,
amount: new BigNumber(3.3),
receiver: testData.addresses.receiver3,
},
{
tokenAddress: null,
amount: new BigNumber(3),
receiver: testData.addresses.receiver1,
},
{
tokenAddress: null,
amount: new BigNumber(0.33),
receiver: testData.addresses.receiver1,
},
];
const summary = transfersToSummary(transfers);
expect(
summary.get(testData.unlistedToken.address).amount.toFixed()
).to.equal("6.4");
expect(summary.get(null).amount.toFixed()).to.equal("3.33");
});
});
15 changes: 12 additions & 3 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ const HeaderContainer = styled.div`
width: 100%;
`;

const AlertWrapper = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
`;

export const Header = (): JSX.Element => {
const messageContext = useContext(MessageContext);
const messages = messageContext.messages;
Expand All @@ -22,12 +30,13 @@ export const Header = (): JSX.Element => {
<Title size="md">CSV Airdrop</Title>
{messages?.length > 0 && (
<Snackbar
anchorOrigin={{ vertical: "top", horizontal: "center" }}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
open={messages?.length > 0}
onClose={() => messageContext.setMessages([])}
autoHideDuration={6000}
style={{ gap: "4px" }}
>
<div>
<AlertWrapper>
{messages.map((message: Message, index: number) => (
<Alert
severity={message.severity}
Expand All @@ -37,7 +46,7 @@ export const Header = (): JSX.Element => {
{message.message}
</Alert>
))}
</div>
</AlertWrapper>
</Snackbar>
)}
</HeaderContainer>
Expand Down
9 changes: 5 additions & 4 deletions src/erc20.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import IERC20 from "@openzeppelin/contracts/build/contracts/IERC20.json";
import { ethers } from "ethers";

export const erc20Interface = new ethers.utils.Interface(IERC20.abi);
import { IERC20, IERC20__factory } from "./contracts";

export const erc20Interface = IERC20__factory.createInterface();

export function erc20Instance(
address: string,
provider: ethers.providers.Provider
): ethers.Contract {
return new ethers.Contract(address, erc20Interface, provider);
): IERC20 {
return IERC20__factory.connect(address, provider);
}
Loading

0 comments on commit 917cf5a

Please sign in to comment.