From bf6025f4744d6deaab7c0b6ad55d54f82630a392 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Sun, 9 Jan 2022 15:46:36 +0100 Subject: [PATCH] Reworks the balance checks and adds them for erc721 tokens (#335) * Reworks the balance checks and adds them for erc721 tokens - Instead of using the web3provider to check individual token balances we fetch all balances at the start of the app - Adds balance and duplicate ID check for ERC721 transfers * Drain safe functionality * The link to the sample file was moved into the Help-Modal * Instead there is a menu to generate transfers with the option to drain the safe closes #171 --- src/App.tsx | 31 +- src/GlobalStyle.ts | 25 + src/__tests__/balanceCheck.test.ts | 569 +++++++++++++++++++ src/__tests__/parser.test.ts | 17 +- src/__tests__/utils.test.ts | 195 +------ src/components/{assets => }/CSVForm.tsx | 77 ++- src/components/CSVUpload.tsx | 7 +- src/components/FAQModal.tsx | 7 +- src/components/GenerateTransfersMenu.tsx | 104 ++++ src/components/Summary.tsx | 3 +- src/components/assets/AssetTransferTable.tsx | 1 + src/hooks/balances.ts | 114 ++++ src/hooks/collectibleTokenInfoProvider.ts | 1 - src/parser/balanceCheck.ts | 145 +++++ src/parser/csvParser.ts | 12 + src/parser/transformation.ts | 2 + src/parser/validation.ts | 2 +- src/utils.ts | 74 --- 18 files changed, 1068 insertions(+), 318 deletions(-) create mode 100644 src/__tests__/balanceCheck.test.ts rename src/components/{assets => }/CSVForm.tsx (60%) create mode 100644 src/components/GenerateTransfersMenu.tsx create mode 100644 src/hooks/balances.ts create mode 100644 src/parser/balanceCheck.ts 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 ( + <> + <div className="generateMenu"> + <Menu className="leftAlignedMenu"> + <ButtonLink color="primary" iconType="add" onClick={() => setIsGenerationMenuOpen(!isGenerationMenuOpen)}> + Generate transfers + </ButtonLink> + </Menu> + <Collapse in={isGenerationMenuOpen}> + <div className="openedGenerateMenu"> + <Tooltip title="Send all assets and collectibles from this safe"> + <ButtonLink color="primary" iconType="exportImg" iconSize="sm" onClick={() => setIsDrainModalOpen(true)}> + Drain safe + </ButtonLink> + </Tooltip> + </div> + </Collapse> + </div> + {isDrainModalOpen && ( + <GenericModal + onClose={() => setIsDrainModalOpen(false)} + title="Enter an address to send all assets to" + body={ + <AddressInput + address={drainAddress} + error="" + hiddenLabel + label="Address" + name="address" + getAddressFromDomain={(name) => ensResolver.resolveName(name).then((address) => address ?? name)} + onChangeAddress={setDrainAddress} + placeholder="Ethereum address" + showNetworkPrefix={false} + /> + } + footer={ + <div style={{ display: "flex", justifyContent: "space-between" }}> + <Button + size="md" + color="primary" + onClick={() => { + generateDrainTransfers(); + setIsDrainModalOpen(false); + setIsGenerationMenuOpen(false); + }} + > + Submit + </Button> + <Button size="md" color="secondary" onClick={() => setIsDrainModalOpen(false)}> + Abort + </Button> + </div> + } + /> + )} + </> + ); +}; 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 ( <> - <Title size="md">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); -};