From 9fa5fab30f838c827589f6869bc4c611a16fdd10 Mon Sep 17 00:00:00 2001 From: schmanu Date: Fri, 1 Oct 2021 15:39:43 +0200 Subject: [PATCH 01/13] First working version of nft transfers --- package.json | 2 +- src/App.tsx | 53 +++++- src/GlobalStyle.ts | 14 ++ src/__tests__/parser.test.ts | 12 +- src/__tests__/transfers.test.ts | 14 +- src/assetParser.ts | 179 ++++++++++++++++++ src/collectiblesParser.ts | 163 ++++++++++++++++ src/components/NFTCSVForm.tsx | 134 +++++++++++++ .../{CSVForm.tsx => assets/AssetCSVForm.tsx} | 28 +-- .../AssetTransferTable.tsx} | 10 +- .../assets/CollectiblesTransferTable.tsx | 39 ++++ .../{Token.tsx => assets/ERC20Token.tsx} | 4 +- src/components/assets/ERC721Token.tsx | 37 ++++ src/hooks/erc721InfoProvider.ts | 48 +++++ src/hooks/token.ts | 2 +- src/parser.ts | 171 ----------------- src/{ => transfers}/erc20.ts | 2 +- src/transfers/erc721.ts | 9 + src/{ => transfers}/transfers.ts | 24 ++- src/utils.ts | 4 +- 20 files changed, 732 insertions(+), 217 deletions(-) create mode 100644 src/assetParser.ts create mode 100644 src/collectiblesParser.ts create mode 100644 src/components/NFTCSVForm.tsx rename src/components/{CSVForm.tsx => assets/AssetCSVForm.tsx} (83%) rename src/components/{TransferTable.tsx => assets/AssetTransferTable.tsx} (73%) create mode 100644 src/components/assets/CollectiblesTransferTable.tsx rename src/components/{Token.tsx => assets/ERC20Token.tsx} (88%) create mode 100644 src/components/assets/ERC721Token.tsx create mode 100644 src/hooks/erc721InfoProvider.ts delete mode 100644 src/parser.ts rename src/{ => transfers}/erc20.ts (82%) create mode 100644 src/transfers/erc721.ts rename src/{ => transfers}/transfers.ts (51%) diff --git a/package.json b/package.json index 258f6b11..f16da90a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "fmt": "prettier --check '**/*.ts'", "fmt:write": "prettier --write '**/*.ts'", "prepare": "husky install", - "generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json'", + "generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json' './node_modules/@openzeppelin/contracts/build/contracts/ERC721.json'", "postinstall": "yarn generate-types" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index a1a1db23..94549f2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,13 @@ import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { Loader, Text } from "@gnosis.pm/safe-react-components"; +import { Dot, Icon, Loader, Tab, Text } from "@gnosis.pm/safe-react-components"; +import { Item } from "@gnosis.pm/safe-react-components/dist/navigation/Tab"; import { setUseWhatChange } from "@simbathesailor/use-what-changed"; -import React from "react"; +import React, { useState } from "react"; import styled from "styled-components"; -import { CSVForm } from "./components/CSVForm"; import { Header } from "./components/Header"; +import { NFTCSVForm } from "./components/NFTCSVForm"; +import { AssetCSVForm } from "./components/assets/AssetCSVForm"; import { useTokenList, networkMap } from "./hooks/token"; setUseWhatChange(process.env.NODE_ENV === "development"); @@ -13,6 +15,45 @@ setUseWhatChange(process.env.NODE_ENV === "development"); const App: React.FC = () => { const { isLoading } = useTokenList(); const { safe } = useSafeAppsSDK(); + const [selectedTab, setSelectedTab] = useState("assets"); + const navigationItems: Item[] = [ + { + id: "assets", + icon: "assets", + label: "Assets", + customContent: ( +
+ + + Assets + + + + 7 + + +
+ ), + }, + { + id: "collectibles", + icon: "collectibles", + label: "Collectibles", + customContent: ( +
+ + + Collectibles + + + + 2 + + +
+ ), + }, + ]; return (
@@ -24,7 +65,11 @@ const App: React.FC = () => { Loading Tokenlist... ) : ( - + <> + + {selectedTab === "assets" && } + {selectedTab === "collectibles" && } + )} ) : ( diff --git a/src/GlobalStyle.ts b/src/GlobalStyle.ts index b6d5626a..79d7efaa 100644 --- a/src/GlobalStyle.ts +++ b/src/GlobalStyle.ts @@ -68,6 +68,20 @@ const GlobalStyle = createGlobalStyle` .MuiPaper-elevation3 { box-shadow: 0px 3px 3px -2px #F7F5F5,0px 3px 4px 0px #F7F5F5,0px 1px 8px 0px #F7F5F5 !important; } + + .navLabel { + flex: 3; + } + + .navDot { + width: 24px; + height: 24px; + top: -8px; + } + + .navDot p { + color: white !important; + } `; export default GlobalStyle; diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 5e73213a..f9fcd4d0 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -4,9 +4,9 @@ import * as chai from "chai"; import { expect } from "chai"; import chaiAsPromised from "chai-as-promised"; +import { AssetParser } from "../assetParser"; import { EnsResolver } from "../hooks/ens"; import { TokenMap, MinimalTokenInfo, fetchTokenList, TokenInfoProvider } from "../hooks/token"; -import { parseCSV } from "../parser"; import { testData } from "../test/util"; let tokenList: TokenMap; @@ -86,7 +86,7 @@ describe("Parsing CSVs ", () => { it("should throw errors for invalid CSVs", async () => { // this csv contains more values than headers in row1 const invalidCSV = "head1,header2\nvalue1,value2,value3"; - expect(parseCSV(invalidCSV, mockTokenInfoProvider, mockEnsResolver)).to.be.rejectedWith( + expect(AssetParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockEnsResolver)).to.be.rejectedWith( "column header mismatch expected: 2 columns got: 3", ); }); @@ -95,7 +95,7 @@ describe("Parsing CSVs ", () => { // we hard coded in our mock that a ens of "error.eth" throws an error. const rowWithErrorReceiver = [listedToken.address, "error.eth", "1"]; expect( - parseCSV(csvStringFromRows(rowWithErrorReceiver), mockTokenInfoProvider, mockEnsResolver), + AssetParser.parseCSV(csvStringFromRows(rowWithErrorReceiver), mockTokenInfoProvider, mockEnsResolver), ).to.be.rejectedWith("unexpected error!"); }); @@ -104,7 +104,7 @@ describe("Parsing CSVs ", () => { const rowWithDecimalAmount = [listedToken.address, validReceiverAddress, "69.420"]; const rowWithoutTokenAddress = ["", validReceiverAddress, "1"]; - const [payment, warnings] = await parseCSV( + const [payment, warnings] = await AssetParser.parseCSV( csvStringFromRows(rowWithoutDecimal, rowWithDecimalAmount, rowWithoutTokenAddress), mockTokenInfoProvider, mockEnsResolver, @@ -138,7 +138,7 @@ describe("Parsing CSVs ", () => { const rowWithInvalidTokenAddress = ["0x420", validReceiverAddress, "1"]; const rowWithInvalidReceiverAddress = [listedToken.address, "0x420", "1"]; - const [payment, warnings] = await parseCSV( + const [payment, warnings] = await AssetParser.parseCSV( csvStringFromRows( rowWithNegativeAmount, unlistedTokenWithoutDecimalInContract, @@ -181,7 +181,7 @@ describe("Parsing CSVs ", () => { const unknownReceiverEnsName = [listedToken.address, "unknown.eth", "1"]; const unknownTokenEnsName = ["unknown.eth", "receiver1.eth", "1"]; - const [payment, warnings] = await parseCSV( + const [payment, warnings] = await AssetParser.parseCSV( csvStringFromRows(receiverEnsName, tokenEnsName, unknownReceiverEnsName, unknownTokenEnsName), mockTokenInfoProvider, mockEnsResolver, diff --git a/src/__tests__/transfers.test.ts b/src/__tests__/transfers.test.ts index 813bb6d8..d8a88f7e 100644 --- a/src/__tests__/transfers.test.ts +++ b/src/__tests__/transfers.test.ts @@ -1,11 +1,11 @@ import { BigNumber } from "bignumber.js"; import { expect } from "chai"; -import { erc20Interface } from "../erc20"; +import { Payment } from "../assetParser"; import { fetchTokenList, MinimalTokenInfo } from "../hooks/token"; -import { Payment } from "../parser"; import { testData } from "../test/util"; -import { buildTransfers } from "../transfers"; +import { erc20Interface } from "../transfers/erc20"; +import { buildAssetTransfers } from "../transfers/transfers"; import { toWei, fromWei, MAX_U256, TokenInfo } from "../utils"; const dummySafeInfo = testData.dummySafeInfo; @@ -54,7 +54,7 @@ describe("Build Transfers:", () => { }, ]; - const [listedTransfer, unlistedTransfer, nativeTransfer] = buildTransfers(largePayments); + const [listedTransfer, unlistedTransfer, nativeTransfer] = buildAssetTransfers(largePayments); expect(listedTransfer.value).to.be.equal("0"); expect(listedTransfer.to).to.be.equal(listedToken.address); expect(listedTransfer.data).to.be.equal( @@ -106,7 +106,7 @@ describe("Build Transfers:", () => { }, ]; - const [listed, unlisted, native] = buildTransfers(smallPayments); + const [listed, unlisted, native] = buildAssetTransfers(smallPayments); expect(listed.value).to.be.equal("0"); expect(listed.to).to.be.equal(listedToken.address); expect(listed.data).to.be.equal( @@ -161,7 +161,7 @@ describe("Build Transfers:", () => { }, ]; - const [listed, unlisted, native] = buildTransfers(mixedPayments); + const [listed, unlisted, native] = buildAssetTransfers(mixedPayments); expect(listed.value).to.be.equal("0"); expect(listed.to).to.be.equal(listedToken.address); expect(listed.data).to.be.equal( @@ -202,7 +202,7 @@ describe("Build Transfers:", () => { symbol: "BTC", receiverEnsName: null, }; - const [transfer] = buildTransfers([payment]); + const [transfer] = buildAssetTransfers([payment]); expect(transfer.value).to.be.equal("0"); expect(transfer.to).to.be.equal(crappyToken.address); expect(transfer.data).to.be.equal( diff --git a/src/assetParser.ts b/src/assetParser.ts new file mode 100644 index 00000000..af519157 --- /dev/null +++ b/src/assetParser.ts @@ -0,0 +1,179 @@ +import { parseString, RowTransformCallback, RowValidateCallback } from "@fast-csv/parse"; +import { BigNumber } from "bignumber.js"; +import { utils } from "ethers"; + +import { CodeWarning } from "./contexts/MessageContextProvider"; +import { EnsResolver } from "./hooks/ens"; +import { TokenInfoProvider } from "./hooks/token"; + +/** + * Includes methods to parse, transform and validate csv content + */ + +export interface Payment { + receiver: string; + amount: BigNumber; + tokenAddress: string | null; + decimals: number; + symbol?: string; + receiverEnsName: string | null; +} + +export type CSVRow = { + receiver: string; + amount: string; + token_address: string; + decimals?: string; +}; + +interface PrePayment { + receiver: string; + amount: BigNumber; + tokenAddress: string | null; +} + +const generateWarnings = ( + // We need the row parameter because of the api of fast-csv + _row: Payment, + rowNumber: number, + warnings: string, +) => { + const messages: CodeWarning[] = warnings.split(";").map((warning: string) => ({ + message: warning, + severity: "warning", + lineNo: rowNumber, + })); + return messages; +}; + +export class AssetParser { + public static parseCSV = ( + csvText: string, + tokenInfoProvider: TokenInfoProvider, + ensResolver: EnsResolver, + ): Promise<[Payment[], CodeWarning[]]> => { + return new Promise<[Payment[], CodeWarning[]]>((resolve, reject) => { + const results: Payment[] = []; + const resultingWarnings: CodeWarning[] = []; + parseString(csvText, { headers: true }) + .transform((row: CSVRow, callback) => AssetParser.transformRow(row, tokenInfoProvider, ensResolver, callback)) + .validate((row: Payment, callback: RowValidateCallback) => AssetParser.validateRow(row, callback)) + .on("data", (data: Payment) => results.push(data)) + .on("end", () => resolve([results, resultingWarnings])) + .on("data-invalid", (row: Payment, rowNumber: number, warnings: string) => + resultingWarnings.push(...generateWarnings(row, rowNumber, warnings)), + ) + .on("error", (error) => reject(error)); + }); + }; + + /** + * Transforms each row into a payment object. + */ + private static transformRow = ( + row: CSVRow, + tokenInfoProvider: TokenInfoProvider, + ensResolver: EnsResolver, + callback: RowTransformCallback, + ): void => { + const prePayment: PrePayment = { + // avoids errors from getAddress. Invalid addresses are later caught in validateRow + tokenAddress: + row.token_address === "" || row.token_address === null + ? null + : utils.isAddress(row.token_address) + ? utils.getAddress(row.token_address) + : row.token_address, + amount: new BigNumber(row.amount), + receiver: utils.isAddress(row.receiver) ? utils.getAddress(row.receiver) : row.receiver, + }; + + AssetParser.toPayment(prePayment, tokenInfoProvider, ensResolver) + .then((row) => callback(null, row)) + .catch((reason) => callback(reason)); + }; + + /** + * Validates, that addresses are valid, the amount is big enough and a decimal is given or can be found in token lists. + */ + private static validateRow = (row: Payment, callback: RowValidateCallback) => { + const warnings = [ + ...AssetParser.areAddressesValid(row), + ...AssetParser.isAmountPositive(row), + ...AssetParser.isTokenValid(row), + ]; + callback(null, warnings.length === 0, warnings.join(";")); + }; + + private static areAddressesValid = (row: Payment): string[] => { + const warnings: string[] = []; + if (!(row.tokenAddress === null || utils.isAddress(row.tokenAddress))) { + warnings.push("Invalid Token Address: " + row.tokenAddress); + } + if (!utils.isAddress(row.receiver)) { + warnings.push("Invalid Receiver Address: " + row.receiver); + } + return warnings; + }; + + private static isAmountPositive = (row: Payment): string[] => + row.amount.isGreaterThan(0) ? [] : ["Only positive amounts possible: " + row.amount.toFixed()]; + + private static isTokenValid = (row: Payment): string[] => + row.decimals === -1 && row.symbol === "TOKEN_NOT_FOUND" + ? [`No token contract was found at ${row.tokenAddress}`] + : []; + + private static async toPayment( + prePayment: PrePayment, + tokenInfoProvider: TokenInfoProvider, + ensResolver: EnsResolver, + ): Promise { + // depending on whether there is an ens name or an address provided we either resolve or lookup + // For performance reasons the lookup will be done after the parsing. + let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(prePayment.receiver) + ? [prePayment.receiver, null] + : [ + (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(prePayment.receiver) : null, + prePayment.receiver, + ]; + resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; + if (prePayment.tokenAddress === null) { + // Native asset payment. + return { + receiver: resolvedReceiverAddress, + amount: prePayment.amount, + tokenAddress: prePayment.tokenAddress, + decimals: 18, + symbol: tokenInfoProvider.getNativeTokenSymbol(), + receiverEnsName, + }; + } + let resolvedTokenAddress = (await ensResolver.isEnsEnabled()) + ? await ensResolver.resolveName(prePayment.tokenAddress) + : prePayment.tokenAddress; + const tokenInfo = + resolvedTokenAddress === null ? undefined : await tokenInfoProvider.getTokenInfo(resolvedTokenAddress); + if (typeof tokenInfo !== "undefined") { + let decimals = tokenInfo.decimals; + let symbol = tokenInfo.symbol; + return { + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + amount: prePayment.amount, + tokenAddress: resolvedTokenAddress, + decimals, + symbol, + receiverEnsName, + }; + } else { + return { + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + amount: prePayment.amount, + tokenAddress: prePayment.tokenAddress, + decimals: -1, + symbol: "TOKEN_NOT_FOUND", + receiverEnsName, + }; + } + } +} diff --git a/src/collectiblesParser.ts b/src/collectiblesParser.ts new file mode 100644 index 00000000..23354121 --- /dev/null +++ b/src/collectiblesParser.ts @@ -0,0 +1,163 @@ +import { parseString, RowTransformCallback, RowValidateCallback } from "@fast-csv/parse"; +import { BigNumber } from "bignumber.js"; +import { utils } from "ethers"; + +import { CodeWarning } from "./contexts/MessageContextProvider"; +import { EnsResolver } from "./hooks/ens"; +import { ERC721InfoProvider } from "./hooks/erc721InfoProvider"; + +/** + * Includes methods to parse, transform and validate csv content + */ + +export interface CollectibleTransfer { + from: string; + receiver: string; + tokenAddress: string; + tokenName: string; + tokenId: BigNumber; + receiverEnsName: string | null; +} + +export type CSVRow = { + receiver: string; + tokenId: string; + token_address: string; +}; + +export type PreCollectibleTransfer = { + receiver: string; + tokenId: BigNumber; + tokenAddress: string; +}; + +const generateWarnings = ( + // We need the row parameter because of the api of fast-csv + _row: CollectibleTransfer, + rowNumber: number, + warnings: string, +) => { + const messages: CodeWarning[] = warnings.split(";").map((warning: string) => ({ + message: warning, + severity: "warning", + lineNo: rowNumber, + })); + return messages; +}; + +export class CollectiblesParser { + public static parseCSV = ( + csvText: string, + erc721InfoProvider: ERC721InfoProvider, + ensResolver: EnsResolver, + ): Promise<[CollectibleTransfer[], CodeWarning[]]> => { + return new Promise<[CollectibleTransfer[], CodeWarning[]]>((resolve, reject) => { + const results: CollectibleTransfer[] = []; + const resultingWarnings: CodeWarning[] = []; + parseString(csvText, { headers: true }) + .transform((row: CSVRow, callback) => + CollectiblesParser.transformRow(row, erc721InfoProvider, ensResolver, callback), + ) + .validate((row: CollectibleTransfer, callback: RowValidateCallback) => + CollectiblesParser.validateRow(row, callback), + ) + .on("data", (data: CollectibleTransfer) => results.push(data)) + .on("end", () => resolve([results, resultingWarnings])) + .on("data-invalid", (row: CollectibleTransfer, rowNumber: number, warnings: string) => + resultingWarnings.push(...generateWarnings(row, rowNumber, warnings)), + ) + .on("error", (error) => reject(error)); + }); + }; + + /** + * Transforms each row into a payment object. + */ + private static transformRow = ( + row: CSVRow, + erc721InfoProvider: ERC721InfoProvider, + ensResolver: EnsResolver, + callback: RowTransformCallback, + ): void => { + const prePayment: PreCollectibleTransfer = { + // avoids errors from getAddress. Invalid addresses are later caught in validateRow + tokenAddress: utils.isAddress(row.token_address) ? utils.getAddress(row.token_address) : row.token_address, + tokenId: new BigNumber(row.tokenId), + receiver: utils.isAddress(row.receiver) ? utils.getAddress(row.receiver) : row.receiver, + }; + + CollectiblesParser.toCollectibleTransfer(prePayment, erc721InfoProvider, ensResolver) + .then((row) => callback(null, row)) + .catch((reason) => callback(reason)); + }; + + /** + * Validates, that addresses are valid, the amount is big enough and a decimal is given or can be found in token lists. + */ + private static validateRow = (row: CollectibleTransfer, callback: RowValidateCallback) => { + const warnings = [ + ...CollectiblesParser.areAddressesValid(row), + ...CollectiblesParser.isTokenIdPositive(row), + ...CollectiblesParser.isTokenValid(row), + ]; + callback(null, warnings.length === 0, warnings.join(";")); + }; + + private static areAddressesValid = (row: CollectibleTransfer): string[] => { + const warnings: string[] = []; + if (!(row.tokenAddress === null || utils.isAddress(row.tokenAddress))) { + warnings.push("Invalid Token Address: " + row.tokenAddress); + } + if (!utils.isAddress(row.receiver)) { + warnings.push("Invalid Receiver Address: " + row.receiver); + } + return warnings; + }; + + private static isTokenIdPositive = (row: CollectibleTransfer): string[] => + row.tokenId.isGreaterThan(0) ? [] : ["Only positive tokenIds possible: " + row.tokenId.toFixed()]; + + /** + * I'm not sure if checking for the tokenName is enough. + */ + private static isTokenValid = (row: CollectibleTransfer): string[] => + row.tokenName === "TOKEN_NOT_FOUND" ? [`No erc721 contract was found at ${row.tokenAddress}`] : []; + + private static async toCollectibleTransfer( + prePayment: PreCollectibleTransfer, + erc721InfoProvider: ERC721InfoProvider, + ensResolver: EnsResolver, + ): Promise { + // depending on whether there is an ens name or an address provided we either resolve or lookup + // For performance reasons the lookup will be done after the parsing. + let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(prePayment.receiver) + ? [prePayment.receiver, null] + : [ + (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(prePayment.receiver) : null, + prePayment.receiver, + ]; + resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; + const tokenInfo = + prePayment.tokenAddress === null ? undefined : await erc721InfoProvider.getTokenInfo(prePayment.tokenAddress); + const fromAddress = erc721InfoProvider.getFromAddress(); + if (typeof tokenInfo !== "undefined") { + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + tokenId: prePayment.tokenId, + tokenAddress: prePayment.tokenAddress, + tokenName: tokenInfo.name, + receiverEnsName, + }; + } else { + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + tokenId: prePayment.tokenId, + tokenAddress: prePayment.tokenAddress, + tokenName: "TOKEN_NOT_FOUND", + receiverEnsName, + }; + } + } +} diff --git a/src/components/NFTCSVForm.tsx b/src/components/NFTCSVForm.tsx new file mode 100644 index 00000000..ea2c01a8 --- /dev/null +++ b/src/components/NFTCSVForm.tsx @@ -0,0 +1,134 @@ +import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; +import { Card, Text, Button, Loader } from "@gnosis.pm/safe-react-components"; +import debounce from "lodash.debounce"; +import React, { useCallback, useContext, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { buildERC721Transfers } from "..//transfers/transfers"; +import { CollectiblesParser, CollectibleTransfer } from "../collectiblesParser"; +import { MessageContext } from "../contexts/MessageContextProvider"; +import { useEnsResolver } from "../hooks/ens"; +import { useERC721InfoProvider } from "../hooks/erc721InfoProvider"; + +import { CSVEditor } from "./CSVEditor"; +import { CSVUpload } from "./CSVUpload"; +import { CollectiblesTransferTable } from "./assets/CollectiblesTransferTable"; + +const Form = styled.div` + flex: 1; + flex-direction: column; + display: flex; + justify-content: space-around; + gap: 8px; +`; + +export interface CSVFormProps {} + +export const NFTCSVForm = (props: CSVFormProps): JSX.Element => { + const [parsing, setParsing] = useState(false); + const [transferContent, setTransferContent] = useState([]); + const [csvText, setCsvText] = useState("token_address,receiver,amount"); + const [submitting, setSubmitting] = useState(false); + + const { setCodeWarnings, setMessages } = useContext(MessageContext); + + const { sdk } = useSafeAppsSDK(); + const erc721InfoProvider = useERC721InfoProvider(); + const ensResolver = useEnsResolver(); + + const submitTx = useCallback(async () => { + setSubmitting(true); + try { + const txs = buildERC721Transfers(transferContent); + console.log(`Encoded ${txs.length} ERC20 transfers.`); + const sendTxResponse = await sdk.txs.send({ txs }); + const safeTx = await sdk.txs.getBySafeTxHash(sendTxResponse.safeTxHash); + console.log({ safeTx }); + } catch (e) { + console.error(e); + } + setSubmitting(false); + }, [transferContent, sdk.txs]); + + const onChangeTextHandler = (csvText: string) => { + setCsvText(csvText); + parseAndValidateCSV(csvText); + }; + + const parseAndValidateCSV = useMemo( + () => + debounce((csvText: string) => { + setParsing(true); + const parsePromise = CollectiblesParser.parseCSV(csvText, erc721InfoProvider, ensResolver); + parsePromise + .then(async ([transfers, warnings]) => { + const uniqueReceiversWithoutEnsName = transfers.reduce( + (previousValue, currentValue): Set => + currentValue.receiverEnsName === null ? previousValue.add(currentValue.receiver) : previousValue, + new Set(), + ); + if (uniqueReceiversWithoutEnsName.size < 15) { + transfers = await Promise.all( + // If there is no ENS Name we will try to lookup the address + transfers.map(async (transfer) => + transfer.receiverEnsName + ? transfer + : { + ...transfer, + receiverEnsName: (await ensResolver.isEnsEnabled()) + ? await ensResolver.lookupAddress(transfer.receiver) + : null, + }, + ), + ); + } + // TODO Check Balances + setTransferContent(transfers); + setCodeWarnings(warnings); + setParsing(false); + }) + .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); + }, 1000), + [ensResolver, erc721InfoProvider, setCodeWarnings, setMessages], + ); + + return ( + +
+ + Send arbitrarily many distinct NFTs, to arbitrarily many distinct accounts from a CSV file in a single + transaction. + + + Upload, edit or paste your NFT transfer CSV
(nft_address,id,receiver) +
+ + + + + + {transferContent.length > 0 && } + + {submitting ? ( + <> + +
+ + + ) : ( + + )} + +
+ ); +}; diff --git a/src/components/CSVForm.tsx b/src/components/assets/AssetCSVForm.tsx similarity index 83% rename from src/components/CSVForm.tsx rename to src/components/assets/AssetCSVForm.tsx index 34a6e5f9..bfcf293c 100644 --- a/src/components/CSVForm.tsx +++ b/src/components/assets/AssetCSVForm.tsx @@ -6,16 +6,16 @@ import debounce from "lodash.debounce"; import React, { useCallback, useContext, useMemo, useState } from "react"; import styled from "styled-components"; -import { MessageContext } from "../contexts/MessageContextProvider"; -import { useEnsResolver } from "../hooks/ens"; -import { useTokenInfoProvider } from "../hooks/token"; -import { parseCSV, Payment } from "../parser"; -import { buildTransfers } from "../transfers"; -import { checkAllBalances, transfersToSummary } from "../utils"; +import { AssetParser, Payment } from "../../assetParser"; +import { MessageContext } from "../../contexts/MessageContextProvider"; +import { useEnsResolver } from "../../hooks/ens"; +import { useTokenInfoProvider } from "../../hooks/token"; +import { buildAssetTransfers } from "../../transfers/transfers"; +import { checkAllBalances, transfersToSummary } from "../../utils"; +import { CSVEditor } from "../CSVEditor"; +import { CSVUpload } from "../CSVUpload"; -import { CSVEditor } from "./CSVEditor"; -import { CSVUpload } from "./CSVUpload"; -import { TransferTable } from "./TransferTable"; +import { AssetTransferTable } from "./AssetTransferTable"; const Form = styled.div` flex: 1; @@ -27,7 +27,7 @@ const Form = styled.div` export interface CSVFormProps {} -export const CSVForm = (props: CSVFormProps): JSX.Element => { +export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { const [parsing, setParsing] = useState(false); const [transferContent, setTransferContent] = useState([]); const [csvText, setCsvText] = useState("token_address,receiver,amount"); @@ -43,7 +43,7 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => { const submitTx = useCallback(async () => { setSubmitting(true); try { - const txs = buildTransfers(transferContent); + const txs = buildAssetTransfers(transferContent); console.log(`Encoded ${txs.length} ERC20 transfers.`); const sendTxResponse = await sdk.txs.send({ txs }); const safeTx = await sdk.txs.getBySafeTxHash(sendTxResponse.safeTxHash); @@ -63,7 +63,7 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => { () => debounce((csvText: string) => { setParsing(true); - const parsePromise = parseCSV(csvText, tokenInfoProvider, ensResolver); + const parsePromise = AssetParser.parseCSV(csvText, tokenInfoProvider, ensResolver); parsePromise .then(async ([transfers, warnings]) => { const uniqueReceiversWithoutEnsName = transfers.reduce( @@ -112,14 +112,14 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => { from a CSV file in a single transaction. - Upload, edit or paste your transfer CSV
(token_address,receiver,amount) + Upload, edit or paste your asset transfer CSV
(token_address,receiver,amount)
- {transferContent.length > 0 && } + {transferContent.length > 0 && } {submitting ? ( <> diff --git a/src/components/TransferTable.tsx b/src/components/assets/AssetTransferTable.tsx similarity index 73% rename from src/components/TransferTable.tsx rename to src/components/assets/AssetTransferTable.tsx index fda90091..f48a5061 100644 --- a/src/components/TransferTable.tsx +++ b/src/components/assets/AssetTransferTable.tsx @@ -1,16 +1,16 @@ import { Table, Text } from "@gnosis.pm/safe-react-components"; import React from "react"; -import { Payment } from "../parser"; +import { Payment } from "../../assetParser"; +import { Receiver } from "../Receiver"; -import { Receiver } from "./Receiver"; -import { Token } from "./Token"; +import { ERC20Token } from "./ERC20Token"; type TransferTableProps = { transferContent: Payment[]; }; -export const TransferTable = (props: TransferTableProps) => { +export const AssetTransferTable = (props: TransferTableProps) => { const { transferContent } = props; return (
@@ -24,7 +24,7 @@ export const TransferTable = (props: TransferTableProps) => { return { id: "" + index, cells: [ - { id: "token", content: }, + { id: "token", content: }, { id: "receiver", content: , diff --git a/src/components/assets/CollectiblesTransferTable.tsx b/src/components/assets/CollectiblesTransferTable.tsx new file mode 100644 index 00000000..76d19522 --- /dev/null +++ b/src/components/assets/CollectiblesTransferTable.tsx @@ -0,0 +1,39 @@ +import { Table, Text } from "@gnosis.pm/safe-react-components"; +import React from "react"; + +import { CollectibleTransfer } from "../../collectiblesParser"; +import { Receiver } from "../Receiver"; + +import { ERC20Token } from "./ERC20Token"; + +type TransferTableProps = { + transferContent: CollectibleTransfer[]; +}; + +export const CollectiblesTransferTable = (props: TransferTableProps) => { + const { transferContent } = props; + return ( +
+ { + return { + id: "" + index, + cells: [ + { id: "token", content: }, + { + id: "receiver", + content: , + }, + { id: "id", content: {row.tokenId.toString()} }, + ], + }; + })} + /> + + ); +}; diff --git a/src/components/Token.tsx b/src/components/assets/ERC20Token.tsx similarity index 88% rename from src/components/Token.tsx rename to src/components/assets/ERC20Token.tsx index a0fec879..09e02054 100644 --- a/src/components/Token.tsx +++ b/src/components/assets/ERC20Token.tsx @@ -1,7 +1,7 @@ import { Text } from "@gnosis.pm/safe-react-components"; import styled from "styled-components"; -import { useTokenList } from "../hooks/token"; +import { useTokenList } from "../../hooks/token"; type TokenProps = { tokenAddress: string | null; @@ -17,7 +17,7 @@ const Container = styled.div` gap: 8px; `; -export const Token = (props: TokenProps) => { +export const ERC20Token = (props: TokenProps) => { const { tokenAddress, symbol } = props; const { tokenList } = useTokenList(); return ( diff --git a/src/components/assets/ERC721Token.tsx b/src/components/assets/ERC721Token.tsx new file mode 100644 index 00000000..73ceadbe --- /dev/null +++ b/src/components/assets/ERC721Token.tsx @@ -0,0 +1,37 @@ +import { Text } from "@gnosis.pm/safe-react-components"; +import styled from "styled-components"; + +import { useTokenList } from "../../hooks/token"; + +type TokenProps = { + tokenAddress: string; + name: string; +}; + +const Container = styled.div` + flex: 1; + flex-direction: row; + display: flex; + justify-content: start; + align-items: center; + gap: 8px; +`; + +export const ERC721Token = (props: TokenProps) => { + const { tokenAddress, name } = props; + const { tokenList } = useTokenList(); + return ( + + {" "} + {name || tokenAddress} + + ); +}; diff --git a/src/hooks/erc721InfoProvider.ts b/src/hooks/erc721InfoProvider.ts new file mode 100644 index 00000000..d349d258 --- /dev/null +++ b/src/hooks/erc721InfoProvider.ts @@ -0,0 +1,48 @@ +import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider"; +import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; +import { ethers } from "ethers"; +import { useCallback, useMemo } from "react"; + +import { erc721Instance } from "../transfers/erc721"; + +export type ERC721TokenInfo = { + name: string; + symbol: string; +}; + +export interface ERC721InfoProvider { + getTokenInfo: (tokenAddress: string) => Promise; + getFromAddress: () => string; +} + +export const useERC721InfoProvider: () => ERC721InfoProvider = () => { + const { safe, sdk } = useSafeAppsSDK(); + const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [sdk, safe]); + + const getTokenInfo = useCallback( + async (tokenAddress: string) => { + const erc721Contract = erc721Instance(tokenAddress, web3Provider); + const name = await erc721Contract.name().catch(() => undefined); + const symbol = await erc721Contract.symbol().catch(() => undefined); + if (!name || !symbol) { + // This is not a valid contract + return undefined; + } else { + return { name, symbol }; + } + }, + [web3Provider], + ); + + const getFromAddress = useCallback(() => { + return safe.safeAddress; + }, [safe]); + + return useMemo( + () => ({ + getTokenInfo: (tokenAddress: string) => getTokenInfo(tokenAddress), + getFromAddress: () => getFromAddress(), + }), + [getTokenInfo, getFromAddress], + ); +}; diff --git a/src/hooks/token.ts b/src/hooks/token.ts index 69d96c6a..ad09a7d0 100644 --- a/src/hooks/token.ts +++ b/src/hooks/token.ts @@ -4,8 +4,8 @@ import { ethers, utils } from "ethers"; import xdaiTokens from "honeyswap-default-token-list"; import { useState, useEffect, useMemo } from "react"; -import { erc20Instance } from "../erc20"; import rinkeby from "../static/rinkebyTokens.json"; +import { erc20Instance } from "../transfers/erc20"; import { TokenInfo } from "../utils"; export type TokenMap = Map; diff --git a/src/parser.ts b/src/parser.ts deleted file mode 100644 index 40a5b031..00000000 --- a/src/parser.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { parseString, RowTransformCallback, RowValidateCallback } from "@fast-csv/parse"; -import { BigNumber } from "bignumber.js"; -import { utils } from "ethers"; - -import { CodeWarning } from "./contexts/MessageContextProvider"; -import { EnsResolver } from "./hooks/ens"; -import { TokenInfoProvider } from "./hooks/token"; - -/** - * Includes methods to parse, transform and validate csv content - */ - -export interface Payment { - receiver: string; - amount: BigNumber; - tokenAddress: string | null; - decimals: number; - symbol?: string; - receiverEnsName: string | null; -} - -export type CSVRow = { - receiver: string; - amount: string; - token_address: string; - decimals?: string; -}; - -interface PrePayment { - receiver: string; - amount: BigNumber; - tokenAddress: string | null; -} - -const generateWarnings = ( - // We need the row parameter because of the api of fast-csv - _row: Payment, - rowNumber: number, - warnings: string, -) => { - const messages: CodeWarning[] = warnings.split(";").map((warning: string) => ({ - message: warning, - severity: "warning", - lineNo: rowNumber, - })); - return messages; -}; - -export const parseCSV = ( - csvText: string, - tokenInfoProvider: TokenInfoProvider, - ensResolver: EnsResolver, -): Promise<[Payment[], CodeWarning[]]> => { - return new Promise<[Payment[], CodeWarning[]]>((resolve, reject) => { - const results: Payment[] = []; - const resultingWarnings: CodeWarning[] = []; - parseString(csvText, { headers: true }) - .transform((row: CSVRow, callback) => transformRow(row, tokenInfoProvider, ensResolver, callback)) - .validate((row: Payment, callback: RowValidateCallback) => validateRow(row, callback)) - .on("data", (data: Payment) => results.push(data)) - .on("end", () => resolve([results, resultingWarnings])) - .on("data-invalid", (row: Payment, rowNumber: number, warnings: string) => - resultingWarnings.push(...generateWarnings(row, rowNumber, warnings)), - ) - .on("error", (error) => reject(error)); - }); -}; - -/** - * Transforms each row into a payment object. - */ -const transformRow = ( - row: CSVRow, - tokenInfoProvider: TokenInfoProvider, - ensResolver: EnsResolver, - callback: RowTransformCallback, -): void => { - const prePayment: PrePayment = { - // avoids errors from getAddress. Invalid addresses are later caught in validateRow - tokenAddress: - row.token_address === "" || row.token_address === null - ? null - : utils.isAddress(row.token_address) - ? utils.getAddress(row.token_address) - : row.token_address, - amount: new BigNumber(row.amount), - receiver: utils.isAddress(row.receiver) ? utils.getAddress(row.receiver) : row.receiver, - }; - - toPayment(prePayment, tokenInfoProvider, ensResolver) - .then((row) => callback(null, row)) - .catch((reason) => callback(reason)); -}; - -/** - * Validates, that addresses are valid, the amount is big enough and a decimal is given or can be found in token lists. - */ -const validateRow = (row: Payment, callback: RowValidateCallback) => { - const warnings = [...areAddressesValid(row), ...isAmountPositive(row), ...isTokenValid(row)]; - callback(null, warnings.length === 0, warnings.join(";")); -}; - -const areAddressesValid = (row: Payment): string[] => { - const warnings: string[] = []; - if (!(row.tokenAddress === null || utils.isAddress(row.tokenAddress))) { - warnings.push("Invalid Token Address: " + row.tokenAddress); - } - if (!utils.isAddress(row.receiver)) { - warnings.push("Invalid Receiver Address: " + row.receiver); - } - return warnings; -}; - -const isAmountPositive = (row: Payment): string[] => - row.amount.isGreaterThan(0) ? [] : ["Only positive amounts possible: " + row.amount.toFixed()]; - -const isTokenValid = (row: Payment): string[] => - row.decimals === -1 && row.symbol === "TOKEN_NOT_FOUND" ? [`No token contract was found at ${row.tokenAddress}`] : []; - -export async function toPayment( - prePayment: PrePayment, - tokenInfoProvider: TokenInfoProvider, - ensResolver: EnsResolver, -): Promise { - // depending on whether there is an ens name or an address provided we either resolve or lookup - // For performance reasons the lookup will be done after the parsing. - let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(prePayment.receiver) - ? [prePayment.receiver, null] - : [ - (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(prePayment.receiver) : null, - prePayment.receiver, - ]; - resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; - if (prePayment.tokenAddress === null) { - // Native asset payment. - return { - receiver: resolvedReceiverAddress, - amount: prePayment.amount, - tokenAddress: prePayment.tokenAddress, - decimals: 18, - symbol: tokenInfoProvider.getNativeTokenSymbol(), - receiverEnsName, - }; - } - let resolvedTokenAddress = (await ensResolver.isEnsEnabled()) - ? await ensResolver.resolveName(prePayment.tokenAddress) - : prePayment.tokenAddress; - const tokenInfo = - resolvedTokenAddress === null ? undefined : await tokenInfoProvider.getTokenInfo(resolvedTokenAddress); - if (typeof tokenInfo !== "undefined") { - let decimals = tokenInfo.decimals; - let symbol = tokenInfo.symbol; - return { - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - amount: prePayment.amount, - tokenAddress: resolvedTokenAddress, - decimals, - symbol, - receiverEnsName, - }; - } else { - return { - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - amount: prePayment.amount, - tokenAddress: prePayment.tokenAddress, - decimals: -1, - symbol: "TOKEN_NOT_FOUND", - receiverEnsName, - }; - } -} diff --git a/src/erc20.ts b/src/transfers/erc20.ts similarity index 82% rename from src/erc20.ts rename to src/transfers/erc20.ts index 655b4f43..617c630d 100644 --- a/src/erc20.ts +++ b/src/transfers/erc20.ts @@ -1,6 +1,6 @@ import { ethers } from "ethers"; -import { ERC20, ERC20__factory } from "./contracts"; +import { ERC20, ERC20__factory } from "../contracts"; export const erc20Interface = ERC20__factory.createInterface(); diff --git a/src/transfers/erc721.ts b/src/transfers/erc721.ts new file mode 100644 index 00000000..6172211a --- /dev/null +++ b/src/transfers/erc721.ts @@ -0,0 +1,9 @@ +import { ethers } from "ethers"; + +import { ERC721, ERC721__factory } from "../contracts"; + +export const erc721Interface = ERC721__factory.createInterface(); + +export function erc721Instance(address: string, provider: ethers.providers.Provider): ERC721 { + return ERC721__factory.connect(address, provider); +} diff --git a/src/transfers.ts b/src/transfers/transfers.ts similarity index 51% rename from src/transfers.ts rename to src/transfers/transfers.ts index 40e82681..c04ed94b 100644 --- a/src/transfers.ts +++ b/src/transfers/transfers.ts @@ -1,10 +1,13 @@ import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk"; +import { Payment } from "../assetParser"; +import { CollectibleTransfer } from "../collectiblesParser"; +import { toWei } from "../utils"; + import { erc20Interface } from "./erc20"; -import { Payment } from "./parser"; -import { toWei } from "./utils"; +import { erc721Interface } from "./erc721"; -export function buildTransfers(transferData: Payment[]): BaseTransaction[] { +export function buildAssetTransfers(transferData: Payment[]): BaseTransaction[] { const txList: BaseTransaction[] = transferData.map((transfer) => { if (transfer.tokenAddress === null) { // Native asset transfer @@ -26,3 +29,18 @@ export function buildTransfers(transferData: Payment[]): BaseTransaction[] { }); return txList; } + +export function buildERC721Transfers(transferData: CollectibleTransfer[]): BaseTransaction[] { + const txList: BaseTransaction[] = transferData.map((transfer) => { + return { + to: transfer.tokenAddress, + value: "0", + data: erc721Interface.encodeFunctionData("transferFrom", [ + transfer.from, + transfer.receiver, + transfer.tokenId.toFixed(), + ]), + }; + }); + return txList; +} diff --git a/src/utils.ts b/src/utils.ts index d7b209f6..5b8a7cb9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,8 +2,8 @@ import { SafeInfo } from "@gnosis.pm/safe-apps-sdk"; import { BigNumber } from "bignumber.js"; import { ethers, utils } from "ethers"; -import { erc20Instance } from "./erc20"; -import { Payment } from "./parser"; +import { Payment } from "./assetParser"; +import { erc20Instance } from "./transfers/erc20"; export const ZERO = new BigNumber(0); export const ONE = new BigNumber(1); From 6ca864f8f08455000be3bc896d0377616ae33fa8 Mon Sep 17 00:00:00 2001 From: schmanu Date: Fri, 1 Oct 2021 16:47:13 +0200 Subject: [PATCH 02/13] UI changes for multisending nfts and erc20 tokens in one transaction NEW UI: * Tab-Navigation to fill out a CSV for Asset-Transfers or Collectible-Transfers * transfer-tables are now always displayed under the CSV-Form. * there are now two tables: For asset transfers and for collectible transfers * submit button is at the bottom of these tables issue #16 --- src/App.tsx | 112 +++++++++++++++--- src/GlobalStyle.ts | 7 ++ src/components/NFTCSVForm.tsx | 62 +++------- src/components/assets/AssetCSVForm.tsx | 67 ++++------- src/components/assets/AssetTransferTable.tsx | 2 +- .../assets/CollectiblesTransferTable.tsx | 4 +- 6 files changed, 146 insertions(+), 108 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 94549f2f..b840d75a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,21 +1,57 @@ import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { Dot, Icon, Loader, Tab, Text } from "@gnosis.pm/safe-react-components"; +import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk"; +import { Button, Card, Divider, Dot, Icon, Loader, Tab, Text } from "@gnosis.pm/safe-react-components"; import { Item } from "@gnosis.pm/safe-react-components/dist/navigation/Tab"; import { setUseWhatChange } from "@simbathesailor/use-what-changed"; -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import styled from "styled-components"; +import { Payment } from "./assetParser"; +import { CollectibleTransfer } from "./collectiblesParser"; import { Header } from "./components/Header"; import { NFTCSVForm } from "./components/NFTCSVForm"; import { AssetCSVForm } from "./components/assets/AssetCSVForm"; +import { AssetTransferTable } from "./components/assets/AssetTransferTable"; +import { CollectiblesTransferTable } from "./components/assets/CollectiblesTransferTable"; import { useTokenList, networkMap } from "./hooks/token"; +import { buildAssetTransfers, buildERC721Transfers } from "./transfers/transfers"; setUseWhatChange(process.env.NODE_ENV === "development"); const App: React.FC = () => { const { isLoading } = useTokenList(); const { safe } = useSafeAppsSDK(); + const [assetTxCount, setAssetTxCount] = useState(0); + const [assetsCsvText, setAssetsCsvText] = useState("token_address,receiver,amount"); + const [assetTransfers, setAssetTransfers] = useState([]); + + const [collectiblesCsvText, setCollectiblesCsvText] = useState("token_address,tokenId,receiver"); + const [collectibleTxCount, setCollectibleTxCount] = useState(0); + const [collectibleTransfers, setCollectibleTransfers] = useState([]); + const [selectedTab, setSelectedTab] = useState("assets"); + + const [submitting, setSubmitting] = useState(false); + const [parsing, setParsing] = useState(false); + const { sdk } = useSafeAppsSDK(); + + const submitTx = useCallback(async () => { + setSubmitting(true); + try { + const txs: BaseTransaction[] = []; + txs.push(...buildAssetTransfers(assetTransfers)); + txs.push(...buildERC721Transfers(collectibleTransfers)); + + console.log(`Encoded ${txs.length} ERC20 transfers.`); + const sendTxResponse = await sdk.txs.send({ txs }); + const safeTx = await sdk.txs.getBySafeTxHash(sendTxResponse.safeTxHash); + console.log({ safeTx }); + } catch (e) { + console.error(e); + } + setSubmitting(false); + }, [assetTransfers, collectibleTransfers, sdk.txs]); + const navigationItems: Item[] = [ { id: "assets", @@ -27,11 +63,13 @@ const App: React.FC = () => { Assets - - - 7 - - + {assetTxCount > 0 && ( + + + {assetTxCount} + + + )} ), }, @@ -45,11 +83,13 @@ const App: React.FC = () => { Collectibles - - - 2 - - + {collectibleTxCount > 0 && ( + + + {collectibleTxCount} + + + )} ), }, @@ -67,8 +107,52 @@ const App: React.FC = () => { ) : ( <> - {selectedTab === "assets" && } - {selectedTab === "collectibles" && } + {selectedTab === "assets" && ( + + )} + {selectedTab === "collectibles" && ( + + )} + + +
+ {assetTransfers.length > 0 && } + {collectibleTransfers.length > 0 && ( + + )} +
+
+ {submitting ? ( + <> + +
+ + + ) : ( + + )} )} diff --git a/src/GlobalStyle.ts b/src/GlobalStyle.ts index 79d7efaa..d0f6993c 100644 --- a/src/GlobalStyle.ts +++ b/src/GlobalStyle.ts @@ -82,6 +82,13 @@ const GlobalStyle = createGlobalStyle` .navDot p { color: white !important; } + + .tableContainer { + display: flex; + flex-direction: horizontal; + gap: 16px; + width: 100%; + } `; export default GlobalStyle; diff --git a/src/components/NFTCSVForm.tsx b/src/components/NFTCSVForm.tsx index ea2c01a8..269a265b 100644 --- a/src/components/NFTCSVForm.tsx +++ b/src/components/NFTCSVForm.tsx @@ -1,5 +1,5 @@ import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { Card, Text, Button, Loader } from "@gnosis.pm/safe-react-components"; +import { Card, Text } from "@gnosis.pm/safe-react-components"; import debounce from "lodash.debounce"; import React, { useCallback, useContext, useMemo, useState } from "react"; import styled from "styled-components"; @@ -12,7 +12,6 @@ import { useERC721InfoProvider } from "../hooks/erc721InfoProvider"; import { CSVEditor } from "./CSVEditor"; import { CSVUpload } from "./CSVUpload"; -import { CollectiblesTransferTable } from "./assets/CollectiblesTransferTable"; const Form = styled.div` flex: 1; @@ -22,36 +21,26 @@ const Form = styled.div` gap: 8px; `; -export interface CSVFormProps {} +export interface CSVFormProps { + updateTxCount: (number) => void; + updateCsvContent: (string) => void; + csvContent: string; + updateTransferTable: (transfers: CollectibleTransfer[]) => void; + setParsing: (parsing: boolean) => void; +} export const NFTCSVForm = (props: CSVFormProps): JSX.Element => { - const [parsing, setParsing] = useState(false); - const [transferContent, setTransferContent] = useState([]); - const [csvText, setCsvText] = useState("token_address,receiver,amount"); - const [submitting, setSubmitting] = useState(false); + const { updateTxCount, csvContent, updateCsvContent, updateTransferTable, setParsing } = props; + const [csvText, setCsvText] = useState(csvContent); const { setCodeWarnings, setMessages } = useContext(MessageContext); - const { sdk } = useSafeAppsSDK(); const erc721InfoProvider = useERC721InfoProvider(); const ensResolver = useEnsResolver(); - const submitTx = useCallback(async () => { - setSubmitting(true); - try { - const txs = buildERC721Transfers(transferContent); - console.log(`Encoded ${txs.length} ERC20 transfers.`); - const sendTxResponse = await sdk.txs.send({ txs }); - const safeTx = await sdk.txs.getBySafeTxHash(sendTxResponse.safeTxHash); - console.log({ safeTx }); - } catch (e) { - console.error(e); - } - setSubmitting(false); - }, [transferContent, sdk.txs]); - const onChangeTextHandler = (csvText: string) => { setCsvText(csvText); + updateCsvContent(csvText); parseAndValidateCSV(csvText); }; @@ -83,13 +72,14 @@ export const NFTCSVForm = (props: CSVFormProps): JSX.Element => { ); } // TODO Check Balances - setTransferContent(transfers); + updateTransferTable(transfers); setCodeWarnings(warnings); + updateTxCount(transfers.length); setParsing(false); }) .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); }, 1000), - [ensResolver, erc721InfoProvider, setCodeWarnings, setMessages], + [ensResolver, erc721InfoProvider, setCodeWarnings, setMessages, setParsing, updateTransferTable, updateTxCount], ); return ( @@ -100,34 +90,12 @@ export const NFTCSVForm = (props: CSVFormProps): JSX.Element => { transaction. - Upload, edit or paste your NFT transfer CSV
(nft_address,id,receiver) + Upload, edit or paste your NFT transfer CSV
(token_address,tokenId,receiver)
- - {transferContent.length > 0 && } - - {submitting ? ( - <> - -
- - - ) : ( - - )} ); diff --git a/src/components/assets/AssetCSVForm.tsx b/src/components/assets/AssetCSVForm.tsx index bfcf293c..37fdeec4 100644 --- a/src/components/assets/AssetCSVForm.tsx +++ b/src/components/assets/AssetCSVForm.tsx @@ -24,14 +24,17 @@ const Form = styled.div` justify-content: space-around; gap: 8px; `; - -export interface CSVFormProps {} +export interface CSVFormProps { + updateTxCount: (count: number) => void; + updateCsvContent: (count: string) => void; + csvContent: string; + updateTransferTable: (transfers: Payment[]) => void; + setParsing: (parsing: boolean) => void; +} export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { - const [parsing, setParsing] = useState(false); - const [transferContent, setTransferContent] = useState([]); - const [csvText, setCsvText] = useState("token_address,receiver,amount"); - const [submitting, setSubmitting] = useState(false); + const { updateTxCount, csvContent, updateCsvContent, updateTransferTable, setParsing } = props; + const [csvText, setCsvText] = useState(csvContent); const { setCodeWarnings, setMessages } = useContext(MessageContext); @@ -40,22 +43,9 @@ export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { const tokenInfoProvider = useTokenInfoProvider(); const ensResolver = useEnsResolver(); - const submitTx = useCallback(async () => { - setSubmitting(true); - try { - const txs = buildAssetTransfers(transferContent); - console.log(`Encoded ${txs.length} ERC20 transfers.`); - const sendTxResponse = await sdk.txs.send({ txs }); - const safeTx = await sdk.txs.getBySafeTxHash(sendTxResponse.safeTxHash); - console.log({ safeTx }); - } catch (e) { - console.error(e); - } - setSubmitting(false); - }, [transferContent, sdk.txs]); - const onChangeTextHandler = (csvText: string) => { setCsvText(csvText); + updateCsvContent(csvText); parseAndValidateCSV(csvText); }; @@ -95,13 +85,24 @@ export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { })), ), ); - setTransferContent(transfers); + updateTransferTable(transfers); setCodeWarnings(warnings); + updateTxCount(transfers.length); setParsing(false); }) .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); }, 1000), - [ensResolver, safe, setCodeWarnings, setMessages, tokenInfoProvider, web3Provider], + [ + ensResolver, + safe, + setCodeWarnings, + setMessages, + setParsing, + tokenInfoProvider, + updateTransferTable, + updateTxCount, + web3Provider, + ], ); return ( @@ -118,28 +119,6 @@ export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { - - {transferContent.length > 0 && } - - {submitting ? ( - <> - -
- - - ) : ( - - )} ); diff --git a/src/components/assets/AssetTransferTable.tsx b/src/components/assets/AssetTransferTable.tsx index f48a5061..de204cba 100644 --- a/src/components/assets/AssetTransferTable.tsx +++ b/src/components/assets/AssetTransferTable.tsx @@ -13,7 +13,7 @@ type TransferTableProps = { export const AssetTransferTable = (props: TransferTableProps) => { const { transferContent } = props; return ( -
+
{ const { transferContent } = props; return ( -
+
{ id: "" + index, cells: [ { id: "token", content: }, + { id: "id", content: {row.tokenId.toString()} }, { id: "receiver", content: , }, - { id: "id", content: {row.tokenId.toString()} }, ], }; })} From a015e13205b0848e911475afd3a506635e7d21b1 Mon Sep 17 00:00:00 2001 From: schmanu Date: Fri, 1 Oct 2021 16:53:55 +0200 Subject: [PATCH 03/13] small ui cleanup --- src/App.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b840d75a..67c1e67a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -125,7 +125,6 @@ const App: React.FC = () => { setParsing={setParsing} /> )} -
{assetTransfers.length > 0 && } @@ -144,7 +143,7 @@ const App: React.FC = () => { ) : ( )} - + )} ) : ( diff --git a/src/GlobalStyle.ts b/src/GlobalStyle.ts index d0f6993c..a7102180 100644 --- a/src/GlobalStyle.ts +++ b/src/GlobalStyle.ts @@ -70,7 +70,7 @@ const GlobalStyle = createGlobalStyle` } .navLabel { - flex: 3; + flex: 1; } .navDot { diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index f9fcd4d0..f7d1150e 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -4,9 +4,9 @@ import * as chai from "chai"; import { expect } from "chai"; import chaiAsPromised from "chai-as-promised"; -import { AssetParser } from "../assetParser"; import { EnsResolver } from "../hooks/ens"; import { TokenMap, MinimalTokenInfo, fetchTokenList, TokenInfoProvider } from "../hooks/token"; +import { CSVParser } from "../parser/csvParser"; import { testData } from "../test/util"; let tokenList: TokenMap; @@ -86,7 +86,7 @@ describe("Parsing CSVs ", () => { it("should throw errors for invalid CSVs", async () => { // this csv contains more values than headers in row1 const invalidCSV = "head1,header2\nvalue1,value2,value3"; - expect(AssetParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockEnsResolver)).to.be.rejectedWith( + expect(CSVParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockEnsResolver)).to.be.rejectedWith( "column header mismatch expected: 2 columns got: 3", ); }); @@ -95,7 +95,7 @@ describe("Parsing CSVs ", () => { // we hard coded in our mock that a ens of "error.eth" throws an error. const rowWithErrorReceiver = [listedToken.address, "error.eth", "1"]; expect( - AssetParser.parseCSV(csvStringFromRows(rowWithErrorReceiver), mockTokenInfoProvider, mockEnsResolver), + CSVParser.parseCSV(csvStringFromRows(rowWithErrorReceiver), mockTokenInfoProvider, mockEnsResolver), ).to.be.rejectedWith("unexpected error!"); }); @@ -104,7 +104,7 @@ describe("Parsing CSVs ", () => { const rowWithDecimalAmount = [listedToken.address, validReceiverAddress, "69.420"]; const rowWithoutTokenAddress = ["", validReceiverAddress, "1"]; - const [payment, warnings] = await AssetParser.parseCSV( + const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(rowWithoutDecimal, rowWithDecimalAmount, rowWithoutTokenAddress), mockTokenInfoProvider, mockEnsResolver, @@ -138,7 +138,7 @@ describe("Parsing CSVs ", () => { const rowWithInvalidTokenAddress = ["0x420", validReceiverAddress, "1"]; const rowWithInvalidReceiverAddress = [listedToken.address, "0x420", "1"]; - const [payment, warnings] = await AssetParser.parseCSV( + const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows( rowWithNegativeAmount, unlistedTokenWithoutDecimalInContract, @@ -181,7 +181,7 @@ describe("Parsing CSVs ", () => { const unknownReceiverEnsName = [listedToken.address, "unknown.eth", "1"]; const unknownTokenEnsName = ["unknown.eth", "receiver1.eth", "1"]; - const [payment, warnings] = await AssetParser.parseCSV( + const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(receiverEnsName, tokenEnsName, unknownReceiverEnsName, unknownTokenEnsName), mockTokenInfoProvider, mockEnsResolver, diff --git a/src/__tests__/transfers.test.ts b/src/__tests__/transfers.test.ts index d8a88f7e..6abeaf7f 100644 --- a/src/__tests__/transfers.test.ts +++ b/src/__tests__/transfers.test.ts @@ -1,8 +1,8 @@ import { BigNumber } from "bignumber.js"; import { expect } from "chai"; -import { Payment } from "../assetParser"; import { fetchTokenList, MinimalTokenInfo } from "../hooks/token"; +import { AssetTransfer } from "../parser/csvParser"; import { testData } from "../test/util"; import { erc20Interface } from "../transfers/erc20"; import { buildAssetTransfers } from "../transfers/transfers"; @@ -24,7 +24,7 @@ describe("Build Transfers:", () => { describe("Integers", () => { it("works with large integers on listed, unlisted and native asset transfers", () => { - const largePayments: Payment[] = [ + const largePayments: AssetTransfer[] = [ // Listed ERC20 { receiver, @@ -76,7 +76,7 @@ describe("Build Transfers:", () => { describe("Decimals", () => { it("works with decimal payments on listed, unlisted and native transfers", () => { const tinyAmount = new BigNumber("0.0000001"); - const smallPayments: Payment[] = [ + const smallPayments: AssetTransfer[] = [ // Listed ERC20 { receiver, @@ -131,7 +131,7 @@ describe("Build Transfers:", () => { describe("Mixed", () => { it("works with arbitrary amount strings on listed, unlisted and native transfers", () => { const mixedAmount = new BigNumber("123456.000000789"); - const mixedPayments: Payment[] = [ + const mixedPayments: AssetTransfer[] = [ // Listed ERC20 { receiver, @@ -194,7 +194,7 @@ describe("Build Transfers:", () => { chainId: -1, }; - const payment: Payment = { + const payment: AssetTransfer = { receiver, amount: amount, tokenAddress: crappyToken.address, diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 998a7c98..6cff2536 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,7 +1,7 @@ import { BigNumber } from "bignumber.js"; import { expect } from "chai"; -import { Payment } from "../assetParser"; +import { AssetTransfer } from "../parser/csvParser"; import { testData } from "../test/util"; import { fromWei, toWei, TEN, ONE, ZERO, transfersToSummary } from "../utils"; @@ -43,7 +43,7 @@ describe("fromWei()", () => { describe("transferToSummary()", () => { it("works for integer native currency", () => { - const transfers: Payment[] = [ + const transfers: AssetTransfer[] = [ { tokenAddress: null, amount: new BigNumber(1), @@ -74,7 +74,7 @@ describe("transferToSummary()", () => { }); it("works for decimals in native currency", () => { - const transfers: Payment[] = [ + const transfers: AssetTransfer[] = [ { tokenAddress: null, amount: new BigNumber(0.1), @@ -105,7 +105,7 @@ describe("transferToSummary()", () => { }); it("works for decimals in erc20", () => { - const transfers: Payment[] = [ + const transfers: AssetTransfer[] = [ { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(0.1), @@ -136,7 +136,7 @@ describe("transferToSummary()", () => { }); it("works for integer in erc20", () => { - const transfers: Payment[] = [ + const transfers: AssetTransfer[] = [ { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(1), @@ -167,7 +167,7 @@ describe("transferToSummary()", () => { }); it("works for mixed payments", () => { - const transfers: Payment[] = [ + const transfers: AssetTransfer[] = [ { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(1.1), diff --git a/src/assetParser.ts b/src/assetParser.ts deleted file mode 100644 index af519157..00000000 --- a/src/assetParser.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { parseString, RowTransformCallback, RowValidateCallback } from "@fast-csv/parse"; -import { BigNumber } from "bignumber.js"; -import { utils } from "ethers"; - -import { CodeWarning } from "./contexts/MessageContextProvider"; -import { EnsResolver } from "./hooks/ens"; -import { TokenInfoProvider } from "./hooks/token"; - -/** - * Includes methods to parse, transform and validate csv content - */ - -export interface Payment { - receiver: string; - amount: BigNumber; - tokenAddress: string | null; - decimals: number; - symbol?: string; - receiverEnsName: string | null; -} - -export type CSVRow = { - receiver: string; - amount: string; - token_address: string; - decimals?: string; -}; - -interface PrePayment { - receiver: string; - amount: BigNumber; - tokenAddress: string | null; -} - -const generateWarnings = ( - // We need the row parameter because of the api of fast-csv - _row: Payment, - rowNumber: number, - warnings: string, -) => { - const messages: CodeWarning[] = warnings.split(";").map((warning: string) => ({ - message: warning, - severity: "warning", - lineNo: rowNumber, - })); - return messages; -}; - -export class AssetParser { - public static parseCSV = ( - csvText: string, - tokenInfoProvider: TokenInfoProvider, - ensResolver: EnsResolver, - ): Promise<[Payment[], CodeWarning[]]> => { - return new Promise<[Payment[], CodeWarning[]]>((resolve, reject) => { - const results: Payment[] = []; - const resultingWarnings: CodeWarning[] = []; - parseString(csvText, { headers: true }) - .transform((row: CSVRow, callback) => AssetParser.transformRow(row, tokenInfoProvider, ensResolver, callback)) - .validate((row: Payment, callback: RowValidateCallback) => AssetParser.validateRow(row, callback)) - .on("data", (data: Payment) => results.push(data)) - .on("end", () => resolve([results, resultingWarnings])) - .on("data-invalid", (row: Payment, rowNumber: number, warnings: string) => - resultingWarnings.push(...generateWarnings(row, rowNumber, warnings)), - ) - .on("error", (error) => reject(error)); - }); - }; - - /** - * Transforms each row into a payment object. - */ - private static transformRow = ( - row: CSVRow, - tokenInfoProvider: TokenInfoProvider, - ensResolver: EnsResolver, - callback: RowTransformCallback, - ): void => { - const prePayment: PrePayment = { - // avoids errors from getAddress. Invalid addresses are later caught in validateRow - tokenAddress: - row.token_address === "" || row.token_address === null - ? null - : utils.isAddress(row.token_address) - ? utils.getAddress(row.token_address) - : row.token_address, - amount: new BigNumber(row.amount), - receiver: utils.isAddress(row.receiver) ? utils.getAddress(row.receiver) : row.receiver, - }; - - AssetParser.toPayment(prePayment, tokenInfoProvider, ensResolver) - .then((row) => callback(null, row)) - .catch((reason) => callback(reason)); - }; - - /** - * Validates, that addresses are valid, the amount is big enough and a decimal is given or can be found in token lists. - */ - private static validateRow = (row: Payment, callback: RowValidateCallback) => { - const warnings = [ - ...AssetParser.areAddressesValid(row), - ...AssetParser.isAmountPositive(row), - ...AssetParser.isTokenValid(row), - ]; - callback(null, warnings.length === 0, warnings.join(";")); - }; - - private static areAddressesValid = (row: Payment): string[] => { - const warnings: string[] = []; - if (!(row.tokenAddress === null || utils.isAddress(row.tokenAddress))) { - warnings.push("Invalid Token Address: " + row.tokenAddress); - } - if (!utils.isAddress(row.receiver)) { - warnings.push("Invalid Receiver Address: " + row.receiver); - } - return warnings; - }; - - private static isAmountPositive = (row: Payment): string[] => - row.amount.isGreaterThan(0) ? [] : ["Only positive amounts possible: " + row.amount.toFixed()]; - - private static isTokenValid = (row: Payment): string[] => - row.decimals === -1 && row.symbol === "TOKEN_NOT_FOUND" - ? [`No token contract was found at ${row.tokenAddress}`] - : []; - - private static async toPayment( - prePayment: PrePayment, - tokenInfoProvider: TokenInfoProvider, - ensResolver: EnsResolver, - ): Promise { - // depending on whether there is an ens name or an address provided we either resolve or lookup - // For performance reasons the lookup will be done after the parsing. - let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(prePayment.receiver) - ? [prePayment.receiver, null] - : [ - (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(prePayment.receiver) : null, - prePayment.receiver, - ]; - resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; - if (prePayment.tokenAddress === null) { - // Native asset payment. - return { - receiver: resolvedReceiverAddress, - amount: prePayment.amount, - tokenAddress: prePayment.tokenAddress, - decimals: 18, - symbol: tokenInfoProvider.getNativeTokenSymbol(), - receiverEnsName, - }; - } - let resolvedTokenAddress = (await ensResolver.isEnsEnabled()) - ? await ensResolver.resolveName(prePayment.tokenAddress) - : prePayment.tokenAddress; - const tokenInfo = - resolvedTokenAddress === null ? undefined : await tokenInfoProvider.getTokenInfo(resolvedTokenAddress); - if (typeof tokenInfo !== "undefined") { - let decimals = tokenInfo.decimals; - let symbol = tokenInfo.symbol; - return { - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - amount: prePayment.amount, - tokenAddress: resolvedTokenAddress, - decimals, - symbol, - receiverEnsName, - }; - } else { - return { - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - amount: prePayment.amount, - tokenAddress: prePayment.tokenAddress, - decimals: -1, - symbol: "TOKEN_NOT_FOUND", - receiverEnsName, - }; - } - } -} diff --git a/src/collectiblesParser.ts b/src/collectiblesParser.ts deleted file mode 100644 index 23354121..00000000 --- a/src/collectiblesParser.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { parseString, RowTransformCallback, RowValidateCallback } from "@fast-csv/parse"; -import { BigNumber } from "bignumber.js"; -import { utils } from "ethers"; - -import { CodeWarning } from "./contexts/MessageContextProvider"; -import { EnsResolver } from "./hooks/ens"; -import { ERC721InfoProvider } from "./hooks/erc721InfoProvider"; - -/** - * Includes methods to parse, transform and validate csv content - */ - -export interface CollectibleTransfer { - from: string; - receiver: string; - tokenAddress: string; - tokenName: string; - tokenId: BigNumber; - receiverEnsName: string | null; -} - -export type CSVRow = { - receiver: string; - tokenId: string; - token_address: string; -}; - -export type PreCollectibleTransfer = { - receiver: string; - tokenId: BigNumber; - tokenAddress: string; -}; - -const generateWarnings = ( - // We need the row parameter because of the api of fast-csv - _row: CollectibleTransfer, - rowNumber: number, - warnings: string, -) => { - const messages: CodeWarning[] = warnings.split(";").map((warning: string) => ({ - message: warning, - severity: "warning", - lineNo: rowNumber, - })); - return messages; -}; - -export class CollectiblesParser { - public static parseCSV = ( - csvText: string, - erc721InfoProvider: ERC721InfoProvider, - ensResolver: EnsResolver, - ): Promise<[CollectibleTransfer[], CodeWarning[]]> => { - return new Promise<[CollectibleTransfer[], CodeWarning[]]>((resolve, reject) => { - const results: CollectibleTransfer[] = []; - const resultingWarnings: CodeWarning[] = []; - parseString(csvText, { headers: true }) - .transform((row: CSVRow, callback) => - CollectiblesParser.transformRow(row, erc721InfoProvider, ensResolver, callback), - ) - .validate((row: CollectibleTransfer, callback: RowValidateCallback) => - CollectiblesParser.validateRow(row, callback), - ) - .on("data", (data: CollectibleTransfer) => results.push(data)) - .on("end", () => resolve([results, resultingWarnings])) - .on("data-invalid", (row: CollectibleTransfer, rowNumber: number, warnings: string) => - resultingWarnings.push(...generateWarnings(row, rowNumber, warnings)), - ) - .on("error", (error) => reject(error)); - }); - }; - - /** - * Transforms each row into a payment object. - */ - private static transformRow = ( - row: CSVRow, - erc721InfoProvider: ERC721InfoProvider, - ensResolver: EnsResolver, - callback: RowTransformCallback, - ): void => { - const prePayment: PreCollectibleTransfer = { - // avoids errors from getAddress. Invalid addresses are later caught in validateRow - tokenAddress: utils.isAddress(row.token_address) ? utils.getAddress(row.token_address) : row.token_address, - tokenId: new BigNumber(row.tokenId), - receiver: utils.isAddress(row.receiver) ? utils.getAddress(row.receiver) : row.receiver, - }; - - CollectiblesParser.toCollectibleTransfer(prePayment, erc721InfoProvider, ensResolver) - .then((row) => callback(null, row)) - .catch((reason) => callback(reason)); - }; - - /** - * Validates, that addresses are valid, the amount is big enough and a decimal is given or can be found in token lists. - */ - private static validateRow = (row: CollectibleTransfer, callback: RowValidateCallback) => { - const warnings = [ - ...CollectiblesParser.areAddressesValid(row), - ...CollectiblesParser.isTokenIdPositive(row), - ...CollectiblesParser.isTokenValid(row), - ]; - callback(null, warnings.length === 0, warnings.join(";")); - }; - - private static areAddressesValid = (row: CollectibleTransfer): string[] => { - const warnings: string[] = []; - if (!(row.tokenAddress === null || utils.isAddress(row.tokenAddress))) { - warnings.push("Invalid Token Address: " + row.tokenAddress); - } - if (!utils.isAddress(row.receiver)) { - warnings.push("Invalid Receiver Address: " + row.receiver); - } - return warnings; - }; - - private static isTokenIdPositive = (row: CollectibleTransfer): string[] => - row.tokenId.isGreaterThan(0) ? [] : ["Only positive tokenIds possible: " + row.tokenId.toFixed()]; - - /** - * I'm not sure if checking for the tokenName is enough. - */ - private static isTokenValid = (row: CollectibleTransfer): string[] => - row.tokenName === "TOKEN_NOT_FOUND" ? [`No erc721 contract was found at ${row.tokenAddress}`] : []; - - private static async toCollectibleTransfer( - prePayment: PreCollectibleTransfer, - erc721InfoProvider: ERC721InfoProvider, - ensResolver: EnsResolver, - ): Promise { - // depending on whether there is an ens name or an address provided we either resolve or lookup - // For performance reasons the lookup will be done after the parsing. - let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(prePayment.receiver) - ? [prePayment.receiver, null] - : [ - (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(prePayment.receiver) : null, - prePayment.receiver, - ]; - resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; - const tokenInfo = - prePayment.tokenAddress === null ? undefined : await erc721InfoProvider.getTokenInfo(prePayment.tokenAddress); - const fromAddress = erc721InfoProvider.getFromAddress(); - if (typeof tokenInfo !== "undefined") { - return { - from: fromAddress, - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - tokenId: prePayment.tokenId, - tokenAddress: prePayment.tokenAddress, - tokenName: tokenInfo.name, - receiverEnsName, - }; - } else { - return { - from: fromAddress, - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - tokenId: prePayment.tokenId, - tokenAddress: prePayment.tokenAddress, - tokenName: "TOKEN_NOT_FOUND", - receiverEnsName, - }; - } - } -} diff --git a/src/components/NFTCSVForm.tsx b/src/components/NFTCSVForm.tsx deleted file mode 100644 index 269a265b..00000000 --- a/src/components/NFTCSVForm.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { Card, Text } from "@gnosis.pm/safe-react-components"; -import debounce from "lodash.debounce"; -import React, { useCallback, useContext, useMemo, useState } from "react"; -import styled from "styled-components"; - -import { buildERC721Transfers } from "..//transfers/transfers"; -import { CollectiblesParser, CollectibleTransfer } from "../collectiblesParser"; -import { MessageContext } from "../contexts/MessageContextProvider"; -import { useEnsResolver } from "../hooks/ens"; -import { useERC721InfoProvider } from "../hooks/erc721InfoProvider"; - -import { CSVEditor } from "./CSVEditor"; -import { CSVUpload } from "./CSVUpload"; - -const Form = styled.div` - flex: 1; - flex-direction: column; - display: flex; - justify-content: space-around; - gap: 8px; -`; - -export interface CSVFormProps { - updateTxCount: (number) => void; - updateCsvContent: (string) => void; - csvContent: string; - updateTransferTable: (transfers: CollectibleTransfer[]) => void; - setParsing: (parsing: boolean) => void; -} - -export const NFTCSVForm = (props: CSVFormProps): JSX.Element => { - const { updateTxCount, csvContent, updateCsvContent, updateTransferTable, setParsing } = props; - const [csvText, setCsvText] = useState(csvContent); - - const { setCodeWarnings, setMessages } = useContext(MessageContext); - - const erc721InfoProvider = useERC721InfoProvider(); - const ensResolver = useEnsResolver(); - - const onChangeTextHandler = (csvText: string) => { - setCsvText(csvText); - updateCsvContent(csvText); - parseAndValidateCSV(csvText); - }; - - const parseAndValidateCSV = useMemo( - () => - debounce((csvText: string) => { - setParsing(true); - const parsePromise = CollectiblesParser.parseCSV(csvText, erc721InfoProvider, ensResolver); - parsePromise - .then(async ([transfers, warnings]) => { - const uniqueReceiversWithoutEnsName = transfers.reduce( - (previousValue, currentValue): Set => - currentValue.receiverEnsName === null ? previousValue.add(currentValue.receiver) : previousValue, - new Set(), - ); - if (uniqueReceiversWithoutEnsName.size < 15) { - transfers = await Promise.all( - // If there is no ENS Name we will try to lookup the address - transfers.map(async (transfer) => - transfer.receiverEnsName - ? transfer - : { - ...transfer, - receiverEnsName: (await ensResolver.isEnsEnabled()) - ? await ensResolver.lookupAddress(transfer.receiver) - : null, - }, - ), - ); - } - // TODO Check Balances - updateTransferTable(transfers); - setCodeWarnings(warnings); - updateTxCount(transfers.length); - setParsing(false); - }) - .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); - }, 1000), - [ensResolver, erc721InfoProvider, setCodeWarnings, setMessages, setParsing, updateTransferTable, updateTxCount], - ); - - return ( - -
- - Send arbitrarily many distinct NFTs, to arbitrarily many distinct accounts from a CSV file in a single - transaction. - - - Upload, edit or paste your NFT transfer CSV
(token_address,tokenId,receiver) -
- - - - - -
- ); -}; diff --git a/src/components/Summary.tsx b/src/components/Summary.tsx new file mode 100644 index 00000000..9b6cd81a --- /dev/null +++ b/src/components/Summary.tsx @@ -0,0 +1,82 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Dot, + Icon, + Text, + Title, +} from "@gnosis.pm/safe-react-components"; + +import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser"; + +import { AssetTransferTable } from "./assets/AssetTransferTable"; +import { CollectiblesTransferTable } from "./assets/CollectiblesTransferTable"; + +type SummaryProps = { + assetTransfers: AssetTransfer[]; + collectibleTransfers: CollectibleTransfer[]; +}; + +export const Summary = (props: SummaryProps): JSX.Element => { + const { assetTransfers, collectibleTransfers } = props; + const assetTxCount = assetTransfers.length; + const collectibleTxCount = collectibleTransfers.length; + return ( + <> + Summary of transfers + + +
+ + + Assets + + +
+ {assetTxCount > 0 && ( + + {assetTxCount} {`transfer${assetTxCount > 1 ? "s" : ""}`} + + )} +
+
+
+ + + +
+ + +
+ + + Collectibles + +
+ {collectibleTxCount > 0 && ( + + {collectibleTxCount} {`transfer${collectibleTxCount > 1 ? "s" : ""}`} + + )} +
+
+
+ + + +
+ + ); +}; diff --git a/src/components/assets/AssetTransferTable.tsx b/src/components/assets/AssetTransferTable.tsx index de204cba..5cee5d40 100644 --- a/src/components/assets/AssetTransferTable.tsx +++ b/src/components/assets/AssetTransferTable.tsx @@ -1,13 +1,13 @@ import { Table, Text } from "@gnosis.pm/safe-react-components"; import React from "react"; -import { Payment } from "../../assetParser"; +import { AssetTransfer } from "../../parser/csvParser"; import { Receiver } from "../Receiver"; import { ERC20Token } from "./ERC20Token"; type TransferTableProps = { - transferContent: Payment[]; + transferContent: AssetTransfer[]; }; export const AssetTransferTable = (props: TransferTableProps) => { diff --git a/src/components/assets/AssetCSVForm.tsx b/src/components/assets/CSVForm.tsx similarity index 71% rename from src/components/assets/AssetCSVForm.tsx rename to src/components/assets/CSVForm.tsx index 37fdeec4..ad17339d 100644 --- a/src/components/assets/AssetCSVForm.tsx +++ b/src/components/assets/CSVForm.tsx @@ -1,22 +1,20 @@ import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider"; import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { Card, Text, Button, Loader } from "@gnosis.pm/safe-react-components"; +import { Text } from "@gnosis.pm/safe-react-components"; import { ethers } from "ethers"; import debounce from "lodash.debounce"; -import React, { useCallback, useContext, useMemo, useState } from "react"; +import React, { useContext, useMemo, useState } from "react"; import styled from "styled-components"; -import { AssetParser, Payment } from "../../assetParser"; import { MessageContext } from "../../contexts/MessageContextProvider"; import { useEnsResolver } from "../../hooks/ens"; +import { useERC721InfoProvider } from "../../hooks/erc721InfoProvider"; import { useTokenInfoProvider } from "../../hooks/token"; -import { buildAssetTransfers } from "../../transfers/transfers"; +import { AssetTransfer, CSVParser, Transfer } from "../../parser/csvParser"; import { checkAllBalances, transfersToSummary } from "../../utils"; import { CSVEditor } from "../CSVEditor"; import { CSVUpload } from "../CSVUpload"; -import { AssetTransferTable } from "./AssetTransferTable"; - const Form = styled.div` flex: 1; flex-direction: column; @@ -25,15 +23,14 @@ const Form = styled.div` gap: 8px; `; export interface CSVFormProps { - updateTxCount: (count: number) => void; updateCsvContent: (count: string) => void; csvContent: string; - updateTransferTable: (transfers: Payment[]) => void; + updateTransferTable: (transfers: Transfer[]) => void; setParsing: (parsing: boolean) => void; } export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { - const { updateTxCount, csvContent, updateCsvContent, updateTransferTable, setParsing } = props; + const { csvContent, updateCsvContent, updateTransferTable, setParsing } = props; const [csvText, setCsvText] = useState(csvContent); const { setCodeWarnings, setMessages } = useContext(MessageContext); @@ -42,6 +39,7 @@ export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [safe, sdk]); const tokenInfoProvider = useTokenInfoProvider(); const ensResolver = useEnsResolver(); + const erc721TokenInfoProvider = useERC721InfoProvider(); const onChangeTextHandler = (csvText: string) => { setCsvText(csvText); @@ -53,8 +51,7 @@ export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { () => debounce((csvText: string) => { setParsing(true); - const parsePromise = AssetParser.parseCSV(csvText, tokenInfoProvider, ensResolver); - parsePromise + CSVParser.parseCSV(csvText, tokenInfoProvider, erc721TokenInfoProvider, ensResolver) .then(async ([transfers, warnings]) => { const uniqueReceiversWithoutEnsName = transfers.reduce( (previousValue, currentValue): Set => @@ -76,7 +73,13 @@ export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { ), ); } - const summary = transfersToSummary(transfers); + const summary = transfersToSummary( + transfers.filter( + (value) => value.token_type === "erc20" || value.token_type === "native", + ) as AssetTransfer[], + ); + updateTransferTable(transfers); + checkAllBalances(summary, web3Provider, safe).then((insufficientBalances) => setMessages( insufficientBalances.map((insufficientBalanceInfo) => ({ @@ -85,41 +88,37 @@ export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { })), ), ); - updateTransferTable(transfers); setCodeWarnings(warnings); - updateTxCount(transfers.length); setParsing(false); }) .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); }, 1000), [ ensResolver, + erc721TokenInfoProvider, safe, setCodeWarnings, setMessages, setParsing, tokenInfoProvider, updateTransferTable, - updateTxCount, web3Provider, ], ); return ( - -
- - Send arbitrarily many distinct tokens, to arbitrarily many distinct accounts with various different values - from a CSV file in a single transaction. - - - Upload, edit or paste your asset transfer CSV
(token_address,receiver,amount) -
+ + + Send arbitrarily many distinct tokens, to arbitrarily many distinct accounts with various different values from + a CSV file in a single transaction. + + + Upload, edit or paste your asset transfer CSV
(token_type,token_address,receiver,value,id) +
- + - - -
+ + ); }; diff --git a/src/components/assets/CollectiblesTransferTable.tsx b/src/components/assets/CollectiblesTransferTable.tsx index 8bbca8a2..5f8934e8 100644 --- a/src/components/assets/CollectiblesTransferTable.tsx +++ b/src/components/assets/CollectiblesTransferTable.tsx @@ -1,7 +1,7 @@ import { Table, Text } from "@gnosis.pm/safe-react-components"; import React from "react"; -import { CollectibleTransfer } from "../../collectiblesParser"; +import { CollectibleTransfer } from "../../parser/csvParser"; import { Receiver } from "../Receiver"; import { ERC20Token } from "./ERC20Token"; @@ -17,19 +17,19 @@ export const CollectiblesTransferTable = (props: TransferTableProps) => {
{ return { id: "" + index, cells: [ { id: "token", content: }, - { id: "id", content: {row.tokenId.toString()} }, { id: "receiver", content: , }, + { id: "id", content: {row.tokenId.toString()} }, ], }; })} diff --git a/src/parser/csvParser.ts b/src/parser/csvParser.ts new file mode 100644 index 00000000..1c984835 --- /dev/null +++ b/src/parser/csvParser.ts @@ -0,0 +1,90 @@ +import { parseString, RowValidateCallback } from "@fast-csv/parse"; +import { BigNumber } from "bignumber.js"; + +import { CodeWarning } from "../contexts/MessageContextProvider"; +import { EnsResolver } from "../hooks/ens"; +import { ERC721InfoProvider } from "../hooks/erc721InfoProvider"; +import { TokenInfoProvider } from "../hooks/token"; + +import { transform } from "./transformation"; +import { validateRow } from "./validation"; + +/** + * Includes methods to parse, transform and validate csv content + */ + +export type Transfer = AssetTransfer | CollectibleTransfer; + +export type AssetTokenType = "erc20" | "native"; +export type CollectibleTokenType = "erc721" | "erc1155"; + +export interface AssetTransfer { + token_type: AssetTokenType; + receiver: string; + amount: BigNumber; + tokenAddress: string | null; + decimals: number; + symbol?: string; + receiverEnsName: string | null; +} + +export interface CollectibleTransfer { + token_type: CollectibleTokenType; + from: string; + receiver: string; + tokenAddress: string; + tokenName: string; + tokenId: BigNumber; + receiverEnsName: string | null; +} + +export interface UnknownTransfer { + token_type: "unknown"; +} + +export type CSVRow = { + token_type: string; + token_address: string; + receiver: string; + value?: string; + id?: string; +}; + +const generateWarnings = ( + // We need the row parameter because of the api of fast-csv + _row: Transfer, + rowNumber: number, + warnings: string, +) => { + const messages: CodeWarning[] = warnings.split(";").map((warning: string) => ({ + message: warning, + severity: "warning", + lineNo: rowNumber, + })); + return messages; +}; + +export class CSVParser { + public static parseCSV = ( + csvText: string, + tokenInfoProvider: TokenInfoProvider, + erc721TokenInfoProvider: ERC721InfoProvider, + ensResolver: EnsResolver, + ): Promise<[Transfer[], CodeWarning[]]> => { + return new Promise<[Transfer[], CodeWarning[]]>((resolve, reject) => { + const results: Transfer[] = []; + const resultingWarnings: CodeWarning[] = []; + parseString(csvText, { headers: true }) + .transform((row: CSVRow, callback) => + transform(row, tokenInfoProvider, erc721TokenInfoProvider, ensResolver, callback), + ) + .validate((row: Transfer | UnknownTransfer, callback: RowValidateCallback) => validateRow(row, callback)) + .on("data", (data: Transfer) => results.push(data)) + .on("end", () => resolve([results, resultingWarnings])) + .on("data-invalid", (row: Transfer, rowNumber: number, warnings: string) => + resultingWarnings.push(...generateWarnings(row, rowNumber, warnings)), + ) + .on("error", (error) => reject(error)); + }); + }; +} diff --git a/src/parser/transformation.ts b/src/parser/transformation.ts new file mode 100644 index 00000000..6a870514 --- /dev/null +++ b/src/parser/transformation.ts @@ -0,0 +1,209 @@ +import { prependListener } from "process"; + +import { RowTransformCallback } from "@fast-csv/parse"; +import { BigNumber } from "bignumber.js"; +import { utils } from "ethers"; + +import { EnsResolver } from "../hooks/ens"; +import { ERC721InfoProvider } from "../hooks/erc721InfoProvider"; +import { TokenInfoProvider } from "../hooks/token"; + +import { AssetTransfer, CollectibleTransfer, CSVRow, Transfer, UnknownTransfer } from "./csvParser"; + +interface PrePayment { + receiver: string; + amount: BigNumber; + tokenAddress: string | null; + tokenType: string; +} + +interface PreCollectibleTransfer { + receiver: string; + tokenId: BigNumber; + tokenAddress: string; + tokenType: string; + value?: string; +} + +export const transform = ( + row: CSVRow, + tokenInfoProvider: TokenInfoProvider, + erc721InfoProvider: ERC721InfoProvider, + ensResolver: EnsResolver, + callback: RowTransformCallback, +): void => { + switch (row.token_type.toLowerCase()) { + case "erc20": + case "native": + transformAsset(row, tokenInfoProvider, ensResolver, callback); + break; + case "erc721": + transformCollectible(row, erc721InfoProvider, ensResolver, callback); + break; + default: + break; + } +}; + +export const transformAsset = ( + row: CSVRow, + tokenInfoProvider: TokenInfoProvider, + ensResolver: EnsResolver, + callback: RowTransformCallback, +): void => { + const prePayment: PrePayment = { + // avoids errors from getAddress. Invalid addresses are later caught in validateRow + tokenAddress: transformERC20TokenAddress(row.token_address), + amount: new BigNumber(row.value ?? ""), + receiver: normalizeAddress(row.receiver), + tokenType: row.token_type.toLowerCase(), + }; + + toPayment(prePayment, tokenInfoProvider, ensResolver) + .then((row) => callback(null, row)) + .catch((reason) => callback(reason)); +}; + +const toPayment = async ( + row: PrePayment, + tokenInfoProvider: TokenInfoProvider, + ensResolver: EnsResolver, +): Promise => { + // depending on whether there is an ens name or an address provided we either resolve or lookup + // For performance reasons the lookup will be done after the parsing. + let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(row.receiver) + ? [row.receiver, null] + : [(await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(row.receiver) : null, row.receiver]; + resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : row.receiver; + if (row.tokenAddress === null) { + // Native asset payment. + return { + receiver: resolvedReceiverAddress, + amount: row.amount, + tokenAddress: row.tokenAddress, + decimals: 18, + symbol: tokenInfoProvider.getNativeTokenSymbol(), + receiverEnsName, + token_type: "native", + }; + } + let resolvedTokenAddress = (await ensResolver.isEnsEnabled()) + ? await ensResolver.resolveName(row.tokenAddress) + : row.tokenAddress; + const tokenInfo = + resolvedTokenAddress === null ? undefined : await tokenInfoProvider.getTokenInfo(resolvedTokenAddress); + if (typeof tokenInfo !== "undefined") { + let decimals = tokenInfo.decimals; + let symbol = tokenInfo.symbol; + return { + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : row.receiver, + amount: row.amount, + tokenAddress: resolvedTokenAddress, + decimals, + symbol, + receiverEnsName, + token_type: "erc20", + }; + } else { + return { + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : row.receiver, + amount: row.amount, + tokenAddress: row.tokenAddress, + decimals: -1, + symbol: "TOKEN_NOT_FOUND", + receiverEnsName, + token_type: "erc20", + }; + } +}; + +/** + * Transforms each row into a payment object. + */ +export const transformCollectible = ( + row: CSVRow, + erc721InfoProvider: ERC721InfoProvider, + ensResolver: EnsResolver, + callback: RowTransformCallback, +): void => { + const prePayment: PreCollectibleTransfer = { + // avoids errors from getAddress. Invalid addresses are later caught in validateRow + tokenAddress: normalizeAddress(row.token_address), + tokenId: new BigNumber(row.id ?? ""), + receiver: normalizeAddress(row.receiver), + tokenType: row.token_type.toLowerCase(), + value: row.value, + }; + + toCollectibleTransfer(prePayment, erc721InfoProvider, ensResolver) + .then((row) => callback(null, row)) + .catch((reason) => callback(reason)); +}; + +const toCollectibleTransfer = async ( + prePayment: PreCollectibleTransfer, + erc721InfoProvider: ERC721InfoProvider, + ensResolver: EnsResolver, +): Promise => { + const fromAddress = erc721InfoProvider.getFromAddress(); + + let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(prePayment.receiver) + ? [prePayment.receiver, null] + : [ + (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(prePayment.receiver) : null, + prePayment.receiver, + ]; + + console.log("TokenType:" + prePayment.tokenType); + if (prePayment.tokenType === "erc721") { + // depending on whether there is an ens name or an address provided we either resolve or lookup + // For performance reasons the lookup will be done after the parsing. + + resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; + const tokenInfo = + prePayment.tokenAddress === null ? undefined : await erc721InfoProvider.getTokenInfo(prePayment.tokenAddress); + if (typeof tokenInfo !== "undefined") { + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + tokenId: prePayment.tokenId, + tokenAddress: prePayment.tokenAddress, + tokenName: tokenInfo.name, + receiverEnsName, + token_type: prePayment.tokenType, + }; + } else { + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + tokenId: prePayment.tokenId, + tokenAddress: prePayment.tokenAddress, + tokenName: "TOKEN_NOT_FOUND", + receiverEnsName, + token_type: prePayment.tokenType, + }; + } + } else { + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + tokenId: prePayment.tokenId, + tokenAddress: prePayment.tokenAddress, + receiverEnsName: "", + tokenName: "TOKEN_NOT_SUPPORTED_YET", + token_type: "erc1155", + }; + } +}; + +/** + * returns null if the tokenAddress is empty. + * Parses and normalizes tokenAddress into a checksum address if the tokenAddress is provided + */ +const transformERC20TokenAddress = (tokenAddress: string | null) => + tokenAddress === "" || tokenAddress === null ? null : normalizeAddress(tokenAddress); + +/* + * Parses and normalizes tokenAddress + */ +const normalizeAddress = (address: string) => (utils.isAddress(address) ? utils.getAddress(address) : address); diff --git a/src/parser/validation.ts b/src/parser/validation.ts new file mode 100644 index 00000000..e539897b --- /dev/null +++ b/src/parser/validation.ts @@ -0,0 +1,55 @@ +import { RowValidateCallback } from "@fast-csv/parse"; +import { utils } from "ethers"; + +import { AssetTransfer, CollectibleTransfer, Transfer, UnknownTransfer } from "./csvParser"; + +export const validateRow = (row: Transfer | UnknownTransfer, callback: RowValidateCallback) => { + switch (row.token_type) { + case "erc20": + case "native": + validateAssetRow(row, callback); + break; + case "erc1155": + case "erc721": + validateCollectibleRow(row, callback); + break; + default: + callback(null, false, "Unknown token_type: Must be one of erc20, native, erc721, erc1155"); + } +}; + +/** + * Validates, that addresses are valid, the amount is big enough and a decimal is given or can be found in token lists. + */ +export const validateAssetRow = (row: AssetTransfer, callback: RowValidateCallback) => { + const warnings = [...areAddressesValid(row), ...isAmountPositive(row), ...isAssetTokenValid(row)]; + callback(null, warnings.length === 0, warnings.join(";")); +}; + +export const validateCollectibleRow = (row: CollectibleTransfer, callback: RowValidateCallback) => { + const warnings = [...areAddressesValid(row), ...isTokenIdPositive(row), ...isCollectibleTokenValid(row)]; + callback(null, warnings.length === 0, warnings.join(";")); +}; + +const areAddressesValid = (row: Transfer): string[] => { + const warnings: string[] = []; + if (!(row.tokenAddress === null || utils.isAddress(row.tokenAddress))) { + warnings.push("Invalid Token Address: " + row.tokenAddress); + } + if (!utils.isAddress(row.receiver)) { + warnings.push("Invalid Receiver Address: " + row.receiver); + } + return warnings; +}; + +const isAmountPositive = (row: AssetTransfer): string[] => + row.amount.isGreaterThan(0) ? [] : ["Only positive amounts possible: " + row.amount.toFixed()]; + +const isAssetTokenValid = (row: AssetTransfer): string[] => + row.decimals === -1 && row.symbol === "TOKEN_NOT_FOUND" ? [`No token contract was found at ${row.tokenAddress}`] : []; + +const isCollectibleTokenValid = (row: CollectibleTransfer): string[] => + row.tokenName === "TOKEN_NOT_FOUND" ? [`No token contract was found at ${row.tokenAddress}`] : []; + +const isTokenIdPositive = (row: CollectibleTransfer): string[] => + row.tokenId.isGreaterThan(0) ? [] : ["Only positive tokenIds possible: " + row.tokenId.toFixed()]; diff --git a/src/transfers/transfers.ts b/src/transfers/transfers.ts index c04ed94b..a699efb2 100644 --- a/src/transfers/transfers.ts +++ b/src/transfers/transfers.ts @@ -1,13 +1,12 @@ import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk"; -import { Payment } from "../assetParser"; -import { CollectibleTransfer } from "../collectiblesParser"; +import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser"; import { toWei } from "../utils"; import { erc20Interface } from "./erc20"; import { erc721Interface } from "./erc721"; -export function buildAssetTransfers(transferData: Payment[]): BaseTransaction[] { +export function buildAssetTransfers(transferData: AssetTransfer[]): BaseTransaction[] { const txList: BaseTransaction[] = transferData.map((transfer) => { if (transfer.tokenAddress === null) { // Native asset transfer @@ -35,7 +34,7 @@ export function buildERC721Transfers(transferData: CollectibleTransfer[]): BaseT return { to: transfer.tokenAddress, value: "0", - data: erc721Interface.encodeFunctionData("transferFrom", [ + data: erc721Interface.encodeFunctionData("safeTransferFrom", [ transfer.from, transfer.receiver, transfer.tokenId.toFixed(), diff --git a/src/utils.ts b/src/utils.ts index 5b8a7cb9..8334bf06 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,7 +2,7 @@ import { SafeInfo } from "@gnosis.pm/safe-apps-sdk"; import { BigNumber } from "bignumber.js"; import { ethers, utils } from "ethers"; -import { Payment } from "./assetParser"; +import { AssetTransfer } from "./parser/csvParser"; import { erc20Instance } from "./transfers/erc20"; export const ZERO = new BigNumber(0); @@ -49,7 +49,7 @@ export type SummaryEntry = { symbol?: string; }; -export const transfersToSummary = (transfers: Payment[]) => { +export const transfersToSummary = (transfers: AssetTransfer[]) => { return transfers.reduce((previousValue, currentValue): Map => { let tokenSummary = previousValue.get(currentValue.tokenAddress); if (typeof tokenSummary === "undefined") { From 85f7d8908be0681a05cf8e1d61b7a87c138a4b94 Mon Sep 17 00:00:00 2001 From: schmanu Date: Mon, 22 Nov 2021 18:43:33 +0100 Subject: [PATCH 06/13] erc1155 support, some redesigns --- package.json | 2 +- src/App.tsx | 4 +- src/components/Summary.tsx | 38 ++++++++----------- src/components/assets/AssetTransferTable.tsx | 6 ++- src/components/assets/CSVForm.tsx | 1 + .../assets/CollectiblesTransferTable.tsx | 4 ++ src/components/assets/ERC20Token.tsx | 23 ++++++----- src/parser/csvParser.ts | 5 ++- src/parser/transformation.ts | 22 ++++++++--- src/parser/validation.ts | 12 +++++- src/transfers/erc1155.ts | 9 +++++ src/transfers/transfers.ts | 36 +++++++++++++----- test_data/rinkeby-mixed.csv | 6 +++ 13 files changed, 112 insertions(+), 56 deletions(-) create mode 100644 src/transfers/erc1155.ts create mode 100644 test_data/rinkeby-mixed.csv diff --git a/package.json b/package.json index a4bc448f..4363173c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "fmt": "prettier --check '**/*.ts'", "fmt:write": "prettier --write '**/*.ts'", "prepare": "husky install", - "generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json' './customabis/ERC721.json'", + "generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json' './customabis/ERC721.json' './node_modules/@openzeppelin/contracts/build/contracts/ERC1155.json'", "postinstall": "yarn generate-types" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index bf52c514..a87d3a8d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { Summary } from "./components/Summary"; import { AssetCSVForm } from "./components/assets/CSVForm"; import { useTokenList, networkMap } from "./hooks/token"; import { AssetTransfer, CollectibleTransfer, Transfer } from "./parser/csvParser"; -import { buildAssetTransfers, buildERC721Transfers } from "./transfers/transfers"; +import { buildAssetTransfers, buildCollectibleTransfers } from "./transfers/transfers"; setUseWhatChange(process.env.NODE_ENV === "development"); @@ -36,7 +36,7 @@ const App: React.FC = () => { try { const txs: BaseTransaction[] = []; txs.push(...buildAssetTransfers(assetTransfers)); - txs.push(...buildERC721Transfers(collectibleTransfers)); + txs.push(...buildCollectibleTransfers(collectibleTransfers)); console.log(`Encoded ${txs.length} ERC20 transfers.`); const sendTxResponse = await sdk.txs.send({ txs }); diff --git a/src/components/Summary.tsx b/src/components/Summary.tsx index 9b6cd81a..ab260585 100644 --- a/src/components/Summary.tsx +++ b/src/components/Summary.tsx @@ -1,12 +1,4 @@ -import { - Accordion, - AccordionDetails, - AccordionSummary, - Dot, - Icon, - Text, - Title, -} from "@gnosis.pm/safe-react-components"; +import { Accordion, AccordionDetails, AccordionSummary, Icon, Text, Title } from "@gnosis.pm/safe-react-components"; import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser"; @@ -25,7 +17,7 @@ export const Summary = (props: SummaryProps): JSX.Element => { return ( <> Summary of transfers - +
{ }} > - + Assets
- {assetTxCount > 0 && ( - - {assetTxCount} {`transfer${assetTxCount > 1 ? "s" : ""}`} - - )} + + {`${assetTxCount > 0 ? assetTxCount : "no"} transfer${ + assetTxCount > 1 || assetTxCount === 0 ? "s" : "" + }`} +
@@ -55,21 +47,21 @@ export const Summary = (props: SummaryProps): JSX.Element => {
- +
- + Collectibles
- {collectibleTxCount > 0 && ( - - {collectibleTxCount} {`transfer${collectibleTxCount > 1 ? "s" : ""}`} - - )} + + {`${collectibleTxCount > 0 ? collectibleTxCount : "no"} transfer${ + collectibleTxCount > 1 || collectibleTxCount === 0 ? "s" : "" + }`} +
diff --git a/src/components/assets/AssetTransferTable.tsx b/src/components/assets/AssetTransferTable.tsx index 5cee5d40..8e3fa263 100644 --- a/src/components/assets/AssetTransferTable.tsx +++ b/src/components/assets/AssetTransferTable.tsx @@ -16,20 +16,22 @@ export const AssetTransferTable = (props: TransferTableProps) => {
{ return { id: "" + index, cells: [ + { id: "position", content: row.position }, { id: "token", content: }, { id: "receiver", content: , }, - { id: "amount", content: {row.amount.toString()} }, + { id: "value", content: {row.amount.toString()} }, ], }; })} diff --git a/src/components/assets/CSVForm.tsx b/src/components/assets/CSVForm.tsx index ad17339d..7aaf0be2 100644 --- a/src/components/assets/CSVForm.tsx +++ b/src/components/assets/CSVForm.tsx @@ -73,6 +73,7 @@ export const AssetCSVForm = (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", diff --git a/src/components/assets/CollectiblesTransferTable.tsx b/src/components/assets/CollectiblesTransferTable.tsx index 5f8934e8..802ddcd0 100644 --- a/src/components/assets/CollectiblesTransferTable.tsx +++ b/src/components/assets/CollectiblesTransferTable.tsx @@ -16,19 +16,23 @@ export const CollectiblesTransferTable = (props: TransferTableProps) => {
{ return { id: "" + index, cells: [ + { id: "position", content: row.position }, { id: "token", content: }, { id: "receiver", content: , }, + { id: "value", content: {row.value?.toString()} }, { id: "id", content: {row.tokenId.toString()} }, ], }; diff --git a/src/components/assets/ERC20Token.tsx b/src/components/assets/ERC20Token.tsx index 09e02054..f83ed462 100644 --- a/src/components/assets/ERC20Token.tsx +++ b/src/components/assets/ERC20Token.tsx @@ -1,4 +1,4 @@ -import { Text } from "@gnosis.pm/safe-react-components"; +import { Icon, Text } from "@gnosis.pm/safe-react-components"; import styled from "styled-components"; import { useTokenList } from "../../hooks/token"; @@ -22,15 +22,18 @@ export const ERC20Token = (props: TokenProps) => { const { tokenList } = useTokenList(); return ( - {" "} + {tokenList.get(tokenAddress) && ( + + )} + {tokenAddress === null && } {symbol || tokenAddress} ); diff --git a/src/parser/csvParser.ts b/src/parser/csvParser.ts index 1c984835..f7bb24e1 100644 --- a/src/parser/csvParser.ts +++ b/src/parser/csvParser.ts @@ -26,6 +26,7 @@ export interface AssetTransfer { decimals: number; symbol?: string; receiverEnsName: string | null; + position?: number; } export interface CollectibleTransfer { @@ -33,9 +34,11 @@ export interface CollectibleTransfer { from: string; receiver: string; tokenAddress: string; - tokenName: string; + tokenName?: string; tokenId: BigNumber; + value?: BigNumber; receiverEnsName: string | null; + position?: number; } export interface UnknownTransfer { diff --git a/src/parser/transformation.ts b/src/parser/transformation.ts index 6a870514..bd3d6f5e 100644 --- a/src/parser/transformation.ts +++ b/src/parser/transformation.ts @@ -22,7 +22,7 @@ interface PreCollectibleTransfer { tokenId: BigNumber; tokenAddress: string; tokenType: string; - value?: string; + value?: BigNumber; } export const transform = ( @@ -38,9 +38,12 @@ export const transform = ( transformAsset(row, tokenInfoProvider, ensResolver, callback); break; case "erc721": + case "erc1155": transformCollectible(row, erc721InfoProvider, ensResolver, callback); break; default: + callback(null, { token_type: "unknown" }); + break; } }; @@ -132,7 +135,7 @@ export const transformCollectible = ( tokenId: new BigNumber(row.id ?? ""), receiver: normalizeAddress(row.receiver), tokenType: row.token_type.toLowerCase(), - value: row.value, + value: new BigNumber(row.value ?? ""), }; toCollectibleTransfer(prePayment, erc721InfoProvider, ensResolver) @@ -155,11 +158,8 @@ const toCollectibleTransfer = async ( ]; console.log("TokenType:" + prePayment.tokenType); + resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; if (prePayment.tokenType === "erc721") { - // depending on whether there is an ens name or an address provided we either resolve or lookup - // For performance reasons the lookup will be done after the parsing. - - resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; const tokenInfo = prePayment.tokenAddress === null ? undefined : await erc721InfoProvider.getTokenInfo(prePayment.tokenAddress); if (typeof tokenInfo !== "undefined") { @@ -183,6 +183,16 @@ const toCollectibleTransfer = async ( token_type: prePayment.tokenType, }; } + } else if (prePayment.tokenType === "erc1155") { + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, + tokenId: prePayment.tokenId, + tokenAddress: prePayment.tokenAddress, + receiverEnsName, + value: prePayment.value, + token_type: "erc1155", + }; } else { return { from: fromAddress, diff --git a/src/parser/validation.ts b/src/parser/validation.ts index e539897b..3ac3db0f 100644 --- a/src/parser/validation.ts +++ b/src/parser/validation.ts @@ -27,7 +27,12 @@ export const validateAssetRow = (row: AssetTransfer, callback: RowValidateCallba }; export const validateCollectibleRow = (row: CollectibleTransfer, callback: RowValidateCallback) => { - const warnings = [...areAddressesValid(row), ...isTokenIdPositive(row), ...isCollectibleTokenValid(row)]; + const warnings = [ + ...areAddressesValid(row), + ...isTokenIdPositive(row), + ...isCollectibleTokenValid(row), + ...isTokenValueValid(row), + ]; callback(null, warnings.length === 0, warnings.join(";")); }; @@ -53,3 +58,8 @@ const isCollectibleTokenValid = (row: CollectibleTransfer): string[] => const isTokenIdPositive = (row: CollectibleTransfer): string[] => row.tokenId.isGreaterThan(0) ? [] : ["Only positive tokenIds possible: " + row.tokenId.toFixed()]; + +const isTokenValueValid = (row: CollectibleTransfer): string[] => + row.token_type === "erc721" || (typeof row.value !== "undefined" && row.value.isGreaterThan(0)) + ? [] + : ["ERC1155 Tokens need a defined value > 0: " + row.value?.toFixed()]; diff --git a/src/transfers/erc1155.ts b/src/transfers/erc1155.ts new file mode 100644 index 00000000..7479683c --- /dev/null +++ b/src/transfers/erc1155.ts @@ -0,0 +1,9 @@ +import { ethers } from "ethers"; + +import { ERC1155, ERC1155__factory } from "../contracts"; + +export const erc1155Interface = ERC1155__factory.createInterface(); + +export function erc1155Instance(address: string, provider: ethers.providers.Provider): ERC1155 { + return ERC1155__factory.connect(address, provider); +} diff --git a/src/transfers/transfers.ts b/src/transfers/transfers.ts index a699efb2..002179e8 100644 --- a/src/transfers/transfers.ts +++ b/src/transfers/transfers.ts @@ -1,8 +1,10 @@ import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk"; +import { ethers } from "ethers"; import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser"; import { toWei } from "../utils"; +import { erc1155Interface } from "./erc1155"; import { erc20Interface } from "./erc20"; import { erc721Interface } from "./erc721"; @@ -29,17 +31,31 @@ export function buildAssetTransfers(transferData: AssetTransfer[]): BaseTransact return txList; } -export function buildERC721Transfers(transferData: CollectibleTransfer[]): BaseTransaction[] { +export function buildCollectibleTransfers(transferData: CollectibleTransfer[]): BaseTransaction[] { const txList: BaseTransaction[] = transferData.map((transfer) => { - return { - to: transfer.tokenAddress, - value: "0", - data: erc721Interface.encodeFunctionData("safeTransferFrom", [ - transfer.from, - transfer.receiver, - transfer.tokenId.toFixed(), - ]), - }; + if (transfer.token_type === "erc721") { + return { + to: transfer.tokenAddress, + value: "0", + data: erc721Interface.encodeFunctionData("safeTransferFrom", [ + transfer.from, + transfer.receiver, + transfer.tokenId.toFixed(), + ]), + }; + } else { + return { + to: transfer.tokenAddress, + value: "0", + data: erc1155Interface.encodeFunctionData("safeTransferFrom", [ + transfer.from, + transfer.receiver, + transfer.tokenId.toFixed(), + transfer.value?.toFixed() ?? "0", + ethers.utils.hexlify("0x00"), + ]), + }; + } }); return txList; } diff --git a/test_data/rinkeby-mixed.csv b/test_data/rinkeby-mixed.csv new file mode 100644 index 00000000..2af4ad4a --- /dev/null +++ b/test_data/rinkeby-mixed.csv @@ -0,0 +1,6 @@ +token_type,token_address,receiver,value,id +native,,schmanu.eth,2 +erc20,0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b,vitalik.eth,2 +erc1155,0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656,schmanu.eth,5,1 +erc1155,0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656,schmanu.eth,5,5 +erc20,0xd0dab4e640d95e9e8a47545598c33e31bdb53c7c,0x9ed3822542BA9D38a36767e6536769296c106C47,2 \ No newline at end of file From bf484b6d4dafbc1256e8dccce2b10b070fd1cf7e Mon Sep 17 00:00:00 2001 From: schmanu Date: Mon, 22 Nov 2021 18:48:27 +0100 Subject: [PATCH 07/13] small refactoring of modal --- src/App.tsx | 61 ++-------------------------------- src/components/FAQModal.tsx | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 58 deletions(-) create mode 100644 src/components/FAQModal.tsx diff --git a/src/App.tsx b/src/App.tsx index ad2225b8..1641be26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,11 @@ import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk"; -import { Button, Card, Divider, GenericModal, Icon, Loader, Title, Text } from "@gnosis.pm/safe-react-components"; -import { Fab } from "@material-ui/core"; +import { 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 { FAQModal } from "./components/FAQModal"; import { Header } from "./components/Header"; import { Summary } from "./components/Summary"; import { AssetCSVForm } from "./components/assets/CSVForm"; @@ -24,7 +24,6 @@ const App: React.FC = () => { const [submitting, setSubmitting] = useState(false); const [parsing, setParsing] = useState(false); const { sdk } = useSafeAppsSDK(); - const [showHelp, setShowHelp] = useState(false); const assetTransfers = tokenTransfers.filter( (transfer) => transfer.token_type === "erc20" || transfer.token_type === "native", @@ -95,61 +94,7 @@ const App: React.FC = () => { ) : ( Network with chainId {safe.chainId} not yet supported. )} - setShowHelp(true)} - > - - Help - - {showHelp && ( - setShowHelp(false)} - title={How to use the CSV Airdrop Gnosis App} - body={ -
- - Preparing a Transfer File - - - Transfer files are expected to be in CSV format with the following required columns: -
    -
  • - receiver: Ethereum address of transfer receiver. -
  • -
  • - token_address: Ethereum address of ERC20 token to be transferred. -
  • -
  • - amount: the amount of token to be transferred. -
  • -
-

- - Important: The CSV file has to use "," as a separator and the header row always has to be provided - as the first row and include the described column names. - -

-
- - - Native Token Transfers - - - Since native tokens do not have a token address, you must leave the token_address column - blank for native transfers. - -
- } - footer={ - - } - >
- )} + ); }; diff --git a/src/components/FAQModal.tsx b/src/components/FAQModal.tsx new file mode 100644 index 00000000..ae3072b8 --- /dev/null +++ b/src/components/FAQModal.tsx @@ -0,0 +1,66 @@ +import { Icon, Text, Title, Divider, Button, GenericModal } from "@gnosis.pm/safe-react-components"; +import { Fab } from "@material-ui/core"; +import { useState } from "react"; + +export const FAQModal: () => JSX.Element = () => { + const [showHelp, setShowHelp] = useState(false); + return ( + <> + setShowHelp(true)} + > + + Help + + {showHelp && ( + setShowHelp(false)} + title={How to use the CSV Airdrop Gnosis App} + body={ +
+ + Preparing a Transfer File + + + Transfer files are expected to be in CSV format with the following required columns: +
    +
  • + receiver: Ethereum address of transfer receiver. +
  • +
  • + token_address: Ethereum address of ERC20 token to be transferred. +
  • +
  • + amount: the amount of token to be transferred. +
  • +
+

+ + Important: The CSV file has to use "," as a separator and the header row always has to be provided + as the first row and include the described column names. + +

+
+ + + Native Token Transfers + + + Since native tokens do not have a token address, you must leave the token_address column + blank for native transfers. + +
+ } + footer={ + + } + >
+ )} + + ); +}; From 17622bf3d751d1a2ee12dd63504476ba11ad838a Mon Sep 17 00:00:00 2001 From: schmanu Date: Tue, 30 Nov 2021 18:05:32 +0100 Subject: [PATCH 08/13] fixes existing unittests --- src/__tests__/parser.test.ts | 69 +++++++++++++++++++++++---------- src/__tests__/transfers.test.ts | 34 ++++++++++------ src/__tests__/utils.test.ts | 41 ++++++++++++++------ src/test/util.ts | 12 +++++- 4 files changed, 109 insertions(+), 47 deletions(-) diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index f7d1150e..e27b86ce 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -5,8 +5,9 @@ import { expect } from "chai"; import chaiAsPromised from "chai-as-promised"; import { EnsResolver } from "../hooks/ens"; +import { ERC721InfoProvider } from "../hooks/erc721InfoProvider"; import { TokenMap, MinimalTokenInfo, fetchTokenList, TokenInfoProvider } from "../hooks/token"; -import { CSVParser } from "../parser/csvParser"; +import { AssetTransfer, CSVParser } from "../parser/csvParser"; import { testData } from "../test/util"; let tokenList: TokenMap; @@ -22,12 +23,13 @@ chai.use(chaiAsPromised); * @param rows array of row-arrays */ const csvStringFromRows = (...rows: string[][]): string => { - const headerRow = "token_address,receiver,amount"; + const headerRow = "token_type,token_address,receiver,value,id"; return [headerRow, ...rows.map((row) => row.join(","))].join("\n"); }; describe("Parsing CSVs ", () => { let mockTokenInfoProvider: TokenInfoProvider; + let mockERC721InfoProvider: ERC721InfoProvider; let mockEnsResolver: EnsResolver; beforeAll(async () => { @@ -45,6 +47,18 @@ describe("Parsing CSVs ", () => { getNativeTokenSymbol: () => "ETH", }; + mockERC721InfoProvider = { + getFromAddress: () => testData.dummySafeInfo.safeAddress, + getTokenInfo: async (tokenAddress) => { + switch (tokenAddress) { + case testData.addresses.dummyErc721Address: + return testData.dummyERC721Token; + default: + return undefined; + } + }, + }; + mockEnsResolver = { resolveName: async (ensName: string) => { if (ensName.startsWith("0x")) { @@ -86,32 +100,38 @@ describe("Parsing CSVs ", () => { it("should throw errors for invalid CSVs", async () => { // this csv contains more values than headers in row1 const invalidCSV = "head1,header2\nvalue1,value2,value3"; - expect(CSVParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockEnsResolver)).to.be.rejectedWith( - "column header mismatch expected: 2 columns got: 3", - ); + expect( + CSVParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockERC721InfoProvider, mockEnsResolver), + ).to.be.rejectedWith("column header mismatch expected: 2 columns got: 3"); }); 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 = [listedToken.address, "error.eth", "1"]; + const rowWithErrorReceiver = ["erc20", listedToken.address, "error.eth", "1"]; expect( - CSVParser.parseCSV(csvStringFromRows(rowWithErrorReceiver), mockTokenInfoProvider, mockEnsResolver), + CSVParser.parseCSV( + csvStringFromRows(rowWithErrorReceiver), + mockTokenInfoProvider, + mockERC721InfoProvider, + mockEnsResolver, + ), ).to.be.rejectedWith("unexpected error!"); }); it("should transform simple, valid CSVs correctly", async () => { - const rowWithoutDecimal = [listedToken.address, validReceiverAddress, "1"]; - const rowWithDecimalAmount = [listedToken.address, validReceiverAddress, "69.420"]; - const rowWithoutTokenAddress = ["", validReceiverAddress, "1"]; + const rowWithoutDecimal = ["erc20", listedToken.address, validReceiverAddress, "1"]; + const rowWithDecimalAmount = ["erc20", listedToken.address, validReceiverAddress, "69.420"]; + const rowWithoutTokenAddress = ["native", "", validReceiverAddress, "1"]; const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(rowWithoutDecimal, rowWithDecimalAmount, rowWithoutTokenAddress), mockTokenInfoProvider, + mockERC721InfoProvider, mockEnsResolver, ); expect(warnings).to.be.empty; expect(payment).to.have.lengthOf(3); - const [paymentWithoutDecimal, paymentWithDecimal, paymentWithoutTokenAddress] = payment; + const [paymentWithoutDecimal, paymentWithDecimal, paymentWithoutTokenAddress] = payment as AssetTransfer[]; expect(paymentWithoutDecimal.decimals).to.be.equal(18); expect(paymentWithoutDecimal.receiver).to.equal(validReceiverAddress); expect(paymentWithoutDecimal.tokenAddress).to.equal(listedToken.address); @@ -132,11 +152,16 @@ describe("Parsing CSVs ", () => { }); it("should generate validation warnings", async () => { - const rowWithNegativeAmount = [listedToken.address, validReceiverAddress, "-1"]; + const rowWithNegativeAmount = ["erc20", listedToken.address, validReceiverAddress, "-1"]; - const unlistedTokenWithoutDecimalInContract = [testData.unlistedToken.address, validReceiverAddress, "1"]; - const rowWithInvalidTokenAddress = ["0x420", validReceiverAddress, "1"]; - const rowWithInvalidReceiverAddress = [listedToken.address, "0x420", "1"]; + const unlistedTokenWithoutDecimalInContract = [ + "erc20", + testData.unlistedERC20Token.address, + validReceiverAddress, + "1", + ]; + const rowWithInvalidTokenAddress = ["erc20", "0x420", validReceiverAddress, "1"]; + const rowWithInvalidReceiverAddress = ["erc20", listedToken.address, "0x420", "1"]; const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows( @@ -146,6 +171,7 @@ describe("Parsing CSVs ", () => { rowWithInvalidReceiverAddress, ), mockTokenInfoProvider, + mockERC721InfoProvider, mockEnsResolver, ); expect(warnings).to.have.lengthOf(5); @@ -162,7 +188,7 @@ describe("Parsing CSVs ", () => { expect(warningNegativeAmount.lineNo).to.equal(1); expect(warningTokenNotFound.message.toLowerCase()).to.equal( - `no token contract was found at ${testData.unlistedToken.address.toLowerCase()}`, + `no token contract was found at ${testData.unlistedERC20Token.address.toLowerCase()}`, ); expect(warningTokenNotFound.lineNo).to.equal(2); @@ -176,19 +202,20 @@ describe("Parsing CSVs ", () => { }); it("tries to resolved ens names", async () => { - const receiverEnsName = [listedToken.address, "receiver1.eth", "1"]; - const tokenEnsName = ["token.eth", validReceiverAddress, "69.420"]; - const unknownReceiverEnsName = [listedToken.address, "unknown.eth", "1"]; - const unknownTokenEnsName = ["unknown.eth", "receiver1.eth", "1"]; + const receiverEnsName = ["erc20", listedToken.address, "receiver1.eth", "1"]; + const tokenEnsName = ["erc20", "token.eth", validReceiverAddress, "69.420"]; + const unknownReceiverEnsName = ["erc20", listedToken.address, "unknown.eth", "1"]; + const unknownTokenEnsName = ["erc20", "unknown.eth", "receiver1.eth", "1"]; const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(receiverEnsName, tokenEnsName, unknownReceiverEnsName, unknownTokenEnsName), mockTokenInfoProvider, + mockERC721InfoProvider, mockEnsResolver, ); expect(warnings).to.have.lengthOf(3); expect(payment).to.have.lengthOf(2); - const [paymentReceiverEnsName, paymentTokenEnsName] = payment; + const [paymentReceiverEnsName, paymentTokenEnsName] = payment as AssetTransfer[]; const [warningUnknownReceiverEnsName, warningInvalidTokenAddress, warningInvalidContract] = warnings; expect(paymentReceiverEnsName.decimals).to.be.equal(18); expect(paymentReceiverEnsName.receiver).to.equal(testData.addresses.receiver1); diff --git a/src/__tests__/transfers.test.ts b/src/__tests__/transfers.test.ts index 6abeaf7f..b1bbae48 100644 --- a/src/__tests__/transfers.test.ts +++ b/src/__tests__/transfers.test.ts @@ -27,6 +27,7 @@ describe("Build Transfers:", () => { const largePayments: AssetTransfer[] = [ // Listed ERC20 { + token_type: "erc20", receiver, amount: fromWei(MAX_U256, listedToken.decimals), tokenAddress: listedToken.address, @@ -36,15 +37,17 @@ describe("Build Transfers:", () => { }, // Unlisted ERC20 { + token_type: "erc20", receiver, - amount: fromWei(MAX_U256, testData.unlistedToken.decimals), - tokenAddress: testData.unlistedToken.address, - decimals: testData.unlistedToken.decimals, + amount: fromWei(MAX_U256, testData.unlistedERC20Token.decimals), + tokenAddress: testData.unlistedERC20Token.address, + decimals: testData.unlistedERC20Token.decimals, symbol: "ULT", receiverEnsName: null, }, // Native Asset { + token_type: "native", receiver, amount: fromWei(MAX_U256, 18), tokenAddress: null, @@ -62,7 +65,7 @@ describe("Build Transfers:", () => { ); expect(unlistedTransfer.value).to.be.equal("0"); - expect(unlistedTransfer.to).to.be.equal(testData.unlistedToken.address); + expect(unlistedTransfer.to).to.be.equal(testData.unlistedERC20Token.address); expect(unlistedTransfer.data).to.be.equal( erc20Interface.encodeFunctionData("transfer", [receiver, MAX_U256.toFixed()]), ); @@ -79,6 +82,7 @@ describe("Build Transfers:", () => { const smallPayments: AssetTransfer[] = [ // Listed ERC20 { + token_type: "erc20", receiver, amount: tinyAmount, tokenAddress: listedToken.address, @@ -88,15 +92,17 @@ describe("Build Transfers:", () => { }, // Unlisted ERC20 { + token_type: "erc20", receiver, amount: tinyAmount, - tokenAddress: testData.unlistedToken.address, - decimals: testData.unlistedToken.decimals, + tokenAddress: testData.unlistedERC20Token.address, + decimals: testData.unlistedERC20Token.decimals, symbol: "ULT", receiverEnsName: null, }, // Native Asset { + token_type: "native", receiver, amount: tinyAmount, tokenAddress: null, @@ -114,11 +120,11 @@ describe("Build Transfers:", () => { ); expect(unlisted.value).to.be.equal("0"); - expect(unlisted.to).to.be.equal(testData.unlistedToken.address); + expect(unlisted.to).to.be.equal(testData.unlistedERC20Token.address); expect(unlisted.data).to.be.equal( erc20Interface.encodeFunctionData("transfer", [ receiver, - toWei(tinyAmount, testData.unlistedToken.decimals).toFixed(), + toWei(tinyAmount, testData.unlistedERC20Token.decimals).toFixed(), ]), ); @@ -134,6 +140,7 @@ describe("Build Transfers:", () => { const mixedPayments: AssetTransfer[] = [ // Listed ERC20 { + token_type: "erc20", receiver, amount: mixedAmount, tokenAddress: listedToken.address, @@ -143,15 +150,17 @@ describe("Build Transfers:", () => { }, // Unlisted ERC20 { + token_type: "erc20", receiver, amount: mixedAmount, - tokenAddress: testData.unlistedToken.address, - decimals: testData.unlistedToken.decimals, + tokenAddress: testData.unlistedERC20Token.address, + decimals: testData.unlistedERC20Token.decimals, symbol: "ULT", receiverEnsName: null, }, // Native Asset { + token_type: "native", receiver, amount: mixedAmount, tokenAddress: null, @@ -169,11 +178,11 @@ describe("Build Transfers:", () => { ); expect(unlisted.value).to.be.equal("0"); - expect(unlisted.to).to.be.equal(testData.unlistedToken.address); + expect(unlisted.to).to.be.equal(testData.unlistedERC20Token.address); expect(unlisted.data).to.be.equal( erc20Interface.encodeFunctionData("transfer", [ receiver, - toWei(mixedAmount, testData.unlistedToken.decimals).toFixed(), + toWei(mixedAmount, testData.unlistedERC20Token.decimals).toFixed(), ]), ); @@ -195,6 +204,7 @@ describe("Build Transfers:", () => { }; const payment: AssetTransfer = { + token_type: "erc20", receiver, amount: amount, tokenAddress: crappyToken.address, diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 6cff2536..d8766c91 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -45,6 +45,7 @@ describe("transferToSummary()", () => { it("works for integer native currency", () => { const transfers: AssetTransfer[] = [ { + token_type: "native", tokenAddress: null, amount: new BigNumber(1), receiver: testData.addresses.receiver1, @@ -53,6 +54,7 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { + token_type: "native", tokenAddress: null, amount: new BigNumber(2), receiver: testData.addresses.receiver2, @@ -61,6 +63,7 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { + token_type: "native", tokenAddress: null, amount: new BigNumber(3), receiver: testData.addresses.receiver3, @@ -76,6 +79,7 @@ describe("transferToSummary()", () => { it("works for decimals in native currency", () => { const transfers: AssetTransfer[] = [ { + token_type: "native", tokenAddress: null, amount: new BigNumber(0.1), receiver: testData.addresses.receiver1, @@ -84,6 +88,7 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { + token_type: "native", tokenAddress: null, amount: new BigNumber(0.01), receiver: testData.addresses.receiver2, @@ -92,6 +97,7 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { + token_type: "native", tokenAddress: null, amount: new BigNumber(0.001), receiver: testData.addresses.receiver3, @@ -107,7 +113,8 @@ describe("transferToSummary()", () => { it("works for decimals in erc20", () => { const transfers: AssetTransfer[] = [ { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(0.1), receiver: testData.addresses.receiver1, decimals: 18, @@ -115,7 +122,8 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(0.01), receiver: testData.addresses.receiver2, decimals: 18, @@ -123,7 +131,8 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(0.001), receiver: testData.addresses.receiver3, decimals: 18, @@ -132,13 +141,14 @@ describe("transferToSummary()", () => { }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(testData.unlistedToken.address)?.amount.toFixed()).to.equal("0.111"); + expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("0.111"); }); it("works for integer in erc20", () => { const transfers: AssetTransfer[] = [ { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(1), receiver: testData.addresses.receiver1, decimals: 18, @@ -146,7 +156,8 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(2), receiver: testData.addresses.receiver2, decimals: 18, @@ -154,7 +165,8 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(3), receiver: testData.addresses.receiver3, decimals: 18, @@ -163,13 +175,14 @@ describe("transferToSummary()", () => { }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(testData.unlistedToken.address)?.amount.toFixed()).to.equal("6"); + expect(summary.get(testData.unlistedERC20Token.address)?.amount.toFixed()).to.equal("6"); }); it("works for mixed payments", () => { const transfers: AssetTransfer[] = [ { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(1.1), receiver: testData.addresses.receiver1, decimals: 18, @@ -177,7 +190,8 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(2), receiver: testData.addresses.receiver2, decimals: 18, @@ -185,7 +199,8 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { - tokenAddress: testData.unlistedToken.address, + token_type: "erc20", + tokenAddress: testData.unlistedERC20Token.address, amount: new BigNumber(3.3), receiver: testData.addresses.receiver3, decimals: 18, @@ -193,6 +208,7 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { + token_type: "native", tokenAddress: null, amount: new BigNumber(3), receiver: testData.addresses.receiver1, @@ -201,6 +217,7 @@ describe("transferToSummary()", () => { receiverEnsName: null, }, { + token_type: "native", tokenAddress: null, amount: new BigNumber(0.33), receiver: testData.addresses.receiver1, @@ -210,7 +227,7 @@ describe("transferToSummary()", () => { }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(testData.unlistedToken.address)?.amount.toFixed()).to.equal("6.4"); + 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/test/util.ts b/src/test/util.ts index 8bc2b072..d2f9bad9 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -1,5 +1,6 @@ import { SafeInfo } from "@gnosis.pm/safe-apps-sdk"; +import { ERC721TokenInfo } from "../hooks/erc721InfoProvider"; import { TokenInfo } from "../utils"; const dummySafeInfo: SafeInfo = { @@ -9,7 +10,7 @@ const dummySafeInfo: SafeInfo = { owners: [], }; -const unlistedToken: TokenInfo = { +const unlistedERC20Token: TokenInfo = { address: "0x6b175474e89094c44da98b954eedeac495271d0f", decimals: 18, symbol: "UNL", @@ -17,14 +18,21 @@ const unlistedToken: TokenInfo = { chainId: -1, }; +const dummyERC721Token: ERC721TokenInfo = { + name: "Test NFT", + symbol: "Test", +}; + const addresses = { receiver1: "0x1000000000000000000000000000000000000000", receiver2: "0x2000000000000000000000000000000000000000", receiver3: "0x3000000000000000000000000000000000000000", + dummyErc721Address: "0x5500000000000000000000000000000000000000", }; export const testData = { dummySafeInfo, - unlistedToken, + unlistedERC20Token, addresses, + dummyERC721Token, }; From de91e5a1d881fdcb0986cd4abf47ead1e052e52a Mon Sep 17 00:00:00 2001 From: schmanu Date: Tue, 30 Nov 2021 19:24:25 +0100 Subject: [PATCH 09/13] small refactoring, parser tests --- src/App.tsx | 6 +- src/__tests__/parser.test.ts | 210 +++++++++++++++++++++++++++++- src/components/assets/CSVForm.tsx | 2 +- src/parser/transformation.ts | 36 ++--- src/parser/validation.ts | 12 +- src/test/util.ts | 1 + 6 files changed, 233 insertions(+), 34 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1641be26..13f5280f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import styled from "styled-components"; import { FAQModal } from "./components/FAQModal"; import { Header } from "./components/Header"; import { Summary } from "./components/Summary"; -import { AssetCSVForm } from "./components/assets/CSVForm"; +import { CSVForm } from "./components/assets/CSVForm"; import { useTokenList, networkMap } from "./hooks/token"; import { AssetTransfer, CollectibleTransfer, Transfer } from "./parser/csvParser"; import { buildAssetTransfers, buildCollectibleTransfers } from "./transfers/transfers"; @@ -39,7 +39,7 @@ const App: React.FC = () => { txs.push(...buildAssetTransfers(assetTransfers)); txs.push(...buildCollectibleTransfers(collectibleTransfers)); - console.log(`Encoded ${txs.length} ERC20 transfers.`); + console.log(`Encoded ${txs.length} transfers.`); const sendTxResponse = await sdk.txs.send({ txs }); const safeTx = await sdk.txs.getBySafeTxHash(sendTxResponse.safeTxHash); console.log({ safeTx }); @@ -61,7 +61,7 @@ const App: React.FC = () => { ) : ( - { expect(paymentWithoutTokenAddress.receiverEnsName).to.be.null; }); - it("should generate validation warnings", async () => { + it("should generate erc20 validation warnings", async () => { const rowWithNegativeAmount = ["erc20", listedToken.address, validReceiverAddress, "-1"]; const unlistedTokenWithoutDecimalInContract = [ @@ -201,7 +201,7 @@ describe("Parsing CSVs ", () => { expect(warningInvalidReceiverAddress.lineNo).to.equal(4); }); - it("tries to resolved ens names", async () => { + it("tries to resolve ens names", async () => { const receiverEnsName = ["erc20", listedToken.address, "receiver1.eth", "1"]; const tokenEnsName = ["erc20", "token.eth", validReceiverAddress, "69.420"]; const unknownReceiverEnsName = ["erc20", listedToken.address, "unknown.eth", "1"]; @@ -238,4 +238,208 @@ describe("Parsing CSVs ", () => { expect(warningInvalidContract.lineNo).to.equal(4); expect(warningInvalidContract.message).to.equal("No token contract was found at unknown.eth"); }); + + it("parses valid collectible transfers", async () => { + const rowWithErc721AndAddress = ["erc721", testData.addresses.dummyErc721Address, validReceiverAddress, "", "1"]; + const rowWithErc721AndENS = ["erc721", testData.addresses.dummyErc721Address, "receiver2.eth", "", "69"]; + const rowWithErc1155AndAddress = [ + "erc1155", + testData.addresses.dummyErc1155Address, + validReceiverAddress, + "69", + "420", + ]; + const rowWithErc1155AndENS = ["erc1155", testData.addresses.dummyErc1155Address, "receiver3.eth", "9", "99"]; + + const [payment, warnings] = await CSVParser.parseCSV( + csvStringFromRows(rowWithErc721AndAddress, rowWithErc721AndENS, rowWithErc1155AndAddress, rowWithErc1155AndENS), + mockTokenInfoProvider, + mockERC721InfoProvider, + mockEnsResolver, + ); + expect(warnings).to.be.empty; + expect(payment).to.have.lengthOf(4); + const [transferErc721AndAddress, transferErc721AndENS, transferErc1155AndAddress, transferErc1155AndENS] = + payment as CollectibleTransfer[]; + expect(transferErc721AndAddress.receiver).to.equal(validReceiverAddress); + expect(transferErc721AndAddress.tokenAddress).to.equal(testData.addresses.dummyErc721Address); + expect(transferErc721AndAddress.value).to.be.undefined; + expect(transferErc721AndAddress.tokenId.isEqualTo(new BigNumber(1))).to.be.true; + expect(transferErc721AndAddress.receiverEnsName).to.be.null; + + expect(transferErc721AndENS.receiver).to.equal(testData.addresses.receiver2); + expect(transferErc721AndENS.tokenAddress).to.equal(testData.addresses.dummyErc721Address); + expect(transferErc721AndENS.tokenId.isEqualTo(new BigNumber(69))).to.be.true; + expect(transferErc721AndENS.value).to.be.undefined; + expect(transferErc721AndENS.receiverEnsName).to.equal("receiver2.eth"); + + expect(transferErc1155AndAddress.receiver).to.equal(validReceiverAddress); + expect(transferErc1155AndAddress.tokenAddress.toLowerCase()).to.equal( + testData.addresses.dummyErc1155Address.toLowerCase(), + ); + expect(transferErc1155AndAddress.value).not.to.be.undefined; + expect(transferErc1155AndAddress.value?.isEqualTo(new BigNumber(69))).to.be.true; + expect(transferErc1155AndAddress.tokenId.isEqualTo(new BigNumber(420))).to.be.true; + expect(transferErc1155AndAddress.receiverEnsName).to.be.null; + + expect(transferErc1155AndENS.receiver).to.equal(testData.addresses.receiver3); + expect(transferErc1155AndENS.tokenAddress.toLowerCase()).to.equal( + testData.addresses.dummyErc1155Address.toLowerCase(), + ); + expect(transferErc1155AndENS.value).not.to.be.undefined; + expect(transferErc1155AndENS.value?.isEqualTo(new BigNumber(9))).to.be.true; + expect(transferErc1155AndENS.tokenId.isEqualTo(new BigNumber(99))).to.be.true; + expect(transferErc1155AndENS.receiverEnsName).to.equal("receiver3.eth"); + }); + + it("should generate erc721/erc1155 validation warnings", async () => { + const rowErc1155WithNegativeValue = [ + "erc1155", + testData.addresses.dummyErc1155Address, + validReceiverAddress, + "-1", + "5", + ]; + + const rowErc1155WithMissingValue = [ + "erc1155", + testData.addresses.dummyErc1155Address, + validReceiverAddress, + "", + "5", + ]; + + const rowErc1155WithMissingId = ["erc1155", testData.addresses.dummyErc1155Address, validReceiverAddress, "5", ""]; + + const rowErc1155WithInvalidTokenAddress = ["erc1155", "0xwhoopsie", validReceiverAddress, "5", "5"]; + + const rowErc1155WithInvalidReceiverAddress = [ + "erc1155", + testData.addresses.dummyErc1155Address, + "0xwhoopsie", + "5", + "5", + ]; + + const rowErc721WithNegativeId = ["erc721", testData.addresses.dummyErc721Address, validReceiverAddress, "", "-20"]; + + const rowErc721WithMissingId = ["erc721", testData.addresses.dummyErc721Address, validReceiverAddress, "", ""]; + + const rowErc721WithDecimalId = [ + "erc721", + testData.addresses.dummyErc721Address, + validReceiverAddress, + "", + "69.420", + ]; + + const rowErc721WithInvalidToken = ["erc721", "0xwhoopsie", validReceiverAddress, "", "69"]; + + const rowErc721WithInvalidReceiver = ["erc721", testData.addresses.dummyErc721Address, "0xwhoopsie", "", "69"]; + + const [payment, warnings] = await CSVParser.parseCSV( + csvStringFromRows( + rowErc1155WithNegativeValue, + rowErc1155WithMissingValue, + rowErc1155WithMissingId, + rowErc1155WithInvalidTokenAddress, + rowErc1155WithInvalidReceiverAddress, + rowErc721WithNegativeId, + rowErc721WithDecimalId, + rowErc721WithMissingId, + rowErc721WithInvalidToken, + rowErc721WithInvalidReceiver, + ), + mockTokenInfoProvider, + mockERC721InfoProvider, + mockEnsResolver, + ); + expect(warnings).to.have.lengthOf(13); + const [ + warningErc1155WithNegativeValue, + warningErc1155WithMissingValue, + warningErc1155WithMissingId, + warningErc1155WithMissingId2, + warningErc1155WithInvalidTokenAddress, + warningErc1155WithInvalidReceiverAddress, + warningErc721WithNegativeId, + warningErc721WithDecimalId, + warningErc721WithMissingId, + warningErc721WithMissingId2, + warningErc721WithInvalidToken, + warningErc721WithInvalidToken2, + warningErc721WithInvalidReceiver, + ] = warnings; + expect(payment).to.be.empty; + + expect(warningErc1155WithNegativeValue.lineNo).to.equal(1); + expect(warningErc1155WithNegativeValue.message).to.equal("ERC1155 Tokens need a defined value > 0: -1"); + + expect(warningErc1155WithMissingValue.lineNo).to.equal(2); + expect(warningErc1155WithMissingValue.message).to.equal("ERC1155 Tokens need a defined value > 0: NaN"); + + expect(warningErc1155WithMissingId.lineNo).to.equal(3); + expect(warningErc1155WithMissingId.message).to.equal("Only positive Token IDs possible: NaN"); + + expect(warningErc1155WithMissingId2.lineNo).to.equal(3); + expect(warningErc1155WithMissingId2.message).to.equal("Token IDs must be integer numbers: NaN"); + + expect(warningErc1155WithInvalidTokenAddress.lineNo).to.equal(4); + expect(warningErc1155WithInvalidTokenAddress.message).to.equal("Invalid Token Address: 0xwhoopsie"); + + expect(warningErc1155WithInvalidReceiverAddress.lineNo).to.equal(5); + expect(warningErc1155WithInvalidReceiverAddress.message).to.equal("Invalid Receiver Address: 0xwhoopsie"); + + expect(warningErc721WithNegativeId.lineNo).to.equal(6); + expect(warningErc721WithNegativeId.message).to.equal("Only positive Token IDs possible: -20"); + + expect(warningErc721WithDecimalId.lineNo).to.equal(7); + expect(warningErc721WithDecimalId.message).to.equal("Token IDs must be integer numbers: 69.42"); + + expect(warningErc721WithMissingId.lineNo).to.equal(8); + expect(warningErc721WithMissingId.message).to.equal("Only positive Token IDs possible: NaN"); + + expect(warningErc721WithMissingId2.lineNo).to.equal(8); + expect(warningErc721WithMissingId2.message).to.equal("Token IDs must be integer numbers: NaN"); + + expect(warningErc721WithInvalidToken.lineNo).to.equal(9); + expect(warningErc721WithInvalidToken.message).to.equal("Invalid Token Address: 0xwhoopsie"); + + expect(warningErc721WithInvalidToken2.lineNo).to.equal(9); + expect(warningErc721WithInvalidToken2.message).to.equal("No token contract was found at 0xwhoopsie"); + + expect(warningErc721WithInvalidReceiver.lineNo).to.equal(10); + expect(warningErc721WithInvalidReceiver.message).to.equal("Invalid Receiver Address: 0xwhoopsie"); + }); + + it("invalid or missing token types", async () => { + const rowWithInvalidTokenType = [ + "invalidTokenType", + testData.unlistedERC20Token.address, + validReceiverAddress, + "15", + ]; + + const missingTokenType = ["", testData.unlistedERC20Token.address, validReceiverAddress, "15"]; + + const [payment, warnings] = await CSVParser.parseCSV( + csvStringFromRows(rowWithInvalidTokenType, missingTokenType), + mockTokenInfoProvider, + mockERC721InfoProvider, + mockEnsResolver, + ); + expect(warnings).to.have.lengthOf(2); + const [warningWithInvalidTokenType, warningWithMissingTokenType] = warnings; + 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(warningWithMissingTokenType.lineNo).to.equal(2); + expect(warningWithMissingTokenType.message).to.equal( + "Unknown token_type: Must be one of erc20, native, erc721, erc1155", + ); + }); }); diff --git a/src/components/assets/CSVForm.tsx b/src/components/assets/CSVForm.tsx index 7aaf0be2..0e82e121 100644 --- a/src/components/assets/CSVForm.tsx +++ b/src/components/assets/CSVForm.tsx @@ -29,7 +29,7 @@ export interface CSVFormProps { setParsing: (parsing: boolean) => void; } -export const AssetCSVForm = (props: CSVFormProps): JSX.Element => { +export const CSVForm = (props: CSVFormProps): JSX.Element => { const { csvContent, updateCsvContent, updateTransferTable, setParsing } = props; const [csvText, setCsvText] = useState(csvContent); diff --git a/src/parser/transformation.ts b/src/parser/transformation.ts index bd3d6f5e..65b73129 100644 --- a/src/parser/transformation.ts +++ b/src/parser/transformation.ts @@ -1,5 +1,3 @@ -import { prependListener } from "process"; - import { RowTransformCallback } from "@fast-csv/parse"; import { BigNumber } from "bignumber.js"; import { utils } from "ethers"; @@ -14,14 +12,14 @@ interface PrePayment { receiver: string; amount: BigNumber; tokenAddress: string | null; - tokenType: string; + tokenType: "erc20" | "native"; } interface PreCollectibleTransfer { receiver: string; tokenId: BigNumber; tokenAddress: string; - tokenType: string; + tokenType: "erc721" | "erc1155"; value?: BigNumber; } @@ -34,22 +32,25 @@ export const transform = ( ): void => { switch (row.token_type.toLowerCase()) { case "erc20": + transformAsset({ ...row, token_type: "erc20" }, tokenInfoProvider, ensResolver, callback); + break; case "native": - transformAsset(row, tokenInfoProvider, ensResolver, callback); + transformAsset({ ...row, token_type: "native" }, tokenInfoProvider, ensResolver, callback); break; case "erc721": + transformCollectible({ ...row, token_type: "erc721" }, erc721InfoProvider, ensResolver, callback); + break; case "erc1155": - transformCollectible(row, erc721InfoProvider, ensResolver, callback); + transformCollectible({ ...row, token_type: "erc1155" }, erc721InfoProvider, ensResolver, callback); break; default: callback(null, { token_type: "unknown" }); - break; } }; export const transformAsset = ( - row: CSVRow, + row: Omit & { token_type: "erc20" | "native" }, tokenInfoProvider: TokenInfoProvider, ensResolver: EnsResolver, callback: RowTransformCallback, @@ -59,7 +60,7 @@ export const transformAsset = ( tokenAddress: transformERC20TokenAddress(row.token_address), amount: new BigNumber(row.value ?? ""), receiver: normalizeAddress(row.receiver), - tokenType: row.token_type.toLowerCase(), + tokenType: row.token_type, }; toPayment(prePayment, tokenInfoProvider, ensResolver) @@ -124,7 +125,7 @@ const toPayment = async ( * Transforms each row into a payment object. */ export const transformCollectible = ( - row: CSVRow, + row: Omit & { token_type: "erc721" | "erc1155" }, erc721InfoProvider: ERC721InfoProvider, ensResolver: EnsResolver, callback: RowTransformCallback, @@ -134,7 +135,7 @@ export const transformCollectible = ( tokenAddress: normalizeAddress(row.token_address), tokenId: new BigNumber(row.id ?? ""), receiver: normalizeAddress(row.receiver), - tokenType: row.token_type.toLowerCase(), + tokenType: row.token_type, value: new BigNumber(row.value ?? ""), }; @@ -157,7 +158,6 @@ const toCollectibleTransfer = async ( prePayment.receiver, ]; - console.log("TokenType:" + prePayment.tokenType); resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; if (prePayment.tokenType === "erc721") { const tokenInfo = @@ -183,7 +183,7 @@ const toCollectibleTransfer = async ( token_type: prePayment.tokenType, }; } - } else if (prePayment.tokenType === "erc1155") { + } else { return { from: fromAddress, receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, @@ -193,16 +193,6 @@ const toCollectibleTransfer = async ( value: prePayment.value, token_type: "erc1155", }; - } else { - return { - from: fromAddress, - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - tokenId: prePayment.tokenId, - tokenAddress: prePayment.tokenAddress, - receiverEnsName: "", - tokenName: "TOKEN_NOT_SUPPORTED_YET", - token_type: "erc1155", - }; } }; diff --git a/src/parser/validation.ts b/src/parser/validation.ts index 3ac3db0f..ed99aad8 100644 --- a/src/parser/validation.ts +++ b/src/parser/validation.ts @@ -32,6 +32,7 @@ export const validateCollectibleRow = (row: CollectibleTransfer, callback: RowVa ...isTokenIdPositive(row), ...isCollectibleTokenValid(row), ...isTokenValueValid(row), + ...isTokenIdInteger(row), ]; callback(null, warnings.length === 0, warnings.join(";")); }; @@ -39,10 +40,10 @@ export const validateCollectibleRow = (row: CollectibleTransfer, callback: RowVa const areAddressesValid = (row: Transfer): string[] => { const warnings: string[] = []; if (!(row.tokenAddress === null || utils.isAddress(row.tokenAddress))) { - warnings.push("Invalid Token Address: " + row.tokenAddress); + warnings.push(`Invalid Token Address: ${row.tokenAddress}`); } if (!utils.isAddress(row.receiver)) { - warnings.push("Invalid Receiver Address: " + row.receiver); + warnings.push(`Invalid Receiver Address: ${row.receiver}`); } return warnings; }; @@ -57,9 +58,12 @@ const isCollectibleTokenValid = (row: CollectibleTransfer): string[] => row.tokenName === "TOKEN_NOT_FOUND" ? [`No token contract was found at ${row.tokenAddress}`] : []; const isTokenIdPositive = (row: CollectibleTransfer): string[] => - row.tokenId.isGreaterThan(0) ? [] : ["Only positive tokenIds possible: " + row.tokenId.toFixed()]; + row.tokenId.isGreaterThan(0) ? [] : [`Only positive Token IDs possible: ${row.tokenId.toFixed()}`]; + +const isTokenIdInteger = (row: CollectibleTransfer): string[] => + row.tokenId.isInteger() ? [] : [`Token IDs must be integer numbers: ${row.tokenId.toFixed()}`]; const isTokenValueValid = (row: CollectibleTransfer): string[] => row.token_type === "erc721" || (typeof row.value !== "undefined" && row.value.isGreaterThan(0)) ? [] - : ["ERC1155 Tokens need a defined value > 0: " + row.value?.toFixed()]; + : [`ERC1155 Tokens need a defined value > 0: ${row.value?.toFixed()}`]; diff --git a/src/test/util.ts b/src/test/util.ts index d2f9bad9..3762e2de 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -28,6 +28,7 @@ const addresses = { receiver2: "0x2000000000000000000000000000000000000000", receiver3: "0x3000000000000000000000000000000000000000", dummyErc721Address: "0x5500000000000000000000000000000000000000", + dummyErc1155Address: "0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656", }; export const testData = { From 763df5c69e856baf7874df13d419afac259c72d8 Mon Sep 17 00:00:00 2001 From: schmanu Date: Tue, 30 Nov 2021 23:07:45 +0100 Subject: [PATCH 10/13] finishes up nft transfers * Updates help text * Validates, that the value is a integer for erc1155 transfers * unittests for the transfer of collectibles * unittest for decimal (invalid) erc1155 * fixes sample file --- public/sample.csv | 9 +++--- src/__tests__/parser.test.ts | 39 ++++++++++++++++--------- src/__tests__/transfers.test.ts | 51 +++++++++++++++++++++++++++++++-- src/components/FAQModal.tsx | 48 ++++++++++++++++++++++++++++--- src/parser/validation.ts | 6 ++++ test_data/rinkeby-mixed.csv | 9 +++--- 6 files changed, 134 insertions(+), 28 deletions(-) diff --git a/public/sample.csv b/public/sample.csv index 099a019e..317185d7 100644 --- a/public/sample.csv +++ b/public/sample.csv @@ -1,4 +1,5 @@ -token_address,receiver,amount -0x6810e776880c02933d47db1b9fc05908e5386b96,0x1000000000000000000000000000000000000000,0.0001 -0x6b175474e89094c44da98b954eedeac495271d0f,0x2000000000000000000000000000000000000000,0.0001 -,0x3000000000000000000000000000000000000000,0.0001 \ No newline at end of file +token_type,token_address,receiver,value,id +erc20,0x6810e776880c02933d47db1b9fc05908e5386b96,0x1000000000000000000000000000000000000000,0.0001 +erc20,0x6b175474e89094c44da98b954eedeac495271d0f,0x2000000000000000000000000000000000000000,0.0001 +native,,0x3000000000000000000000000000000000000000,0.0001 +erc721,0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85,0x4000000000000000000000000000000000000000,,42 diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index ddab17dc..ad289e15 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -301,6 +301,14 @@ describe("Parsing CSVs ", () => { "5", ]; + const rowErc1155WithDecimalValue = [ + "erc1155", + testData.addresses.dummyErc1155Address, + validReceiverAddress, + "1.5", + "5", + ]; + const rowErc1155WithMissingValue = [ "erc1155", testData.addresses.dummyErc1155Address, @@ -340,6 +348,7 @@ describe("Parsing CSVs ", () => { const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows( rowErc1155WithNegativeValue, + rowErc1155WithDecimalValue, rowErc1155WithMissingValue, rowErc1155WithMissingId, rowErc1155WithInvalidTokenAddress, @@ -354,9 +363,10 @@ describe("Parsing CSVs ", () => { mockERC721InfoProvider, mockEnsResolver, ); - expect(warnings).to.have.lengthOf(13); + expect(warnings).to.have.lengthOf(14); const [ warningErc1155WithNegativeValue, + warningErc1155WithDecimalValue, warningErc1155WithMissingValue, warningErc1155WithMissingId, warningErc1155WithMissingId2, @@ -375,40 +385,43 @@ describe("Parsing CSVs ", () => { expect(warningErc1155WithNegativeValue.lineNo).to.equal(1); expect(warningErc1155WithNegativeValue.message).to.equal("ERC1155 Tokens need a defined value > 0: -1"); - expect(warningErc1155WithMissingValue.lineNo).to.equal(2); + expect(warningErc1155WithDecimalValue.lineNo).to.equal(2); + expect(warningErc1155WithDecimalValue.message).to.equal("Value of ERC1155 must be an integer: 1.5"); + + expect(warningErc1155WithMissingValue.lineNo).to.equal(3); expect(warningErc1155WithMissingValue.message).to.equal("ERC1155 Tokens need a defined value > 0: NaN"); - expect(warningErc1155WithMissingId.lineNo).to.equal(3); + expect(warningErc1155WithMissingId.lineNo).to.equal(4); expect(warningErc1155WithMissingId.message).to.equal("Only positive Token IDs possible: NaN"); - expect(warningErc1155WithMissingId2.lineNo).to.equal(3); + expect(warningErc1155WithMissingId2.lineNo).to.equal(4); expect(warningErc1155WithMissingId2.message).to.equal("Token IDs must be integer numbers: NaN"); - expect(warningErc1155WithInvalidTokenAddress.lineNo).to.equal(4); + expect(warningErc1155WithInvalidTokenAddress.lineNo).to.equal(5); expect(warningErc1155WithInvalidTokenAddress.message).to.equal("Invalid Token Address: 0xwhoopsie"); - expect(warningErc1155WithInvalidReceiverAddress.lineNo).to.equal(5); + expect(warningErc1155WithInvalidReceiverAddress.lineNo).to.equal(6); expect(warningErc1155WithInvalidReceiverAddress.message).to.equal("Invalid Receiver Address: 0xwhoopsie"); - expect(warningErc721WithNegativeId.lineNo).to.equal(6); + expect(warningErc721WithNegativeId.lineNo).to.equal(7); expect(warningErc721WithNegativeId.message).to.equal("Only positive Token IDs possible: -20"); - expect(warningErc721WithDecimalId.lineNo).to.equal(7); + expect(warningErc721WithDecimalId.lineNo).to.equal(8); expect(warningErc721WithDecimalId.message).to.equal("Token IDs must be integer numbers: 69.42"); - expect(warningErc721WithMissingId.lineNo).to.equal(8); + expect(warningErc721WithMissingId.lineNo).to.equal(9); expect(warningErc721WithMissingId.message).to.equal("Only positive Token IDs possible: NaN"); - expect(warningErc721WithMissingId2.lineNo).to.equal(8); + expect(warningErc721WithMissingId2.lineNo).to.equal(9); expect(warningErc721WithMissingId2.message).to.equal("Token IDs must be integer numbers: NaN"); - expect(warningErc721WithInvalidToken.lineNo).to.equal(9); + expect(warningErc721WithInvalidToken.lineNo).to.equal(10); expect(warningErc721WithInvalidToken.message).to.equal("Invalid Token Address: 0xwhoopsie"); - expect(warningErc721WithInvalidToken2.lineNo).to.equal(9); + expect(warningErc721WithInvalidToken2.lineNo).to.equal(10); expect(warningErc721WithInvalidToken2.message).to.equal("No token contract was found at 0xwhoopsie"); - expect(warningErc721WithInvalidReceiver.lineNo).to.equal(10); + expect(warningErc721WithInvalidReceiver.lineNo).to.equal(11); expect(warningErc721WithInvalidReceiver.message).to.equal("Invalid Receiver Address: 0xwhoopsie"); }); diff --git a/src/__tests__/transfers.test.ts b/src/__tests__/transfers.test.ts index b1bbae48..852d8a86 100644 --- a/src/__tests__/transfers.test.ts +++ b/src/__tests__/transfers.test.ts @@ -1,11 +1,14 @@ import { BigNumber } from "bignumber.js"; import { expect } from "chai"; +import { ethers } from "ethers"; import { fetchTokenList, MinimalTokenInfo } from "../hooks/token"; -import { AssetTransfer } from "../parser/csvParser"; +import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser"; import { testData } from "../test/util"; +import { erc1155Interface } from "../transfers/erc1155"; import { erc20Interface } from "../transfers/erc20"; -import { buildAssetTransfers } from "../transfers/transfers"; +import { erc721Interface } from "../transfers/erc721"; +import { buildAssetTransfers, buildCollectibleTransfers } from "../transfers/transfers"; import { toWei, fromWei, MAX_U256, TokenInfo } from "../utils"; const dummySafeInfo = testData.dummySafeInfo; @@ -220,4 +223,48 @@ describe("Build Transfers:", () => { ); }); }); + + describe("Collectibles", () => { + const transfers: CollectibleTransfer[] = [ + { + token_type: "erc721", + receiver, + from: testData.dummySafeInfo.safeAddress, + receiverEnsName: null, + tokenAddress: testData.addresses.dummyErc721Address, + tokenName: "Test NFT", + tokenId: new BigNumber("69"), + }, + { + token_type: "erc1155", + receiver, + from: testData.dummySafeInfo.safeAddress, + receiverEnsName: null, + tokenAddress: testData.addresses.dummyErc1155Address, + tokenName: "Test MultiToken", + value: new BigNumber("69"), + tokenId: new BigNumber("420"), + }, + ]; + + const [firstTransfer, secondTransfer] = buildCollectibleTransfers(transfers); + + expect(firstTransfer.value).to.be.equal("0"); + expect(firstTransfer.to).to.be.equal(testData.addresses.dummyErc721Address); + expect(firstTransfer.data).to.be.equal( + erc721Interface.encodeFunctionData("safeTransferFrom", [testData.dummySafeInfo.safeAddress, receiver, 69]), + ); + + expect(secondTransfer.value).to.be.equal("0"); + expect(secondTransfer.to).to.be.equal(testData.addresses.dummyErc1155Address); + expect(secondTransfer.data).to.be.equal( + erc1155Interface.encodeFunctionData("safeTransferFrom", [ + testData.dummySafeInfo.safeAddress, + receiver, + 420, + 69, + ethers.utils.hexlify("0x00"), + ]), + ); + }); }); diff --git a/src/components/FAQModal.tsx b/src/components/FAQModal.tsx index ae3072b8..a852103f 100644 --- a/src/components/FAQModal.tsx +++ b/src/components/FAQModal.tsx @@ -17,10 +17,26 @@ export const FAQModal: () => JSX.Element = () => { {showHelp && ( setShowHelp(false)} - title={How to use the CSV Airdrop Gnosis App} + title={How to use the CSV Airdrop App} body={
+ + Overview + + +

+ This app can batch multiple transfers of ERC20, ERC721, ERC1155 and native tokens into a single + transaction. It's as simple as uploading / copy & pasting a single CSV transfer file and hitting the + submit button. +

+

+ {" "} + This safes gas ⛽ and a substantial amount of time ⌚ by requiring less signatures and transactions. +

+
+ Preparing a Transfer File @@ -28,13 +44,37 @@ export const FAQModal: () => JSX.Element = () => { Transfer files are expected to be in CSV format with the following required columns:
  • - receiver: Ethereum address of transfer receiver. + + token_type + + : The type of token that is being transferred. One of erc20,erc721,erc1155 or{" "} + native. +
  • +
  • + + token_address + + : Ethereum address of ERC20 token to be transferred. This has to be left blank for native (ETH) + transfers. +
  • +
  • + + receiver + + : Ethereum address of transfer receiver.
  • - token_address: Ethereum address of ERC20 token to be transferred. + + value + + : the amount of token to be transferred. This can be left blank for erc721 transfers.
  • - amount: the amount of token to be transferred. + + id + + : The id of the collectible token (erc721 or erc1155) to transfer. This can be left blank for native + and erc20 transfers.

diff --git a/src/parser/validation.ts b/src/parser/validation.ts index ed99aad8..27789253 100644 --- a/src/parser/validation.ts +++ b/src/parser/validation.ts @@ -32,6 +32,7 @@ export const validateCollectibleRow = (row: CollectibleTransfer, callback: RowVa ...isTokenIdPositive(row), ...isCollectibleTokenValid(row), ...isTokenValueValid(row), + ...isTokenValueInteger(row), ...isTokenIdInteger(row), ]; callback(null, warnings.length === 0, warnings.join(";")); @@ -63,6 +64,11 @@ const isTokenIdPositive = (row: CollectibleTransfer): string[] => const isTokenIdInteger = (row: CollectibleTransfer): string[] => row.tokenId.isInteger() ? [] : [`Token IDs must be integer numbers: ${row.tokenId.toFixed()}`]; +const isTokenValueInteger = (row: CollectibleTransfer): string[] => + !row.value || row.value.isNaN() || row.value.isInteger() + ? [] + : [`Value of ERC1155 must be an integer: ${row.value.toFixed()}`]; + const isTokenValueValid = (row: CollectibleTransfer): string[] => row.token_type === "erc721" || (typeof row.value !== "undefined" && row.value.isGreaterThan(0)) ? [] diff --git a/test_data/rinkeby-mixed.csv b/test_data/rinkeby-mixed.csv index 2af4ad4a..fe00d880 100644 --- a/test_data/rinkeby-mixed.csv +++ b/test_data/rinkeby-mixed.csv @@ -1,6 +1,5 @@ token_type,token_address,receiver,value,id -native,,schmanu.eth,2 -erc20,0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b,vitalik.eth,2 -erc1155,0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656,schmanu.eth,5,1 -erc1155,0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656,schmanu.eth,5,5 -erc20,0xd0dab4e640d95e9e8a47545598c33e31bdb53c7c,0x9ed3822542BA9D38a36767e6536769296c106C47,2 \ No newline at end of file +erc1155,0xa637223989799ea2f14c691b5db17dd00e4a4f3e,0x79BD0Bd219Dd0D50CEF72e1194Fc970FFEceB304,69,1 +erc20,0xd0dab4e640d95e9e8a47545598c33e31bdb53c7c,0x79BD0Bd219Dd0D50CEF72e1194Fc970FFEceB304,0.5 +erc20,0xd0dab4e640d95e9e8a47545598c33e31bdb53c7c,0x79BD0Bd219Dd0D50CEF72e1194Fc970FFEceB304,0.19 +native,,0x79BD0Bd219Dd0D50CEF72e1194Fc970FFEceB304,0.1 \ No newline at end of file From 7324e7857204ba4458b4878fa4f3ad6278dd1b0f Mon Sep 17 00:00:00 2001 From: schmanu Date: Tue, 30 Nov 2021 23:35:52 +0100 Subject: [PATCH 11/13] remove unused global styles --- src/GlobalStyle.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/GlobalStyle.ts b/src/GlobalStyle.ts index e0cddf0a..286ea78d 100644 --- a/src/GlobalStyle.ts +++ b/src/GlobalStyle.ts @@ -62,16 +62,6 @@ const GlobalStyle = createGlobalStyle` flex: 1; } - .navDot { - width: 24px; - height: 24px; - top: -8px; - } - - .navDot p { - color: white !important; - } - .tableContainer { display: flex; flex-direction: horizontal; From 2d9b915d25cab411f8082f7fc84956d6987206ef Mon Sep 17 00:00:00 2001 From: schmanu Date: Thu, 2 Dec 2021 20:19:30 +0100 Subject: [PATCH 12/13] simplifies token_types erc1155 and erc721 to nft * instead of providing erc1155/erc721 the token_type now is simply nft * fixes performance problem of editor. For no reason the csvContent was held by the App and passed down to the editor * tests for nft transfers * updated faq --- package.json | 2 +- src/App.tsx | 8 +- src/__tests__/parser.test.ts | 81 ++++----- src/__tests__/transfers.test.ts | 2 + src/components/FAQModal.tsx | 4 +- src/components/assets/AssetTransferTable.tsx | 2 +- src/components/assets/CSVForm.tsx | 13 +- .../assets/CollectiblesTransferTable.tsx | 20 ++- src/components/assets/ERC721Token.tsx | 101 ++++++++--- src/hooks/collectibleTokenInfoProvider.ts | 158 ++++++++++++++++++ src/hooks/erc721InfoProvider.ts | 58 ------- src/parser/csvParser.ts | 6 +- src/parser/transformation.ts | 95 +++++------ src/test/util.ts | 16 +- src/transfers/erc165.ts | 9 + 15 files changed, 375 insertions(+), 200 deletions(-) create mode 100644 src/hooks/collectibleTokenInfoProvider.ts delete mode 100644 src/hooks/erc721InfoProvider.ts create mode 100644 src/transfers/erc165.ts diff --git a/package.json b/package.json index ea78013e..deac8e4f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "fmt": "prettier --check '**/*.ts'", "fmt:write": "prettier --write '**/*.ts'", "prepare": "husky install", - "generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json' './customabis/ERC721.json' './node_modules/@openzeppelin/contracts/build/contracts/ERC1155.json'", + "generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json' './customabis/ERC721.json' './node_modules/@openzeppelin/contracts/build/contracts/ERC1155.json' './node_modules/@openzeppelin/contracts/build/contracts/ERC165.json'", "postinstall": "yarn generate-types" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index 13f5280f..7c230684 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,6 @@ setUseWhatChange(process.env.NODE_ENV === "development"); const App: React.FC = () => { const { isLoading } = useTokenList(); const { safe } = useSafeAppsSDK(); - const [csvText, setCsvText] = useState("token_type,token_address,receiver,value,id"); const [tokenTransfers, setTokenTransfers] = useState([]); const [submitting, setSubmitting] = useState(false); @@ -61,12 +60,7 @@ const App: React.FC = () => { ) : ( - +

{submitting ? ( diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index ad289e15..8d1bceaf 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -4,8 +4,8 @@ import * as chai from "chai"; import { expect } from "chai"; import chaiAsPromised from "chai-as-promised"; +import { CollectibleTokenInfoProvider } from "../hooks/collectibleTokenInfoProvider"; import { EnsResolver } from "../hooks/ens"; -import { ERC721InfoProvider } from "../hooks/erc721InfoProvider"; import { TokenMap, MinimalTokenInfo, fetchTokenList, TokenInfoProvider } from "../hooks/token"; import { AssetTransfer, CollectibleTransfer, CSVParser } from "../parser/csvParser"; import { testData } from "../test/util"; @@ -29,7 +29,7 @@ const csvStringFromRows = (...rows: string[][]): string => { describe("Parsing CSVs ", () => { let mockTokenInfoProvider: TokenInfoProvider; - let mockERC721InfoProvider: ERC721InfoProvider; + let mockCollectibleTokenInfoProvider: CollectibleTokenInfoProvider; let mockEnsResolver: EnsResolver; beforeAll(async () => { @@ -47,16 +47,19 @@ describe("Parsing CSVs ", () => { getNativeTokenSymbol: () => "ETH", }; - mockERC721InfoProvider = { + mockCollectibleTokenInfoProvider = { getFromAddress: () => testData.dummySafeInfo.safeAddress, getTokenInfo: async (tokenAddress) => { - switch (tokenAddress) { + switch (tokenAddress.toLowerCase()) { case testData.addresses.dummyErc721Address: return testData.dummyERC721Token; + case testData.addresses.dummyErc1155Address: + return testData.dummyERC1155Token; default: return undefined; } }, + fetchMetaInfo: jest.fn(), }; mockEnsResolver = { @@ -101,7 +104,7 @@ describe("Parsing CSVs ", () => { // this csv contains more values than headers in row1 const invalidCSV = "head1,header2\nvalue1,value2,value3"; expect( - CSVParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockERC721InfoProvider, mockEnsResolver), + CSVParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockCollectibleTokenInfoProvider, mockEnsResolver), ).to.be.rejectedWith("column header mismatch expected: 2 columns got: 3"); }); @@ -112,7 +115,7 @@ describe("Parsing CSVs ", () => { CSVParser.parseCSV( csvStringFromRows(rowWithErrorReceiver), mockTokenInfoProvider, - mockERC721InfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ), ).to.be.rejectedWith("unexpected error!"); @@ -126,7 +129,7 @@ describe("Parsing CSVs ", () => { const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(rowWithoutDecimal, rowWithDecimalAmount, rowWithoutTokenAddress), mockTokenInfoProvider, - mockERC721InfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ); expect(warnings).to.be.empty; @@ -171,7 +174,7 @@ describe("Parsing CSVs ", () => { rowWithInvalidReceiverAddress, ), mockTokenInfoProvider, - mockERC721InfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ); expect(warnings).to.have.lengthOf(5); @@ -210,7 +213,7 @@ describe("Parsing CSVs ", () => { const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(receiverEnsName, tokenEnsName, unknownReceiverEnsName, unknownTokenEnsName), mockTokenInfoProvider, - mockERC721InfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ); expect(warnings).to.have.lengthOf(3); @@ -240,21 +243,15 @@ describe("Parsing CSVs ", () => { }); it("parses valid collectible transfers", async () => { - const rowWithErc721AndAddress = ["erc721", testData.addresses.dummyErc721Address, validReceiverAddress, "", "1"]; - const rowWithErc721AndENS = ["erc721", testData.addresses.dummyErc721Address, "receiver2.eth", "", "69"]; - const rowWithErc1155AndAddress = [ - "erc1155", - testData.addresses.dummyErc1155Address, - validReceiverAddress, - "69", - "420", - ]; - const rowWithErc1155AndENS = ["erc1155", testData.addresses.dummyErc1155Address, "receiver3.eth", "9", "99"]; + const rowWithErc721AndAddress = ["nft", testData.addresses.dummyErc721Address, validReceiverAddress, "", "1"]; + const rowWithErc721AndENS = ["nft", testData.addresses.dummyErc721Address, "receiver2.eth", "", "69"]; + const rowWithErc1155AndAddress = ["nft", testData.addresses.dummyErc1155Address, validReceiverAddress, "69", "420"]; + const rowWithErc1155AndENS = ["nft", testData.addresses.dummyErc1155Address, "receiver3.eth", "9", "99"]; const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(rowWithErc721AndAddress, rowWithErc721AndENS, rowWithErc1155AndAddress, rowWithErc1155AndENS), mockTokenInfoProvider, - mockERC721InfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ); expect(warnings).to.be.empty; @@ -294,7 +291,7 @@ describe("Parsing CSVs ", () => { it("should generate erc721/erc1155 validation warnings", async () => { const rowErc1155WithNegativeValue = [ - "erc1155", + "nft", testData.addresses.dummyErc1155Address, validReceiverAddress, "-1", @@ -302,48 +299,36 @@ describe("Parsing CSVs ", () => { ]; const rowErc1155WithDecimalValue = [ - "erc1155", + "nft", testData.addresses.dummyErc1155Address, validReceiverAddress, "1.5", "5", ]; - const rowErc1155WithMissingValue = [ - "erc1155", - testData.addresses.dummyErc1155Address, - validReceiverAddress, - "", - "5", - ]; + const rowErc1155WithMissingValue = ["nft", testData.addresses.dummyErc1155Address, validReceiverAddress, "", "5"]; - const rowErc1155WithMissingId = ["erc1155", testData.addresses.dummyErc1155Address, validReceiverAddress, "5", ""]; + const rowErc1155WithMissingId = ["nft", testData.addresses.dummyErc1155Address, validReceiverAddress, "5", ""]; - const rowErc1155WithInvalidTokenAddress = ["erc1155", "0xwhoopsie", validReceiverAddress, "5", "5"]; + const rowErc1155WithInvalidTokenAddress = ["nft", "0xwhoopsie", validReceiverAddress, "5", "5"]; const rowErc1155WithInvalidReceiverAddress = [ - "erc1155", + "nft", testData.addresses.dummyErc1155Address, "0xwhoopsie", "5", "5", ]; - const rowErc721WithNegativeId = ["erc721", testData.addresses.dummyErc721Address, validReceiverAddress, "", "-20"]; + const rowErc721WithNegativeId = ["nft", testData.addresses.dummyErc721Address, validReceiverAddress, "", "-20"]; - const rowErc721WithMissingId = ["erc721", testData.addresses.dummyErc721Address, validReceiverAddress, "", ""]; + const rowErc721WithMissingId = ["nft", testData.addresses.dummyErc721Address, validReceiverAddress, "", ""]; - const rowErc721WithDecimalId = [ - "erc721", - testData.addresses.dummyErc721Address, - validReceiverAddress, - "", - "69.420", - ]; + const rowErc721WithDecimalId = ["nft", testData.addresses.dummyErc721Address, validReceiverAddress, "", "69.420"]; - const rowErc721WithInvalidToken = ["erc721", "0xwhoopsie", validReceiverAddress, "", "69"]; + const rowErc721WithInvalidToken = ["nft", "0xwhoopsie", validReceiverAddress, "", "69"]; - const rowErc721WithInvalidReceiver = ["erc721", testData.addresses.dummyErc721Address, "0xwhoopsie", "", "69"]; + const rowErc721WithInvalidReceiver = ["nft", testData.addresses.dummyErc721Address, "0xwhoopsie", "", "69"]; const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows( @@ -360,10 +345,10 @@ describe("Parsing CSVs ", () => { rowErc721WithInvalidReceiver, ), mockTokenInfoProvider, - mockERC721InfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ); - expect(warnings).to.have.lengthOf(14); + expect(warnings).to.have.lengthOf(15); const [ warningErc1155WithNegativeValue, warningErc1155WithDecimalValue, @@ -371,6 +356,7 @@ describe("Parsing CSVs ", () => { warningErc1155WithMissingId, warningErc1155WithMissingId2, warningErc1155WithInvalidTokenAddress, + warningErc1155WithInvalidTokenAddress2, warningErc1155WithInvalidReceiverAddress, warningErc721WithNegativeId, warningErc721WithDecimalId, @@ -400,6 +386,9 @@ describe("Parsing CSVs ", () => { expect(warningErc1155WithInvalidTokenAddress.lineNo).to.equal(5); expect(warningErc1155WithInvalidTokenAddress.message).to.equal("Invalid Token Address: 0xwhoopsie"); + expect(warningErc1155WithInvalidTokenAddress2.lineNo).to.equal(5); + expect(warningErc1155WithInvalidTokenAddress2.message).to.equal("No token contract was found at 0xwhoopsie"); + expect(warningErc1155WithInvalidReceiverAddress.lineNo).to.equal(6); expect(warningErc1155WithInvalidReceiverAddress.message).to.equal("Invalid Receiver Address: 0xwhoopsie"); @@ -438,7 +427,7 @@ describe("Parsing CSVs ", () => { const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(rowWithInvalidTokenType, missingTokenType), mockTokenInfoProvider, - mockERC721InfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ); expect(warnings).to.have.lengthOf(2); diff --git a/src/__tests__/transfers.test.ts b/src/__tests__/transfers.test.ts index 852d8a86..ac58b2c3 100644 --- a/src/__tests__/transfers.test.ts +++ b/src/__tests__/transfers.test.ts @@ -234,6 +234,7 @@ describe("Build Transfers:", () => { tokenAddress: testData.addresses.dummyErc721Address, tokenName: "Test NFT", tokenId: new BigNumber("69"), + hasMetaData: false, }, { token_type: "erc1155", @@ -244,6 +245,7 @@ describe("Build Transfers:", () => { tokenName: "Test MultiToken", value: new BigNumber("69"), tokenId: new BigNumber("420"), + hasMetaData: false, }, ]; diff --git a/src/components/FAQModal.tsx b/src/components/FAQModal.tsx index a852103f..87a374b8 100644 --- a/src/components/FAQModal.tsx +++ b/src/components/FAQModal.tsx @@ -47,8 +47,8 @@ export const FAQModal: () => JSX.Element = () => { token_type - : The type of token that is being transferred. One of erc20,erc721,erc1155 or{" "} - native. + : The type of token that is being transferred. One of erc20,nft or native. + NFT Tokens can be either ERC721 or ERC1155.
  • diff --git a/src/components/assets/AssetTransferTable.tsx b/src/components/assets/AssetTransferTable.tsx index 5b94c54f..ff6f4d19 100644 --- a/src/components/assets/AssetTransferTable.tsx +++ b/src/components/assets/AssetTransferTable.tsx @@ -16,7 +16,7 @@ export const AssetTransferTable = (props: TransferTableProps) => {
  • void; - csvContent: string; updateTransferTable: (transfers: Transfer[]) => void; setParsing: (parsing: boolean) => void; } export const CSVForm = (props: CSVFormProps): JSX.Element => { - const { csvContent, updateCsvContent, updateTransferTable, setParsing } = props; - const [csvText, setCsvText] = useState(csvContent); + const { updateTransferTable, setParsing } = props; + const [csvText, setCsvText] = useState("token_type,token_address,receiver,value,id"); const { setCodeWarnings, setMessages } = useContext(MessageContext); @@ -39,11 +37,10 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => { const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [safe, sdk]); const tokenInfoProvider = useTokenInfoProvider(); const ensResolver = useEnsResolver(); - const erc721TokenInfoProvider = useERC721InfoProvider(); + const erc721TokenInfoProvider = useCollectibleTokenInfoProvider(); const onChangeTextHandler = (csvText: string) => { setCsvText(csvText); - updateCsvContent(csvText); parseAndValidateCSV(csvText); }; @@ -93,7 +90,7 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => { setParsing(false); }) .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); - }, 1000), + }, 750), [ ensResolver, erc721TokenInfoProvider, diff --git a/src/components/assets/CollectiblesTransferTable.tsx b/src/components/assets/CollectiblesTransferTable.tsx index 802ddcd0..b99f74b1 100644 --- a/src/components/assets/CollectiblesTransferTable.tsx +++ b/src/components/assets/CollectiblesTransferTable.tsx @@ -4,7 +4,7 @@ import React from "react"; import { CollectibleTransfer } from "../../parser/csvParser"; import { Receiver } from "../Receiver"; -import { ERC20Token } from "./ERC20Token"; +import { ERC721Token } from "./ERC721Token"; type TransferTableProps = { transferContent: CollectibleTransfer[]; @@ -15,9 +15,11 @@ export const CollectiblesTransferTable = (props: TransferTableProps) => { return (
    { return { id: "" + index, cells: [ - { id: "position", content: row.position }, - { id: "token", content: }, + { + id: "token", + content: ( + + ), + }, + { id: "type", content: row.token_type.toUpperCase() }, { id: "receiver", content: , diff --git a/src/components/assets/ERC721Token.tsx b/src/components/assets/ERC721Token.tsx index 73ceadbe..198d54f4 100644 --- a/src/components/assets/ERC721Token.tsx +++ b/src/components/assets/ERC721Token.tsx @@ -1,12 +1,10 @@ -import { Text } from "@gnosis.pm/safe-react-components"; +import { Loader, Text } from "@gnosis.pm/safe-react-components"; +import { Popover } from "@material-ui/core"; +import { BigNumber } from "bignumber.js"; +import { useEffect, useState } from "react"; import styled from "styled-components"; -import { useTokenList } from "../../hooks/token"; - -type TokenProps = { - tokenAddress: string; - name: string; -}; +import { CollectibleTokenMetaInfo, useCollectibleTokenInfoProvider } from "../../hooks/collectibleTokenInfoProvider"; const Container = styled.div` flex: 1; @@ -17,21 +15,86 @@ const Container = styled.div` gap: 8px; `; +type TokenProps = { + tokenAddress: string; + id: BigNumber; + token_type: "erc721" | "erc1155"; + hasMetaData: boolean; +}; + export const ERC721Token = (props: TokenProps) => { - const { tokenAddress, name } = props; - const { tokenList } = useTokenList(); + const [anchorEl, setAnchorEl] = useState(null); + + const [isMetaDataLoading, setIsMetaDataLoading] = useState(false); + + const [tokenMetaData, setTokenMetaData] = useState(undefined); + + const collectibleTokenInfoProvider = useCollectibleTokenInfoProvider(); + + const { tokenAddress, id, token_type, hasMetaData } = props; + + const imageZoomedIn = Boolean(anchorEl); + useEffect(() => { + let isMounted = true; + if (hasMetaData) { + setIsMetaDataLoading(true); + collectibleTokenInfoProvider.fetchMetaInfo(tokenAddress, id, token_type).then((result) => { + if (isMounted) { + setTokenMetaData(result); + setIsMetaDataLoading(false); + } + }); + } + return function callback() { + isMounted = false; + }; + }, [hasMetaData, collectibleTokenInfoProvider, tokenAddress, id, token_type]); + return ( - {" "} - {name || tokenAddress} + {isMetaDataLoading ? ( + + ) : ( + <> + {""} { + setAnchorEl(event.currentTarget); + }} + style={{ + maxWidth: 20, + marginRight: 3, + verticalAlign: "middle", + }} + />{" "} + setAnchorEl(null)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "center", + }} + transformOrigin={{ + vertical: "top", + horizontal: "center", + }} + > + {" "} + + + )} + {tokenMetaData?.name || tokenAddress} ); }; diff --git a/src/hooks/collectibleTokenInfoProvider.ts b/src/hooks/collectibleTokenInfoProvider.ts new file mode 100644 index 00000000..dfb4e5db --- /dev/null +++ b/src/hooks/collectibleTokenInfoProvider.ts @@ -0,0 +1,158 @@ +import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider"; +import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; +import BigNumber from "bignumber.js"; +import { ethers } from "ethers"; +import { useCallback, useMemo } from "react"; + +import { erc1155Instance } from "../transfers/erc1155"; +import { erc165Instance } from "../transfers/erc165"; +import { erc721Instance } from "../transfers/erc721"; + +const ERC721_INTERFACE_ID = "0x80ac58cd"; +const ERC721_METADATA_INTERFACE_ID = "0x5b5e139f"; +const ERC1155_INTERFACE_ID = "0xd9b67a26"; +const ERC1155_METADATA_INTERFACE_ID = "0x0e89341c"; + +export type CollectibleTokenInfo = { + token_type: "erc721" | "erc1155"; + address: string; + hasMetaInfo: boolean; +}; + +export type CollectibleTokenMetaInfo = { + imageURI?: string; + name?: string; +}; + +export interface CollectibleTokenInfoProvider { + getTokenInfo: (tokenAddress: string, id: BigNumber) => Promise; + getFromAddress: () => string; + fetchMetaInfo: ( + tokenAddress: string, + id: BigNumber, + token_type: "erc1155" | "erc721", + ) => Promise; +} + +export const useCollectibleTokenInfoProvider: () => CollectibleTokenInfoProvider = () => { + const { safe, sdk } = useSafeAppsSDK(); + const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [sdk, safe]); + + const collectibleContractCache = useMemo(() => new Map(), []); + + const contractInterfaceCache = useMemo( + () => new Map(), + [], + ); + + const determineInterface: ( + tokenAddress: string, + ) => Promise<["erc721" | "erc721_Meta" | "erc1155" | "erc1155_Meta" | undefined]> = useCallback( + async (tokenAddress: string) => { + if (contractInterfaceCache.has(tokenAddress)) { + return contractInterfaceCache.get(tokenAddress) ?? [undefined]; + } + let determinedInterface: ["erc721" | "erc721_Meta" | "erc1155" | "erc1155_Meta" | undefined] = [undefined]; + const erc165Contract = erc165Instance(tokenAddress, web3Provider); + const isErc1155 = await erc165Contract.supportsInterface(ERC1155_INTERFACE_ID).catch(() => false); + if (isErc1155) { + determinedInterface = ["erc1155"]; + if (await erc165Contract.supportsInterface(ERC1155_METADATA_INTERFACE_ID).catch(() => false)) { + determinedInterface.push("erc1155_Meta"); + } + } else { + const isErc721 = await erc165Contract.supportsInterface(ERC721_INTERFACE_ID).catch(() => false); + if (isErc721) { + determinedInterface = ["erc721"]; + if (await erc165Contract.supportsInterface(ERC721_METADATA_INTERFACE_ID).catch(() => false)) { + determinedInterface.push("erc721_Meta"); + } + } + } + contractInterfaceCache.set(tokenAddress, determinedInterface); + return determinedInterface; + }, + [contractInterfaceCache, web3Provider], + ); + const getTokenInfo = useCallback( + async (tokenAddress: string, id: BigNumber) => { + let tokenId: string = "-1"; + if (!id.isNaN() && id.isInteger() && id.isPositive()) { + tokenId = id.toFixed(); + } + if (collectibleContractCache.has(toKey(tokenAddress, tokenId))) { + 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 = { + token_type: "erc721", + address: tokenAddress, + hasMetaInfo: tokenInterfaces.includes("erc721_Meta"), + }; + } else if (tokenInterfaces.includes("erc1155")) { + fetchedTokenInfo = { + token_type: "erc1155", + address: tokenAddress, + hasMetaInfo: tokenInterfaces.includes("erc1155_Meta"), + }; + } + collectibleContractCache.set(toKey(tokenAddress, tokenId), fetchedTokenInfo); + return fetchedTokenInfo; + }, + [collectibleContractCache, determineInterface], + ); + + const fetchMetaInfo: ( + tokenAddress: string, + id: BigNumber, + token_type: "erc1155" | "erc721", + ) => Promise = useCallback( + async (tokenAddress: string, id: BigNumber, token_type: "erc1155" | "erc721") => { + if (token_type === "erc721") { + const erc721Contract = erc721Instance(tokenAddress, web3Provider); + const metaInfo: CollectibleTokenMetaInfo = { + name: await erc721Contract.name().catch(() => undefined), + }; + const tokenURI = await erc721Contract.tokenURI(id.toFixed()).catch(() => undefined); + if (tokenURI) { + const metaDataJSON = await ethers.utils.fetchJson(tokenURI).catch(() => undefined); + metaInfo.imageURI = metaDataJSON?.image; + } + return metaInfo; + } else { + const erc1155Contract = erc1155Instance(tokenAddress, web3Provider); + const metaInfo: CollectibleTokenMetaInfo = {}; + const tokenURI = await erc1155Contract.uri(id.toFixed()).catch(() => undefined); + if (tokenURI) { + const metaDataJSON = await ethers.utils.fetchJson(tokenURI).catch(() => undefined); + metaInfo.imageURI = metaDataJSON?.image; + metaInfo.name = metaDataJSON?.name; + } + return metaInfo; + } + }, + [web3Provider], + ); + + const getFromAddress = useCallback(() => { + return safe.safeAddress; + }, [safe]); + + return useMemo( + () => ({ + getTokenInfo: (tokenAddress: string, id: BigNumber) => getTokenInfo(tokenAddress, id), + getFromAddress: () => getFromAddress(), + fetchMetaInfo: (tokenAddress: string, id: BigNumber, token_type: "erc1155" | "erc721") => + fetchMetaInfo(tokenAddress, id, token_type), + }), + [getTokenInfo, getFromAddress, fetchMetaInfo], + ); +}; + +/** + * Maps cannot hash custom objects. So we convert tokenaddress and id to a unique key. + */ +const toKey = (tokenAddr: string, id: string) => `addr: ${tokenAddr}, id: ${id}`; diff --git a/src/hooks/erc721InfoProvider.ts b/src/hooks/erc721InfoProvider.ts deleted file mode 100644 index fd8d77d1..00000000 --- a/src/hooks/erc721InfoProvider.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider"; -import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { ethers } from "ethers"; -import { useCallback, useMemo } from "react"; - -import { erc721Instance } from "../transfers/erc721"; - -export type ERC721TokenInfo = { - name: string; - symbol: string; -}; - -export interface ERC721InfoProvider { - getTokenInfo: (tokenAddress: string) => Promise; - getFromAddress: () => string; -} - -export const useERC721InfoProvider: () => ERC721InfoProvider = () => { - const { safe, sdk } = useSafeAppsSDK(); - const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [sdk, safe]); - - const erc721ContractCache = useMemo(() => new Map(), []); - - const getTokenInfo = useCallback( - async (tokenAddress: string) => { - if (erc721ContractCache.has(tokenAddress)) { - return erc721ContractCache.get(tokenAddress); - } - console.log("fetching erc721 token info"); - const erc721Contract = erc721Instance(tokenAddress, web3Provider); - const name = await erc721Contract.name().catch(() => undefined); - const symbol = await erc721Contract.symbol().catch(() => undefined); - - let fetchedTokenInfo: ERC721TokenInfo | undefined = undefined; - if (name && symbol) { - fetchedTokenInfo = { - name, - symbol, - }; - } - erc721ContractCache.set(tokenAddress, fetchedTokenInfo); - return fetchedTokenInfo; - }, - [erc721ContractCache, web3Provider], - ); - - const getFromAddress = useCallback(() => { - return safe.safeAddress; - }, [safe]); - - return useMemo( - () => ({ - getTokenInfo: (tokenAddress: string) => getTokenInfo(tokenAddress), - getFromAddress: () => getFromAddress(), - }), - [getTokenInfo, getFromAddress], - ); -}; diff --git a/src/parser/csvParser.ts b/src/parser/csvParser.ts index f7bb24e1..84bbe136 100644 --- a/src/parser/csvParser.ts +++ b/src/parser/csvParser.ts @@ -2,8 +2,8 @@ import { parseString, RowValidateCallback } from "@fast-csv/parse"; import { BigNumber } from "bignumber.js"; import { CodeWarning } from "../contexts/MessageContextProvider"; +import { CollectibleTokenInfoProvider } from "../hooks/collectibleTokenInfoProvider"; import { EnsResolver } from "../hooks/ens"; -import { ERC721InfoProvider } from "../hooks/erc721InfoProvider"; import { TokenInfoProvider } from "../hooks/token"; import { transform } from "./transformation"; @@ -38,7 +38,7 @@ export interface CollectibleTransfer { tokenId: BigNumber; value?: BigNumber; receiverEnsName: string | null; - position?: number; + hasMetaData: boolean; } export interface UnknownTransfer { @@ -71,7 +71,7 @@ export class CSVParser { public static parseCSV = ( csvText: string, tokenInfoProvider: TokenInfoProvider, - erc721TokenInfoProvider: ERC721InfoProvider, + erc721TokenInfoProvider: CollectibleTokenInfoProvider, ensResolver: EnsResolver, ): Promise<[Transfer[], CodeWarning[]]> => { return new Promise<[Transfer[], CodeWarning[]]>((resolve, reject) => { diff --git a/src/parser/transformation.ts b/src/parser/transformation.ts index 65b73129..aecb109a 100644 --- a/src/parser/transformation.ts +++ b/src/parser/transformation.ts @@ -2,8 +2,8 @@ import { RowTransformCallback } from "@fast-csv/parse"; import { BigNumber } from "bignumber.js"; import { utils } from "ethers"; +import { CollectibleTokenInfoProvider } from "../hooks/collectibleTokenInfoProvider"; import { EnsResolver } from "../hooks/ens"; -import { ERC721InfoProvider } from "../hooks/erc721InfoProvider"; import { TokenInfoProvider } from "../hooks/token"; import { AssetTransfer, CollectibleTransfer, CSVRow, Transfer, UnknownTransfer } from "./csvParser"; @@ -19,14 +19,14 @@ interface PreCollectibleTransfer { receiver: string; tokenId: BigNumber; tokenAddress: string; - tokenType: "erc721" | "erc1155"; + tokenType: "nft"; value?: BigNumber; } export const transform = ( row: CSVRow, tokenInfoProvider: TokenInfoProvider, - erc721InfoProvider: ERC721InfoProvider, + erc721InfoProvider: CollectibleTokenInfoProvider, ensResolver: EnsResolver, callback: RowTransformCallback, ): void => { @@ -37,11 +37,8 @@ export const transform = ( case "native": transformAsset({ ...row, token_type: "native" }, tokenInfoProvider, ensResolver, callback); break; - case "erc721": - transformCollectible({ ...row, token_type: "erc721" }, erc721InfoProvider, ensResolver, callback); - break; - case "erc1155": - transformCollectible({ ...row, token_type: "erc1155" }, erc721InfoProvider, ensResolver, callback); + case "nft": + transformCollectible({ ...row, token_type: "nft" }, erc721InfoProvider, ensResolver, callback); break; default: callback(null, { token_type: "unknown" }); @@ -125,8 +122,8 @@ const toPayment = async ( * Transforms each row into a payment object. */ export const transformCollectible = ( - row: Omit & { token_type: "erc721" | "erc1155" }, - erc721InfoProvider: ERC721InfoProvider, + row: Omit & { token_type: "nft" }, + erc721InfoProvider: CollectibleTokenInfoProvider, ensResolver: EnsResolver, callback: RowTransformCallback, ): void => { @@ -145,53 +142,57 @@ export const transformCollectible = ( }; const toCollectibleTransfer = async ( - prePayment: PreCollectibleTransfer, - erc721InfoProvider: ERC721InfoProvider, + preCollectible: PreCollectibleTransfer, + collectibleTokenInfoProvider: CollectibleTokenInfoProvider, ensResolver: EnsResolver, ): Promise => { - const fromAddress = erc721InfoProvider.getFromAddress(); + const fromAddress = collectibleTokenInfoProvider.getFromAddress(); - let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(prePayment.receiver) - ? [prePayment.receiver, null] + let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(preCollectible.receiver) + ? [preCollectible.receiver, null] : [ - (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(prePayment.receiver) : null, - prePayment.receiver, + (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(preCollectible.receiver) : null, + preCollectible.receiver, ]; + resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : preCollectible.receiver; - resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver; - if (prePayment.tokenType === "erc721") { - const tokenInfo = - prePayment.tokenAddress === null ? undefined : await erc721InfoProvider.getTokenInfo(prePayment.tokenAddress); - if (typeof tokenInfo !== "undefined") { - return { - from: fromAddress, - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - tokenId: prePayment.tokenId, - tokenAddress: prePayment.tokenAddress, - tokenName: tokenInfo.name, - receiverEnsName, - token_type: prePayment.tokenType, - }; - } else { - return { - from: fromAddress, - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - tokenId: prePayment.tokenId, - tokenAddress: prePayment.tokenAddress, - tokenName: "TOKEN_NOT_FOUND", - receiverEnsName, - token_type: prePayment.tokenType, - }; - } - } else { + const tokenInfo = await collectibleTokenInfoProvider.getTokenInfo( + preCollectible.tokenAddress, + preCollectible.tokenId, + ); + + if (tokenInfo?.token_type === "erc721") { + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : preCollectible.receiver, + tokenId: preCollectible.tokenId, + tokenAddress: preCollectible.tokenAddress, + receiverEnsName, + token_type: "erc721", + hasMetaData: tokenInfo.hasMetaInfo, + }; + } else if (tokenInfo?.token_type === "erc1155") { return { from: fromAddress, - receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : prePayment.receiver, - tokenId: prePayment.tokenId, - tokenAddress: prePayment.tokenAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : preCollectible.receiver, + tokenId: preCollectible.tokenId, + tokenAddress: preCollectible.tokenAddress, receiverEnsName, - value: prePayment.value, + value: preCollectible.value, token_type: "erc1155", + hasMetaData: tokenInfo.hasMetaInfo, + }; + } else { + // return a fake token which will fail validation. + return { + from: fromAddress, + receiver: resolvedReceiverAddress !== null ? resolvedReceiverAddress : preCollectible.receiver, + tokenId: preCollectible.tokenId, + tokenAddress: preCollectible.tokenAddress, + tokenName: "TOKEN_NOT_FOUND", + receiverEnsName, + token_type: "erc721", + hasMetaData: false, }; } }; diff --git a/src/test/util.ts b/src/test/util.ts index 3762e2de..e7b37c43 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -1,6 +1,6 @@ import { SafeInfo } from "@gnosis.pm/safe-apps-sdk"; -import { ERC721TokenInfo } from "../hooks/erc721InfoProvider"; +import { CollectibleTokenInfo } from "../hooks/collectibleTokenInfoProvider"; import { TokenInfo } from "../utils"; const dummySafeInfo: SafeInfo = { @@ -18,9 +18,16 @@ const unlistedERC20Token: TokenInfo = { chainId: -1, }; -const dummyERC721Token: ERC721TokenInfo = { - name: "Test NFT", - symbol: "Test", +const dummyERC721Token: CollectibleTokenInfo = { + token_type: "erc721", + address: "0x5500000000000000000000000000000000000000", + hasMetaInfo: false, +}; + +const dummyERC1155Token: CollectibleTokenInfo = { + token_type: "erc1155", + address: "0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656", + hasMetaInfo: false, }; const addresses = { @@ -36,4 +43,5 @@ export const testData = { unlistedERC20Token, addresses, dummyERC721Token, + dummyERC1155Token, }; diff --git a/src/transfers/erc165.ts b/src/transfers/erc165.ts new file mode 100644 index 00000000..61c3b9af --- /dev/null +++ b/src/transfers/erc165.ts @@ -0,0 +1,9 @@ +import { ethers } from "ethers"; + +import { ERC165, ERC165__factory } from "../contracts"; + +export const erc165Interface = ERC165__factory.createInterface(); + +export function erc165Instance(address: string, provider: ethers.providers.Provider): ERC165 { + return ERC165__factory.connect(address, provider); +} From 1a7373b36b1c15831ed1ccf24f71f9762eb338ee Mon Sep 17 00:00:00 2001 From: Benjamin Smith Date: Fri, 3 Dec 2021 07:23:06 +0100 Subject: [PATCH 13/13] Update src/components/FAQModal.tsx --- src/components/FAQModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/FAQModal.tsx b/src/components/FAQModal.tsx index 87a374b8..9aeca4bc 100644 --- a/src/components/FAQModal.tsx +++ b/src/components/FAQModal.tsx @@ -33,7 +33,7 @@ export const FAQModal: () => JSX.Element = () => {

    {" "} - This safes gas ⛽ and a substantial amount of time ⌚ by requiring less signatures and transactions. + This saves gas ⛽ and a substantial amount of time ⌚ by requiring less signatures and transactions.