diff --git a/src/App.tsx b/src/App.tsx
index f301d40d..366d3432 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,14 +1,15 @@
import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk";
import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk";
-import { Button, Card, Divider, Loader, Text } from "@gnosis.pm/safe-react-components";
+import { Breadcrumb, BreadcrumbElement, Button, Card, Divider, Loader, Text } from "@gnosis.pm/safe-react-components";
import { setUseWhatChange } from "@simbathesailor/use-what-changed";
import React, { useCallback, useState } from "react";
import styled from "styled-components";
+import { CSVForm } from "./components/CSVForm";
import { FAQModal } from "./components/FAQModal";
import { Header } from "./components/Header";
import { Summary } from "./components/Summary";
-import { CSVForm } from "./components/assets/CSVForm";
+import { useBalances } from "./hooks/balances";
import { useTokenList } from "./hooks/token";
import { AssetTransfer, CollectibleTransfer, Transfer } from "./parser/csvParser";
import { buildAssetTransfers, buildCollectibleTransfers } from "./transfers/transfers";
@@ -17,6 +18,7 @@ setUseWhatChange(process.env.NODE_ENV === "development");
const App: React.FC = () => {
const { isLoading } = useTokenList();
+ const balanceLoader = useBalances();
const [tokenTransfers, setTokenTransfers] = useState([]);
const [submitting, setSubmitting] = useState(false);
@@ -52,15 +54,34 @@ const App: React.FC = () => {
{
<>
- {isLoading ? (
+ {isLoading || balanceLoader.isLoading ? (
<>
-
- Loading Tokenlist...
+
+
+ Loading tokenlist and balances...
+
+
+
>
) : (
+
+
+
+
+
+
+
{submitting ? (
<>
diff --git a/src/GlobalStyle.ts b/src/GlobalStyle.ts
index 286ea78d..68796994 100644
--- a/src/GlobalStyle.ts
+++ b/src/GlobalStyle.ts
@@ -68,6 +68,31 @@ const GlobalStyle = createGlobalStyle`
gap: 16px;
width: 100%;
}
+
+ .leftAlignedMenu {
+ padding-top: 4px;
+ justify-content: flex-start;
+ padding-bottom: 4px;
+ }
+
+ .openedGenerateMenu {
+ padding-left: 12px;
+ }
+
+ .generateMenu {
+ width: 160px;
+ border-color: rgb(247, 245, 245);
+ border-radius: 8px;
+ }
+
+ .generateMenu button {
+ padding: 4px;
+ }
+
+ .generateMenu button:hover {
+ background-color: rgb(247, 245, 245);
+ border-radius: 8px;
+ }
`;
export default GlobalStyle;
diff --git a/src/__tests__/balanceCheck.test.ts b/src/__tests__/balanceCheck.test.ts
new file mode 100644
index 00000000..8f6409ff
--- /dev/null
+++ b/src/__tests__/balanceCheck.test.ts
@@ -0,0 +1,569 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions */
+import BigNumber from "bignumber.js";
+import { expect } from "chai";
+
+import { AssetBalance, CollectibleBalance } from "../hooks/balances";
+import { assetTransfersToSummary, checkAllBalances } from "../parser/balanceCheck";
+import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser";
+import { testData } from "../test/util";
+import { toWei } from "../utils";
+
+describe("transferToSummary and check balances", () => {
+ it("works for integer native currency", () => {
+ const transfers: AssetTransfer[] = [
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(1),
+ receiver: testData.addresses.receiver1,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(2),
+ receiver: testData.addresses.receiver2,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(3),
+ receiver: testData.addresses.receiver3,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ ];
+ const summary = assetTransfersToSummary(transfers);
+ expect(summary.get(null)?.amount.toFixed()).to.equal("6");
+
+ const exactBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("6", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const biggerBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("7", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const smallerBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("5.999999999999", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+
+ expect(checkAllBalances(exactBalance, undefined, transfers)).to.be.empty;
+ expect(checkAllBalances(biggerBalance, undefined, transfers)).to.be.empty;
+ const smallBalanceCheckResult = checkAllBalances(smallerBalance, undefined, transfers);
+ expect(smallBalanceCheckResult).to.have.length(1);
+ expect(smallBalanceCheckResult[0].token).to.equal("ETH");
+ expect(smallBalanceCheckResult[0].token_type).to.equal("native");
+ expect(smallBalanceCheckResult[0].transferAmount).to.equal("6");
+ });
+
+ it("works for decimals in native currency", () => {
+ const transfers: AssetTransfer[] = [
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(0.1),
+ receiver: testData.addresses.receiver1,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(0.01),
+ receiver: testData.addresses.receiver2,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(0.001),
+ receiver: testData.addresses.receiver3,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ ];
+ const summary = assetTransfersToSummary(transfers);
+ expect(summary.get(null)?.amount.toFixed()).to.equal("0.111");
+
+ const exactBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("0.111", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const biggerBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("0.1111", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const smallerBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("0.11", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+
+ expect(checkAllBalances(exactBalance, undefined, transfers)).to.be.empty;
+ expect(checkAllBalances(biggerBalance, undefined, transfers)).to.be.empty;
+ const smallBalanceCheckResult = checkAllBalances(smallerBalance, undefined, transfers);
+ expect(smallBalanceCheckResult).to.have.length(1);
+ expect(smallBalanceCheckResult[0].token).to.equal("ETH");
+ expect(smallBalanceCheckResult[0].token_type).to.equal("native");
+ expect(smallBalanceCheckResult[0].transferAmount).to.equal("0.111");
+ });
+
+ it("works for decimals in erc20", () => {
+ const transfers: AssetTransfer[] = [
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(0.1),
+ receiver: testData.addresses.receiver1,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(0.01),
+ receiver: testData.addresses.receiver2,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(0.001),
+ receiver: testData.addresses.receiver3,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ ];
+ const summary = assetTransfersToSummary(transfers);
+ expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("0.111");
+
+ const exactBalance: AssetBalance = [
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("0.111", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const biggerBalance: AssetBalance = [
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("0.1111", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const smallerBalance: AssetBalance = [
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("0.11", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+
+ expect(checkAllBalances(exactBalance, undefined, transfers)).to.be.empty;
+ expect(checkAllBalances(biggerBalance, undefined, transfers)).to.be.empty;
+ const smallBalanceCheckResult = checkAllBalances(smallerBalance, undefined, transfers);
+ expect(smallBalanceCheckResult).to.have.length(1);
+ expect(smallBalanceCheckResult[0].token).to.equal("ULT");
+ expect(smallBalanceCheckResult[0].token_type).to.equal("erc20");
+ expect(smallBalanceCheckResult[0].transferAmount).to.equal("0.111");
+ });
+
+ it("works for integer in erc20", () => {
+ const transfers: AssetTransfer[] = [
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(1),
+ receiver: testData.addresses.receiver1,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(2),
+ receiver: testData.addresses.receiver2,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(3),
+ receiver: testData.addresses.receiver3,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ ];
+ const summary = assetTransfersToSummary(transfers);
+ expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("6");
+
+ const exactBalance: AssetBalance = [
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("6", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const biggerBalance: AssetBalance = [
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("7", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const smallerBalance: AssetBalance = [
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("5.999999999999", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+
+ expect(checkAllBalances(exactBalance, undefined, transfers)).to.be.empty;
+ expect(checkAllBalances(biggerBalance, undefined, transfers)).to.be.empty;
+ const smallBalanceCheckResult = checkAllBalances(smallerBalance, undefined, transfers);
+ expect(smallBalanceCheckResult).to.have.length(1);
+ expect(smallBalanceCheckResult[0].token).to.equal("ULT");
+ expect(smallBalanceCheckResult[0].token_type).to.equal("erc20");
+ expect(smallBalanceCheckResult[0].transferAmount).to.equal("6");
+ });
+
+ it("works for mixed payments", () => {
+ const transfers: AssetTransfer[] = [
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(1.1),
+ receiver: testData.addresses.receiver1,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(2),
+ receiver: testData.addresses.receiver2,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "erc20",
+ tokenAddress: testData.unlistedERC20Token.address,
+ amount: new BigNumber(3.3),
+ receiver: testData.addresses.receiver3,
+ decimals: 18,
+ symbol: "ULT",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(3),
+ receiver: testData.addresses.receiver1,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ {
+ token_type: "native",
+ tokenAddress: null,
+ amount: new BigNumber(0.33),
+ receiver: testData.addresses.receiver1,
+ decimals: 18,
+ symbol: "ETH",
+ receiverEnsName: null,
+ },
+ ];
+ const summary = assetTransfersToSummary(transfers);
+ expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("6.4");
+ expect(summary.get(null)?.amount.toFixed()).to.equal("3.33");
+
+ const exactBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("3.33", 18).toFixed(),
+ decimals: 18,
+ },
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("6.4", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const biggerBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("3.34", 18).toFixed(),
+ decimals: 18,
+ },
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("6.5", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+ const smallerBalance: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("3.32", 18).toFixed(),
+ decimals: 18,
+ },
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("6.3", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+
+ const lessNativeMoreErc20: AssetBalance = [
+ {
+ token: null,
+ tokenAddress: null,
+ balance: toWei("3.32", 18).toFixed(),
+ decimals: 18,
+ },
+ {
+ token: {
+ decimals: 18,
+ symbol: "ULT",
+ name: "Unlisted Token",
+ },
+ tokenAddress: testData.unlistedERC20Token.address,
+ balance: toWei("69", 18).toFixed(),
+ decimals: 18,
+ },
+ ];
+
+ expect(checkAllBalances(exactBalance, undefined, transfers)).to.be.empty;
+ expect(checkAllBalances(biggerBalance, undefined, transfers)).to.be.empty;
+ const smallBalanceCheckResult = checkAllBalances(smallerBalance, undefined, transfers);
+ expect(smallBalanceCheckResult).to.have.length(2);
+ expect(smallBalanceCheckResult[0].token).to.equal("ULT");
+ expect(smallBalanceCheckResult[0].token_type).to.equal("erc20");
+ expect(smallBalanceCheckResult[0].transferAmount).to.equal("6.4");
+ expect(smallBalanceCheckResult[0].isDuplicate).to.be.false;
+
+ expect(smallBalanceCheckResult[1].token).to.equal("ETH");
+ expect(smallBalanceCheckResult[1].token_type).to.equal("native");
+ expect(smallBalanceCheckResult[1].transferAmount).to.equal("3.33");
+ expect(smallBalanceCheckResult[1].isDuplicate).to.be.false;
+
+ const lessNativeMoreErc20CheckResult = checkAllBalances(lessNativeMoreErc20, undefined, transfers);
+ expect(lessNativeMoreErc20CheckResult).to.have.length(1);
+ expect(lessNativeMoreErc20CheckResult[0].token).to.equal("ETH");
+ expect(lessNativeMoreErc20CheckResult[0].token_type).to.equal("native");
+ expect(lessNativeMoreErc20CheckResult[0].transferAmount).to.equal("3.33");
+ expect(lessNativeMoreErc20CheckResult[0].isDuplicate).to.be.false;
+ });
+
+ it("balance check works for erc721 tokens", () => {
+ const transfers: CollectibleTransfer[] = [
+ {
+ token_type: "erc721",
+ tokenAddress: testData.unlistedERC20Token.address,
+ tokenId: new BigNumber(69),
+ receiver: testData.addresses.receiver1,
+ tokenName: "Test Collectible",
+ receiverEnsName: null,
+ hasMetaData: false,
+ from: testData.addresses.receiver2,
+ },
+ {
+ token_type: "erc721",
+ tokenAddress: testData.unlistedERC20Token.address,
+ tokenId: new BigNumber(420),
+ receiver: testData.addresses.receiver1,
+ tokenName: "Test Collectible",
+ receiverEnsName: null,
+ hasMetaData: false,
+ from: testData.addresses.receiver2,
+ },
+ ];
+
+ const exactBalance: CollectibleBalance = [
+ {
+ address: testData.unlistedERC20Token.address,
+ id: "69",
+ tokenName: "Test Collectible",
+ tokenSymbol: "TC",
+ },
+ {
+ address: testData.unlistedERC20Token.address,
+ id: "420",
+ tokenName: "Test Collectible",
+ tokenSymbol: "TC",
+ },
+ ];
+ const biggerBalance: CollectibleBalance = [
+ {
+ address: testData.unlistedERC20Token.address,
+ id: "69",
+ tokenName: "Test Collectible",
+ tokenSymbol: "TC",
+ },
+ {
+ address: testData.unlistedERC20Token.address,
+ id: "420",
+ tokenName: "Test Collectible",
+ tokenSymbol: "TC",
+ },
+ {
+ address: testData.unlistedERC20Token.address,
+ id: "42069",
+ tokenName: "Test Collectible",
+ tokenSymbol: "TC",
+ },
+ ];
+ const smallerBalance: CollectibleBalance = [
+ {
+ address: testData.unlistedERC20Token.address,
+ id: "69",
+ tokenName: "Test Collectible",
+ tokenSymbol: "TC",
+ },
+ ];
+
+ expect(checkAllBalances(undefined, exactBalance, transfers)).to.be.empty;
+ expect(checkAllBalances(undefined, biggerBalance, transfers)).to.be.empty;
+ const smallBalanceCheckResult = checkAllBalances(undefined, smallerBalance, transfers);
+ expect(smallBalanceCheckResult).to.have.length(1);
+ expect(smallBalanceCheckResult[0].token).to.equal("Test Collectible");
+ expect(smallBalanceCheckResult[0].token_type).to.equal("erc721");
+ expect(smallBalanceCheckResult[0].id?.toFixed()).to.equal("420");
+ expect(smallBalanceCheckResult[0].transferAmount).to.be.undefined;
+ expect(smallBalanceCheckResult[0].isDuplicate).to.be.false;
+ });
+
+ it("detects duplicate transfers for erc721 tokens", () => {
+ const transfers: CollectibleTransfer[] = [
+ {
+ token_type: "erc721",
+ tokenAddress: testData.unlistedERC20Token.address,
+ tokenId: new BigNumber(69),
+ receiver: testData.addresses.receiver1,
+ receiverEnsName: null,
+ hasMetaData: false,
+ from: testData.addresses.receiver2,
+ },
+ {
+ token_type: "erc721",
+ tokenAddress: testData.unlistedERC20Token.address,
+ tokenId: new BigNumber(69),
+ receiver: testData.addresses.receiver2,
+ receiverEnsName: null,
+ hasMetaData: false,
+ from: testData.addresses.receiver2,
+ },
+ ];
+
+ const exactBalance: CollectibleBalance = [
+ {
+ address: testData.unlistedERC20Token.address,
+ id: "69",
+ tokenName: "Test Collectible",
+ tokenSymbol: "TC",
+ },
+ ];
+
+ const balanceCheckResult = checkAllBalances(undefined, exactBalance, transfers);
+ expect(balanceCheckResult).to.have.length(1);
+ expect(balanceCheckResult[0].token).to.equal("Test Collectible");
+ expect(balanceCheckResult[0].token_type).to.equal("erc721");
+ expect(balanceCheckResult[0].id?.toFixed()).to.equal("69");
+ expect(balanceCheckResult[0].transferAmount).to.undefined;
+ expect(balanceCheckResult[0].isDuplicate).to.be.true;
+ });
+});
diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts
index 8d1bceaf..193cd66c 100644
--- a/src/__tests__/parser.test.ts
+++ b/src/__tests__/parser.test.ts
@@ -108,6 +108,15 @@ describe("Parsing CSVs ", () => {
).to.be.rejectedWith("column header mismatch expected: 2 columns got: 3");
});
+ it("should skip files with >400 lines of transfers", async () => {
+ let largeCSV = csvStringFromRows(...Array(401).fill(["erc20", listedToken.address, validReceiverAddress, "1"]));
+ expect(
+ CSVParser.parseCSV(largeCSV, mockTokenInfoProvider, mockCollectibleTokenInfoProvider, mockEnsResolver),
+ ).to.be.rejectedWith(
+ "Max number of lines exceeded. Due to the block gas limit transactions are limited to 400 lines.",
+ );
+ });
+
it("should throw errors for unexpected errors while parsing", async () => {
// we hard coded in our mock that a ens of "error.eth" throws an error.
const rowWithErrorReceiver = ["erc20", listedToken.address, "error.eth", "1"];
@@ -435,13 +444,9 @@ describe("Parsing CSVs ", () => {
expect(payment).to.be.empty;
expect(warningWithInvalidTokenType.lineNo).to.equal(1);
- expect(warningWithInvalidTokenType.message).to.equal(
- "Unknown token_type: Must be one of erc20, native, erc721, erc1155",
- );
+ expect(warningWithInvalidTokenType.message).to.equal("Unknown token_type: Must be one of erc20, native or nft");
expect(warningWithMissingTokenType.lineNo).to.equal(2);
- expect(warningWithMissingTokenType.message).to.equal(
- "Unknown token_type: Must be one of erc20, native, erc721, erc1155",
- );
+ expect(warningWithMissingTokenType.message).to.equal("Unknown token_type: Must be one of erc20, native or nft");
});
});
diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts
index d8766c91..5d6950dc 100644
--- a/src/__tests__/utils.test.ts
+++ b/src/__tests__/utils.test.ts
@@ -1,9 +1,7 @@
import { BigNumber } from "bignumber.js";
import { expect } from "chai";
-import { AssetTransfer } from "../parser/csvParser";
-import { testData } from "../test/util";
-import { fromWei, toWei, TEN, ONE, ZERO, transfersToSummary } from "../utils";
+import { fromWei, toWei, TEN, ONE, ZERO } from "../utils";
// TODO - this is super ugly at the moment and is probably missing some stuff.
describe("toWei()", () => {
@@ -40,194 +38,3 @@ describe("fromWei()", () => {
expect(fromWei(oneETH, 20).toFixed()).to.be.equal("0.01");
});
});
-
-describe("transferToSummary()", () => {
- it("works for integer native currency", () => {
- const transfers: AssetTransfer[] = [
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(1),
- receiver: testData.addresses.receiver1,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(2),
- receiver: testData.addresses.receiver2,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(3),
- receiver: testData.addresses.receiver3,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- ];
- const summary = transfersToSummary(transfers);
- expect(summary.get(null)?.amount.toFixed()).to.equal("6");
- });
-
- it("works for decimals in native currency", () => {
- const transfers: AssetTransfer[] = [
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(0.1),
- receiver: testData.addresses.receiver1,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(0.01),
- receiver: testData.addresses.receiver2,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(0.001),
- receiver: testData.addresses.receiver3,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- ];
- const summary = transfersToSummary(transfers);
- expect(summary.get(null)?.amount.toFixed()).to.equal("0.111");
- });
-
- it("works for decimals in erc20", () => {
- const transfers: AssetTransfer[] = [
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(0.1),
- receiver: testData.addresses.receiver1,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(0.01),
- receiver: testData.addresses.receiver2,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(0.001),
- receiver: testData.addresses.receiver3,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- ];
- const summary = transfersToSummary(transfers);
- expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("0.111");
- });
-
- it("works for integer in erc20", () => {
- const transfers: AssetTransfer[] = [
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(1),
- receiver: testData.addresses.receiver1,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(2),
- receiver: testData.addresses.receiver2,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(3),
- receiver: testData.addresses.receiver3,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- ];
- const summary = transfersToSummary(transfers);
- expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("6");
- });
-
- it("works for mixed payments", () => {
- const transfers: AssetTransfer[] = [
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(1.1),
- receiver: testData.addresses.receiver1,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(2),
- receiver: testData.addresses.receiver2,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- {
- token_type: "erc20",
- tokenAddress: testData.unlistedERC20Token.address,
- amount: new BigNumber(3.3),
- receiver: testData.addresses.receiver3,
- decimals: 18,
- symbol: "ULT",
- receiverEnsName: null,
- },
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(3),
- receiver: testData.addresses.receiver1,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- {
- token_type: "native",
- tokenAddress: null,
- amount: new BigNumber(0.33),
- receiver: testData.addresses.receiver1,
- decimals: 18,
- symbol: "ETH",
- receiverEnsName: null,
- },
- ];
- const summary = transfersToSummary(transfers);
- expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("6.4");
- expect(summary.get(null)?.amount.toFixed()).to.equal("3.33");
- });
-});
diff --git a/src/components/assets/CSVForm.tsx b/src/components/CSVForm.tsx
similarity index 60%
rename from src/components/assets/CSVForm.tsx
rename to src/components/CSVForm.tsx
index 68a57a0b..8db4f072 100644
--- a/src/components/assets/CSVForm.tsx
+++ b/src/components/CSVForm.tsx
@@ -1,19 +1,19 @@
-import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider";
-import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk";
import { Text } from "@gnosis.pm/safe-react-components";
-import { ethers } from "ethers";
import debounce from "lodash.debounce";
import React, { useContext, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
-import { MessageContext } from "../../contexts/MessageContextProvider";
-import { useCollectibleTokenInfoProvider } from "../../hooks/collectibleTokenInfoProvider";
-import { useEnsResolver } from "../../hooks/ens";
-import { useTokenInfoProvider } from "../../hooks/token";
-import { AssetTransfer, CSVParser, Transfer } from "../../parser/csvParser";
-import { checkAllBalances, transfersToSummary } from "../../utils";
-import { CSVEditor } from "../CSVEditor";
-import { CSVUpload } from "../CSVUpload";
+import { MessageContext } from "../contexts/MessageContextProvider";
+import { useBalances } from "../hooks/balances";
+import { useCollectibleTokenInfoProvider } from "../hooks/collectibleTokenInfoProvider";
+import { useEnsResolver } from "../hooks/ens";
+import { useTokenInfoProvider } from "../hooks/token";
+import { checkAllBalances } from "../parser/balanceCheck";
+import { CSVParser, Transfer } from "../parser/csvParser";
+
+import { CSVEditor } from "./CSVEditor";
+import { CSVUpload } from "./CSVUpload";
+import { GenerateTransfersMenu } from "./GenerateTransfersMenu";
const Form = styled.div`
flex: 1;
@@ -33,8 +33,7 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => {
const { setCodeWarnings, setMessages } = useContext(MessageContext);
- const { safe, sdk } = useSafeAppsSDK();
- const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [safe, sdk]);
+ const { assetBalance, collectibleBalance } = useBalances();
const tokenInfoProvider = useTokenInfoProvider();
const ensResolver = useEnsResolver();
const erc721TokenInfoProvider = useCollectibleTokenInfoProvider();
@@ -47,6 +46,7 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => {
() =>
debounce((csvText: string) => {
setParsing(true);
+
CSVParser.parseCSV(csvText, tokenInfoProvider, erc721TokenInfoProvider, ensResolver)
.then(async ([transfers, warnings]) => {
const uniqueReceiversWithoutEnsName = transfers.reduce(
@@ -70,21 +70,37 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => {
);
}
transfers = transfers.map((transfer, idx) => ({ ...transfer, position: idx + 1 }));
- const summary = transfersToSummary(
- transfers.filter(
- (value) => value.token_type === "erc20" || value.token_type === "native",
- ) as AssetTransfer[],
- );
updateTransferTable(transfers);
-
- checkAllBalances(summary, web3Provider, safe).then((insufficientBalances) =>
+ // If we have no balances we dont need to check them.
+ if (assetBalance || collectibleBalance) {
+ const insufficientBalances = checkAllBalances(assetBalance, collectibleBalance, transfers);
setMessages(
- insufficientBalances.map((insufficientBalanceInfo) => ({
- message: `Insufficient Balance: ${insufficientBalanceInfo.transferAmount} of ${insufficientBalanceInfo.token}`,
- severity: "warning",
- })),
- ),
- );
+ insufficientBalances.map((insufficientBalanceInfo) => {
+ if (
+ insufficientBalanceInfo.token_type === "erc20" ||
+ insufficientBalanceInfo.token_type === "native"
+ ) {
+ return {
+ message: `Insufficient Balance: ${insufficientBalanceInfo.transferAmount} of ${insufficientBalanceInfo.token}`,
+ severity: "warning",
+ };
+ } else {
+ if (insufficientBalanceInfo.isDuplicate) {
+ return {
+ message: `Duplicate transfer for ERC721 token ${insufficientBalanceInfo.token} with ID ${insufficientBalanceInfo.id}`,
+ severity: "warning",
+ };
+ } else {
+ return {
+ message: `Collectible ERC721 token ${insufficientBalanceInfo.token} with ID ${insufficientBalanceInfo.id} is not held by this safe`,
+ severity: "warning",
+ };
+ }
+ }
+ }),
+ );
+ }
+
setCodeWarnings(warnings);
setParsing(false);
})
@@ -93,13 +109,13 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => {
[
ensResolver,
erc721TokenInfoProvider,
- safe,
+ assetBalance,
+ collectibleBalance,
setCodeWarnings,
setMessages,
setParsing,
tokenInfoProvider,
updateTransferTable,
- web3Provider,
],
);
@@ -120,6 +136,11 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => {
+
);
};
diff --git a/src/components/CSVUpload.tsx b/src/components/CSVUpload.tsx
index f83871ce..36fdc808 100644
--- a/src/components/CSVUpload.tsx
+++ b/src/components/CSVUpload.tsx
@@ -1,4 +1,4 @@
-import { Button, Link, Text, theme as GnosisTheme } from "@gnosis.pm/safe-react-components";
+import { Button, Text, theme as GnosisTheme } from "@gnosis.pm/safe-react-components";
import { createStyles } from "@material-ui/core";
import React, { useCallback, useMemo } from "react";
import { useDropzone } from "react-dropzone";
@@ -62,11 +62,6 @@ export const CSVUpload = (props: CSVUploadProps): JSX.Element => {
-
-
- Sample Transfer File
-
-
);
};
diff --git a/src/components/FAQModal.tsx b/src/components/FAQModal.tsx
index 9aeca4bc..d1fafb3a 100644
--- a/src/components/FAQModal.tsx
+++ b/src/components/FAQModal.tsx
@@ -1,4 +1,4 @@
-import { Icon, Text, Title, Divider, Button, GenericModal } from "@gnosis.pm/safe-react-components";
+import { Icon, Text, Title, Divider, Button, GenericModal, Link } from "@gnosis.pm/safe-react-components";
import { Fab } from "@material-ui/core";
import { useState } from "react";
@@ -84,6 +84,11 @@ export const FAQModal: () => JSX.Element = () => {
+
+
+ Sample Transfer File
+
+
Native Token Transfers
diff --git a/src/components/GenerateTransfersMenu.tsx b/src/components/GenerateTransfersMenu.tsx
new file mode 100644
index 00000000..21c3b75e
--- /dev/null
+++ b/src/components/GenerateTransfersMenu.tsx
@@ -0,0 +1,104 @@
+import { AddressInput, Button, ButtonLink, GenericModal, Menu, Tooltip } from "@gnosis.pm/safe-react-components";
+import { Collapse } from "@material-ui/core";
+import BigNumber from "bignumber.js";
+import React, { useState } from "react";
+
+import { AssetBalance, CollectibleBalance } from "../hooks/balances";
+import { useEnsResolver } from "../hooks/ens";
+import { fromWei } from "../utils";
+
+export interface GenerateTransfersMenuProps {
+ assetBalance?: AssetBalance;
+ collectibleBalance?: CollectibleBalance;
+ setCsvText: (csv: string) => void;
+}
+
+export const GenerateTransfersMenu = (props: GenerateTransfersMenuProps): JSX.Element => {
+ const { assetBalance, collectibleBalance, setCsvText } = props;
+ const [isGenerationMenuOpen, setIsGenerationMenuOpen] = useState(false);
+ const [isDrainModalOpen, setIsDrainModalOpen] = useState(false);
+ const [drainAddress, setDrainAddress] = useState("");
+
+ const ensResolver = useEnsResolver();
+
+ const generateDrainTransfers = () => {
+ let drainCSV = "token_type,token_address,receiver,value,id,";
+ if (drainAddress) {
+ assetBalance?.forEach((asset) => {
+ if (asset.token === null && asset.tokenAddress === null) {
+ drainCSV += `\nnative,,${drainAddress},${fromWei(new BigNumber(asset.balance), 18)},`;
+ } else {
+ const tokenDecimals = asset.token?.decimals;
+ if (tokenDecimals) {
+ drainCSV += `\nerc20,${asset.tokenAddress},${drainAddress},${fromWei(
+ new BigNumber(asset.balance),
+ tokenDecimals,
+ )},`;
+ }
+ }
+ });
+
+ collectibleBalance?.forEach((collectible) => {
+ drainCSV += `\nnft,${collectible.address},${drainAddress},,${collectible.id}`;
+ });
+ }
+ setCsvText(drainCSV);
+ };
+ return (
+ <>
+
+
+
+
+
+ setIsDrainModalOpen(true)}>
+ Drain safe
+
+
+
+
+
+ {isDrainModalOpen && (
+ setIsDrainModalOpen(false)}
+ title="Enter an address to send all assets to"
+ body={
+ ensResolver.resolveName(name).then((address) => address ?? name)}
+ onChangeAddress={setDrainAddress}
+ placeholder="Ethereum address"
+ showNetworkPrefix={false}
+ />
+ }
+ footer={
+
+
+
+
+ }
+ />
+ )}
+ >
+ );
+};
diff --git a/src/components/Summary.tsx b/src/components/Summary.tsx
index 75547566..37ec4a62 100644
--- a/src/components/Summary.tsx
+++ b/src/components/Summary.tsx
@@ -1,4 +1,4 @@
-import { Accordion, AccordionDetails, AccordionSummary, Icon, Text, Title } from "@gnosis.pm/safe-react-components";
+import { Accordion, AccordionDetails, AccordionSummary, Icon, Text } from "@gnosis.pm/safe-react-components";
import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser";
@@ -16,7 +16,6 @@ export const Summary = (props: SummaryProps): JSX.Element => {
const collectibleTxCount = collectibleTransfers.length;
return (
<>
- Summary of transfers
{
flex: 1,
boxShadow:
"rgb(247, 245, 245) 0px 3px 3px -2px, rgb(247, 245, 245) 0px 3px 4px 0px, rgb(247, 245, 245) 0px 1px 8px 0px",
+ borderRadius: 8,
}}
>
diff --git a/src/hooks/balances.ts b/src/hooks/balances.ts
new file mode 100644
index 00000000..700bf028
--- /dev/null
+++ b/src/hooks/balances.ts
@@ -0,0 +1,114 @@
+import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk";
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+const baseAPIMap = new Map([
+ [1, "https://safe-transaction.gnosis.io/api/v1"],
+ [4, "https://safe-transaction.rinkeby.gnosis.io/api/v1"],
+ [100, "https://safe-transaction.xdai.gnosis.io/api/v1"],
+ [137, "https://safe-transaction.polygon.gnosis.io/api/v1"],
+ [56, "https://safe-transaction.bsc.gnosis.io/api/v1"],
+]);
+
+type Token = {
+ name: string;
+ symbol: string;
+ decimals: number;
+};
+
+type AssetBalanceEntry = {
+ tokenAddress: string | null;
+ token: Token | null;
+ balance: string;
+ decimals: number;
+};
+
+type CollectibleBalanceEntry = {
+ address: string;
+ tokenName: string;
+ tokenSymbol: string;
+ id: string;
+};
+
+export type AssetBalance = AssetBalanceEntry[];
+export type CollectibleBalance = CollectibleBalanceEntry[];
+
+export interface BalanceLoader {
+ assetBalance: AssetBalance | undefined;
+
+ collectibleBalance: CollectibleBalance | undefined;
+
+ isLoading: boolean;
+}
+
+export const useBalances: () => BalanceLoader = () => {
+ const { safe } = useSafeAppsSDK();
+
+ const [isAssetBalanceLoading, setIsAssetBalanceLoading] = useState(true);
+ const [isCollectibleBalanceLoading, setIsCollectibleBalanceLoading] = useState(true);
+
+ const [assetBalance, setAssetBalance] = useState(undefined);
+ const [collectibleBalance, setCollectibleBalance] = useState(undefined);
+
+ const fetchAssetBalance = useCallback(async (chainId: number, safeAddress: string) => {
+ if (baseAPIMap.has(chainId)) {
+ return await fetch(`${baseAPIMap.get(chainId)}/safes/${safeAddress}/balances?trusted=false&exclude_spam=false`)
+ .then((response) => {
+ if (response.ok) {
+ return response.json() as Promise;
+ } else {
+ throw Error(response.statusText);
+ }
+ })
+ .catch(() => undefined);
+ } else {
+ return undefined;
+ }
+ }, []);
+
+ const fetchCollectibleBalance = useCallback(async (chainId: number, safeAddress: string) => {
+ if (baseAPIMap.has(chainId)) {
+ return await fetch(
+ `${baseAPIMap.get(chainId)}/safes/${safeAddress}/collectibles?trusted=false&exclude_spam=false`,
+ )
+ .then((response) => {
+ if (response.ok) {
+ return response.json() as Promise;
+ } else {
+ throw Error(response.statusText);
+ }
+ })
+ .catch(() => undefined);
+ } else {
+ return undefined;
+ }
+ }, []);
+
+ useEffect(() => {
+ let isMounted = true;
+ setIsAssetBalanceLoading(true);
+ setIsCollectibleBalanceLoading(true);
+
+ fetchAssetBalance(safe.chainId, safe.safeAddress).then((result) => {
+ if (isMounted) {
+ setAssetBalance(result);
+ setIsAssetBalanceLoading(false);
+ }
+ });
+
+ fetchCollectibleBalance(safe.chainId, safe.safeAddress).then((result) => {
+ if (isMounted) {
+ setCollectibleBalance(result);
+ setIsCollectibleBalanceLoading(false);
+ }
+ });
+ return function callback() {
+ isMounted = false;
+ };
+ }, [fetchAssetBalance, fetchCollectibleBalance, safe.chainId, safe.safeAddress]);
+
+ return {
+ assetBalance: useMemo(() => assetBalance, [assetBalance]),
+ collectibleBalance: useMemo(() => collectibleBalance, [collectibleBalance]),
+ isLoading: isAssetBalanceLoading || isCollectibleBalanceLoading,
+ };
+};
diff --git a/src/hooks/collectibleTokenInfoProvider.ts b/src/hooks/collectibleTokenInfoProvider.ts
index dfb4e5db..b661da7a 100644
--- a/src/hooks/collectibleTokenInfoProvider.ts
+++ b/src/hooks/collectibleTokenInfoProvider.ts
@@ -84,7 +84,6 @@ export const useCollectibleTokenInfoProvider: () => CollectibleTokenInfoProvider
return collectibleContractCache.get(toKey(tokenAddress, tokenId));
}
const tokenInterfaces = await determineInterface(tokenAddress);
- console.log("Trying to determine interface: " + tokenInterfaces);
let fetchedTokenInfo: CollectibleTokenInfo | undefined = undefined;
if (tokenInterfaces.includes("erc721")) {
fetchedTokenInfo = {
diff --git a/src/parser/balanceCheck.ts b/src/parser/balanceCheck.ts
new file mode 100644
index 00000000..79b6248b
--- /dev/null
+++ b/src/parser/balanceCheck.ts
@@ -0,0 +1,145 @@
+import { BigNumber } from "bignumber.js";
+
+import { AssetBalance, CollectibleBalance } from "../hooks/balances";
+import { toWei } from "../utils";
+
+import { AssetTransfer, CollectibleTransfer, Transfer } from "./csvParser";
+
+export type AssetSummaryEntry = {
+ tokenAddress: string | null;
+ amount: BigNumber;
+ decimals: number;
+ symbol?: string;
+};
+
+export const assetTransfersToSummary = (transfers: AssetTransfer[]) => {
+ return transfers.reduce((previousValue, currentValue): Map => {
+ let tokenSummary = previousValue.get(currentValue.tokenAddress);
+ if (typeof tokenSummary === "undefined") {
+ tokenSummary = {
+ tokenAddress: currentValue.tokenAddress,
+ amount: new BigNumber(0),
+ decimals: currentValue.decimals,
+ symbol: currentValue.symbol,
+ };
+ previousValue.set(currentValue.tokenAddress, tokenSummary);
+ }
+ tokenSummary.amount = tokenSummary.amount.plus(currentValue.amount);
+
+ return previousValue;
+ }, new Map());
+};
+
+export type CollectibleSummaryEntry = {
+ tokenAddress: string;
+ id: BigNumber;
+ count: number;
+ name?: string;
+};
+
+export const collectibleTransfersToSummary = (transfers: CollectibleTransfer[]) => {
+ return transfers.reduce((previousValue, currentValue): Map => {
+ const entryKey = `${currentValue.tokenAddress}:${currentValue.tokenId.toFixed()}`;
+ let tokenSummary = previousValue.get(entryKey);
+ if (typeof tokenSummary === "undefined") {
+ tokenSummary = {
+ tokenAddress: currentValue.tokenAddress,
+ count: 0,
+ name: currentValue.tokenName,
+ id: currentValue.tokenId,
+ };
+ previousValue.set(entryKey, tokenSummary);
+ }
+ tokenSummary.count = tokenSummary.count + 1;
+
+ return previousValue;
+ }, new Map());
+};
+
+export type InsufficientBalanceInfo = {
+ token: string;
+ transferAmount?: string;
+ isDuplicate: boolean;
+ token_type: "erc20" | "native" | "erc721";
+ id?: BigNumber;
+};
+
+export const checkAllBalances = (
+ assetBalance: AssetBalance | undefined,
+ collectibleBalance: CollectibleBalance | undefined,
+ transfers: Transfer[],
+): InsufficientBalanceInfo[] => {
+ const insufficientTokens: InsufficientBalanceInfo[] = [];
+
+ const assetSummary = assetTransfersToSummary(
+ transfers.filter(
+ (transfer) => transfer.token_type === "erc20" || transfer.token_type === "native",
+ ) as AssetTransfer[],
+ );
+
+ // erc1155 balance checks are not possible yet through the safe api
+ const collectibleSummary = collectibleTransfersToSummary(
+ transfers.filter((transfer) => transfer.token_type === "erc721") as CollectibleTransfer[],
+ );
+
+ for (const { tokenAddress, amount, decimals, symbol } of assetSummary.values()) {
+ if (tokenAddress === null) {
+ // Check ETH Balance
+ const tokenBalance = assetBalance?.find((balanceEntry) => balanceEntry.tokenAddress === null);
+
+ if (
+ typeof tokenBalance === "undefined" ||
+ !isSufficientBalance(new BigNumber(tokenBalance.balance), amount, 18)
+ ) {
+ insufficientTokens.push({
+ token: "ETH",
+ token_type: "native",
+ transferAmount: amount.toFixed(),
+ isDuplicate: false, // For Erc20 / Coin Transfers duplicates are never an issue
+ });
+ }
+ } else {
+ const tokenBalance = assetBalance?.find(
+ (balanceEntry) => balanceEntry.tokenAddress?.toLowerCase() === tokenAddress.toLowerCase(),
+ );
+ if (
+ typeof tokenBalance === "undefined" ||
+ !isSufficientBalance(new BigNumber(tokenBalance.balance), amount, decimals)
+ ) {
+ insufficientTokens.push({
+ token: symbol || tokenAddress,
+ token_type: "erc20",
+ transferAmount: amount.toFixed(),
+ isDuplicate: false, // For Erc20 / Coin Transfers duplicates are never an issue
+ });
+ }
+ }
+ }
+
+ for (const { tokenAddress, count, name, id } of collectibleSummary.values()) {
+ const tokenBalance = collectibleBalance?.find(
+ (balanceEntry) =>
+ balanceEntry.address?.toLowerCase() === tokenAddress.toLowerCase() && balanceEntry.id === id.toFixed(),
+ );
+ if (typeof tokenBalance === "undefined" || count > 1) {
+ const tokenName =
+ name ??
+ tokenBalance?.tokenName ??
+ collectibleBalance?.find((balanceEntry) => balanceEntry.address?.toLowerCase() === tokenAddress.toLowerCase())
+ ?.tokenName;
+ insufficientTokens.push({
+ token: tokenName ?? tokenAddress,
+ token_type: "erc721",
+ isDuplicate: count > 1,
+ id: id,
+ });
+ }
+ }
+
+ return insufficientTokens;
+};
+
+const isSufficientBalance = (tokenBalance: BigNumber, transferAmount: BigNumber, decimals: number) => {
+ const transferAmountInWei = toWei(transferAmount, decimals);
+ return tokenBalance.gte(transferAmountInWei);
+};
diff --git a/src/parser/csvParser.ts b/src/parser/csvParser.ts
index 84bbe136..dfd7a811 100644
--- a/src/parser/csvParser.ts
+++ b/src/parser/csvParser.ts
@@ -67,6 +67,8 @@ const generateWarnings = (
return messages;
};
+const countLines = (text: string) => text.split(/\r\n|\r|\n/).length;
+
export class CSVParser {
public static parseCSV = (
csvText: string,
@@ -74,6 +76,16 @@ export class CSVParser {
erc721TokenInfoProvider: CollectibleTokenInfoProvider,
ensResolver: EnsResolver,
): Promise<[Transfer[], CodeWarning[]]> => {
+ const noLines = countLines(csvText);
+ // Hard limit at 400 lines of txs
+ if (noLines > 401) {
+ return new Promise<[Transfer[], CodeWarning[]]>((resolve, reject) => {
+ reject({
+ message: "Max number of lines exceeded. Due to the block gas limit transactions are limited to 400 lines.",
+ });
+ });
+ }
+
return new Promise<[Transfer[], CodeWarning[]]>((resolve, reject) => {
const results: Transfer[] = [];
const resultingWarnings: CodeWarning[] = [];
diff --git a/src/parser/transformation.ts b/src/parser/transformation.ts
index aecb109a..baba3c15 100644
--- a/src/parser/transformation.ts
+++ b/src/parser/transformation.ts
@@ -38,6 +38,8 @@ export const transform = (
transformAsset({ ...row, token_type: "native" }, tokenInfoProvider, ensResolver, callback);
break;
case "nft":
+ case "erc721":
+ case "erc1155":
transformCollectible({ ...row, token_type: "nft" }, erc721InfoProvider, ensResolver, callback);
break;
default:
diff --git a/src/parser/validation.ts b/src/parser/validation.ts
index 27789253..bf98447b 100644
--- a/src/parser/validation.ts
+++ b/src/parser/validation.ts
@@ -14,7 +14,7 @@ export const validateRow = (row: Transfer | UnknownTransfer, callback: RowValida
validateCollectibleRow(row, callback);
break;
default:
- callback(null, false, "Unknown token_type: Must be one of erc20, native, erc721, erc1155");
+ callback(null, false, "Unknown token_type: Must be one of erc20, native or nft");
}
};
diff --git a/src/utils.ts b/src/utils.ts
index 8334bf06..ba44ceb3 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,9 +1,4 @@
-import { SafeInfo } from "@gnosis.pm/safe-apps-sdk";
import { BigNumber } from "bignumber.js";
-import { ethers, utils } from "ethers";
-
-import { AssetTransfer } from "./parser/csvParser";
-import { erc20Instance } from "./transfers/erc20";
export const ZERO = new BigNumber(0);
export const ONE = new BigNumber(1);
@@ -41,72 +36,3 @@ export function toWei(amount: string | number | BigNumber, decimals: number): Bi
export function fromWei(amount: BigNumber, decimals: number): BigNumber {
return amount.dividedBy(TEN.pow(decimals));
}
-
-export type SummaryEntry = {
- tokenAddress: string | null;
- amount: BigNumber;
- decimals: number;
- symbol?: string;
-};
-
-export const transfersToSummary = (transfers: AssetTransfer[]) => {
- return transfers.reduce((previousValue, currentValue): Map => {
- let tokenSummary = previousValue.get(currentValue.tokenAddress);
- if (typeof tokenSummary === "undefined") {
- tokenSummary = {
- tokenAddress: currentValue.tokenAddress,
- amount: new BigNumber(0),
- decimals: currentValue.decimals,
- symbol: currentValue.symbol,
- };
- previousValue.set(currentValue.tokenAddress, tokenSummary);
- }
- tokenSummary.amount = tokenSummary.amount.plus(currentValue.amount);
-
- return previousValue;
- }, new Map());
-};
-
-export type InsufficientBalanceInfo = {
- token: string;
- transferAmount: string;
-};
-
-export const checkAllBalances = async (
- summary: Map,
- web3Provider: ethers.providers.Web3Provider,
- safe: SafeInfo,
-): Promise => {
- const insufficientTokens: InsufficientBalanceInfo[] = [];
- for (const { tokenAddress, amount, decimals, symbol } of summary.values()) {
- if (tokenAddress === null) {
- // Check ETH Balance
- const tokenBalance = await web3Provider.getBalance(safe.safeAddress, "latest");
- if (!isSufficientBalance(tokenBalance, amount, 18)) {
- insufficientTokens.push({
- token: "ETH",
- transferAmount: amount.toFixed(),
- });
- }
- } else {
- const erc20Contract = erc20Instance(utils.getAddress(tokenAddress), web3Provider);
- const tokenBalance = await erc20Contract.balanceOf(safe.safeAddress).catch((reason) => {
- console.error(reason);
- return ethers.BigNumber.from(-1);
- });
- if (!isSufficientBalance(tokenBalance, amount, decimals)) {
- insufficientTokens.push({
- token: symbol || tokenAddress,
- transferAmount: amount.toFixed(),
- });
- }
- }
- }
- return insufficientTokens;
-};
-
-const isSufficientBalance = (tokenBalance: ethers.BigNumber, transferAmount: BigNumber, decimals: number) => {
- const tokenBalanceNumber = new BigNumber(tokenBalance.toString());
- const transferAmountInWei = toWei(transferAmount, decimals);
- return tokenBalanceNumber.gte(transferAmountInWei);
-};