From 4aaf65fe7c5f27573510a53bb6a3381cfa6dac75 Mon Sep 17 00:00:00 2001 From: Benjamin Smith Date: Fri, 21 May 2021 12:16:32 +0200 Subject: [PATCH] Fetch Token Data from EVM (#62) * add payments.ts in hope to use as converter including token decimals * fetch token data from contract at token address * New TokenInfoProvider hook which offers a method to fetch token info. Either from Tokenlist if it exists or from the contract via web3provider. * Fetched token infos will be added to the token map * introduces strictNullChecks to have a more robust code * fix eslint config to work with prettier * fix file upload bug The fileupload button itself is not an file-input anymore, because the Dropzone wrapped around everything makes the whole area clickable to upload a file anyway. This somehow caused chaos on chrome with multiple file upload windows appearing. closes #63 * allow tokens without symbol * allow tokens without symbol but with decimals in their contract Co-authored-by: schmanu --- .eslintrc.js | 10 +++- package.json | 2 +- src/App.tsx | 14 ++--- src/__tests__/parser.test.ts | 72 +++++++++++++---------- src/__tests__/tokenList.test.ts | 2 +- src/__tests__/transfers.test.ts | 42 +++++++------ src/__tests__/utils.test.ts | 46 +++++++++++++-- src/components/CSVForm.tsx | 4 +- src/components/CSVUpload.tsx | 40 +++++-------- src/erc20.ts | 8 +-- src/hooks/token.ts | 101 ++++++++++++++++++++++++++++++++ src/hooks/tokenList.ts | 57 ------------------ src/parser.ts | 99 ++++++++++++++++++++++--------- src/transfers.ts | 5 +- src/utils.ts | 19 +++--- test_data/rinkeby-example.csv | 3 +- tsconfig.json | 2 +- 17 files changed, 331 insertions(+), 195 deletions(-) create mode 100644 src/hooks/token.ts delete mode 100644 src/hooks/tokenList.ts diff --git a/.eslintrc.js b/.eslintrc.js index 8a05585..208ba3d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,12 @@ module.exports = { - plugins: ["import", "prettier"], - extends: ["plugin:import/recommended", "plugin:import/typescript", "plugin:prettier/recommended", "react-app"], + plugins: ["import"], + extends: [ + "plugin:import/recommended", + "plugin:import/typescript", + "plugin:prettier/recommended", + "react-app", + "prettier", + ], ignorePatterns: ["build/", "node_modules/", "!.prettierrc.js", "lib/"], rules: { "import/order": [ diff --git a/package.json b/package.json index 6e5a068..c36d1ed 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/IERC20.json'", + "generate-types": "typechain --target=ethers-v5 --out-dir src/contracts './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json'", "postinstall": "yarn generate-types" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index 5c741d7..3a6a7cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ import styled from "styled-components"; import { CSVForm } from "./components/CSVForm"; import { Header } from "./components/Header"; import { MessageContext } from "./contexts/MessageContextProvider"; -import { useTokenList } from "./hooks/tokenList"; +import { useTokenInfoProvider, useTokenList } from "./hooks/token"; import { parseCSV, Payment } from "./parser"; import { buildTransfers } from "./transfers"; import { checkAllBalances, transfersToSummary } from "./utils"; @@ -17,6 +17,7 @@ const App: React.FC = () => { const { safe, sdk } = useSafeAppsSDK(); const { tokenList, isLoading } = useTokenList(); + const tokenInfoProvider = useTokenInfoProvider(); const [submitting, setSubmitting] = useState(false); const [transferContent, setTransferContent] = useState([]); const [csvText, setCsvText] = useState("token_address,receiver,amount,decimals"); @@ -28,12 +29,12 @@ const App: React.FC = () => { (csvText: string) => { setCsvText(csvText); // Parse CSV - const parsePromise = parseCSV(csvText, tokenList); + const parsePromise = parseCSV(csvText, tokenInfoProvider); parsePromise .then(([transfers, warnings]) => { console.log("CSV parsed!"); const summary = transfersToSummary(transfers); - checkAllBalances(summary, web3Provider, safe, tokenList).then((insufficientBalances) => + checkAllBalances(summary, web3Provider, safe).then((insufficientBalances) => setMessages( insufficientBalances.map((insufficientBalanceInfo) => ({ message: `Insufficient Balance: ${insufficientBalanceInfo.transferAmount} of ${insufficientBalanceInfo.token}`, @@ -46,14 +47,13 @@ const App: React.FC = () => { }) .catch((reason: any) => addMessage({ severity: "error", message: reason.message })); }, - [addMessage, safe, setCodeWarnings, setMessages, tokenList, web3Provider], + [addMessage, safe, setCodeWarnings, setMessages, tokenInfoProvider, web3Provider], ); const submitTx = useCallback(async () => { setSubmitting(true); try { - // TODO - will need to pass web3Provider in here eventually - const txs = buildTransfers(transferContent, tokenList); + const txs = buildTransfers(transferContent); console.log(`Encoded ${txs.length} ERC20 transfers.`); const sendTxResponse = await sdk.txs.send({ txs }); const safeTx = await sdk.txs.getBySafeTxHash(sendTxResponse.safeTxHash); @@ -62,7 +62,7 @@ const App: React.FC = () => { console.error(e); } setSubmitting(false); - }, [transferContent, tokenList, sdk.txs]); + }, [transferContent, sdk.txs]); return (
diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 1d87578..853f724 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -1,20 +1,17 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import { TokenInfo } from "@uniswap/token-lists"; import { BigNumber } from "bignumber.js"; import * as chai from "chai"; import { expect } from "chai"; import chaiAsPromised from "chai-as-promised"; -import { TokenMap, fetchTokenList } from "../hooks/tokenList"; +import { TokenMap, MinimalTokenInfo, fetchTokenList, TokenInfoProvider } from "../hooks/token"; import { parseCSV } from "../parser"; import { testData } from "../test/util"; let tokenList: TokenMap; -let listedTokens: string[]; -let listedToken: TokenInfo; +let listedToken: MinimalTokenInfo; const validReceiverAddress = testData.addresses.receiver1; -const unlistedTokenAddress = testData.unlistedToken.address; // this lets us handle expectations on Promises. chai.use(chaiAsPromised); @@ -24,46 +21,60 @@ chai.use(chaiAsPromised); * @param rows array of row-arrays */ const csvStringFromRows = (...rows: string[][]): string => { - const headerRow = "token_address,receiver,amount,decimals"; + const headerRow = "token_address,receiver,amount"; return [headerRow, ...rows.map((row) => row.join(","))].join("\n"); }; describe("Parsing CSVs ", () => { + let mockTokenInfoProvider: TokenInfoProvider; + beforeAll(async () => { tokenList = await fetchTokenList(testData.dummySafeInfo.network); - listedTokens = Array.from(tokenList.keys()); - listedToken = tokenList.get(listedTokens[0]); + const fetchTokenFromList = async (tokenAddress: string) => { + return tokenList.get(tokenAddress); + }; + mockTokenInfoProvider = { + getTokenInfo: fetchTokenFromList, + }; + + let listedTokens = Array.from(tokenList.keys()); + const firstTokenInfo = tokenList.get(listedTokens[0]); + if (typeof firstTokenInfo !== "undefined") { + listedToken = firstTokenInfo; + } }); it("should throw errors for invalid CSVs", async () => { // thins csv contains more values than headers in row1 const invalidCSV = "head1,header2\nvalue1,value2,value3"; - expect(parseCSV(invalidCSV, tokenList)).to.be.rejectedWith("column header mismatch expected: 2 columns got: 3"); + expect(parseCSV(invalidCSV, mockTokenInfoProvider)).to.be.rejectedWith( + "column header mismatch expected: 2 columns got: 3", + ); }); it("should transform simple, valid CSVs correctly", async () => { const rowWithoutDecimal = [listedToken.address, validReceiverAddress, "1"]; - const rowWithDecimal = [unlistedTokenAddress, validReceiverAddress, "69.420", "18"]; + const rowWithDecimalAmount = [listedToken.address, validReceiverAddress, "69.420"]; const rowWithoutTokenAddress = ["", validReceiverAddress, "1"]; const [payment, warnings] = await parseCSV( - csvStringFromRows(rowWithoutDecimal, rowWithDecimal, rowWithoutTokenAddress), - tokenList, + csvStringFromRows(rowWithoutDecimal, rowWithDecimalAmount, rowWithoutTokenAddress), + mockTokenInfoProvider, ); expect(warnings).to.be.empty; expect(payment).to.have.lengthOf(3); const [paymentWithoutDecimal, paymentWithDecimal, paymentWithoutTokenAddress] = payment; - expect(paymentWithoutDecimal.decimals).to.be.undefined; + expect(paymentWithoutDecimal.decimals).to.be.equal(18); expect(paymentWithoutDecimal.receiver).to.equal(validReceiverAddress); expect(paymentWithoutDecimal.tokenAddress).to.equal(listedToken.address); expect(paymentWithoutDecimal.amount.isEqualTo(new BigNumber(1))).to.be.true; expect(paymentWithDecimal.receiver).to.equal(validReceiverAddress); - expect(paymentWithDecimal.tokenAddress.toLowerCase()).to.equal(unlistedTokenAddress.toLowerCase()); + expect(paymentWithDecimal.tokenAddress?.toLowerCase()).to.equal(listedToken.address.toLowerCase()); expect(paymentWithDecimal.decimals).to.equal(18); expect(paymentWithDecimal.amount.isEqualTo(new BigNumber(69.42))).to.be.true; - expect(paymentWithoutTokenAddress.decimals).to.be.undefined; + expect(paymentWithoutTokenAddress.decimals).to.be.equal(18); expect(paymentWithoutTokenAddress.receiver).to.equal(validReceiverAddress); expect(paymentWithoutTokenAddress.tokenAddress).to.equal(null); expect(paymentWithoutTokenAddress.amount.isEqualTo(new BigNumber(1))).to.be.true; @@ -71,43 +82,44 @@ describe("Parsing CSVs ", () => { it("should generate validation warnings", async () => { const rowWithNegativeAmount = [listedToken.address, validReceiverAddress, "-1"]; - const rowWithInvalidDecimal = [unlistedTokenAddress, validReceiverAddress, "1", "-2"]; - const unlistedTokenWithoutDecimal = [unlistedTokenAddress, validReceiverAddress, "1"]; - const rowWithInvalidTokenAddress = ["0x420", validReceiverAddress, "1", "18"]; - const rowWithInvalidReceiverAddress = [unlistedTokenAddress, "0x420", "1", "18"]; + + const unlistedTokenWithoutDecimalInContract = [testData.unlistedToken.address, validReceiverAddress, "1"]; + const rowWithInvalidTokenAddress = ["0x420", validReceiverAddress, "1"]; + const rowWithInvalidReceiverAddress = [listedToken.address, "0x420", "1"]; const [payment, warnings] = await parseCSV( csvStringFromRows( rowWithNegativeAmount, - rowWithInvalidDecimal, - unlistedTokenWithoutDecimal, + unlistedTokenWithoutDecimalInContract, rowWithInvalidTokenAddress, rowWithInvalidReceiverAddress, ), - tokenList, + mockTokenInfoProvider, ); expect(warnings).to.have.lengthOf(5); const [ warningNegativeAmount, - warningNegativeDecimals, - warningUndefinedDecimals, + warningTokenNotFound, warningInvalidTokenAddress, + warningInvalidTokenAddressForInvalidAddress, warningInvalidReceiverAddress, ] = warnings; expect(payment).to.be.empty; expect(warningNegativeAmount.message).to.equal("Only positive amounts possible: -1"); expect(warningNegativeAmount.lineNo).to.equal(1); - expect(warningNegativeDecimals.message).to.equal("Invalid decimals: -2"); - expect(warningNegativeDecimals.lineNo).to.equal(2); - expect(warningUndefinedDecimals.message).to.equal("Invalid decimals: undefined"); - expect(warningUndefinedDecimals.lineNo).to.equal(3); + expect(warningTokenNotFound.message.toLowerCase()).to.equal( + `no token contract was found at ${testData.unlistedToken.address.toLowerCase()}`, + ); + expect(warningTokenNotFound.lineNo).to.equal(2); expect(warningInvalidTokenAddress.message).to.equal("Invalid Token Address: 0x420"); - expect(warningInvalidTokenAddress.lineNo).to.equal(4); + expect(warningInvalidTokenAddress.lineNo).to.equal(3); + expect(warningInvalidTokenAddressForInvalidAddress.message).to.equal(`No token contract was found at 0x420`); + expect(warningInvalidTokenAddressForInvalidAddress.lineNo).to.equal(3); expect(warningInvalidReceiverAddress.message).to.equal("Invalid Receiver Address: 0x420"); - expect(warningInvalidReceiverAddress.lineNo).to.equal(5); + expect(warningInvalidReceiverAddress.lineNo).to.equal(4); }); }); diff --git a/src/__tests__/tokenList.test.ts b/src/__tests__/tokenList.test.ts index f2f3204..8926fc7 100644 --- a/src/__tests__/tokenList.test.ts +++ b/src/__tests__/tokenList.test.ts @@ -1,6 +1,6 @@ import { expect } from "chai"; -import { fetchTokenList } from "../hooks/tokenList"; +import { fetchTokenList } from "../hooks/token"; const configuredNetworks = ["MAINNET", "RINKEBY", "XDAI"]; diff --git a/src/__tests__/transfers.test.ts b/src/__tests__/transfers.test.ts index d6adfec..e2e0593 100644 --- a/src/__tests__/transfers.test.ts +++ b/src/__tests__/transfers.test.ts @@ -1,28 +1,26 @@ -import { assert } from "console"; - import { TokenInfo } from "@uniswap/token-lists"; import { BigNumber } from "bignumber.js"; import { expect } from "chai"; import { erc20Interface } from "../erc20"; -import { fetchTokenList, TokenMap } from "../hooks/tokenList"; +import { fetchTokenList, MinimalTokenInfo } from "../hooks/token"; import { Payment } from "../parser"; import { testData } from "../test/util"; import { buildTransfers } from "../transfers"; import { toWei, fromWei, MAX_U256 } from "../utils"; const dummySafeInfo = testData.dummySafeInfo; -let tokenList: TokenMap; -let listedTokens: string[]; -let listedToken: TokenInfo; +let listedToken: MinimalTokenInfo; const receiver = testData.addresses.receiver1; describe("Build Transfers:", () => { beforeAll(async () => { - tokenList = await fetchTokenList(dummySafeInfo.network); - listedTokens = Array.from(tokenList.keys()); - listedToken = tokenList.get(listedTokens[0]); - assert(tokenList.get(testData.unlistedToken.address) === undefined); + const tokenList = await fetchTokenList(dummySafeInfo.network); + let listedTokens = Array.from(tokenList.keys()); + const tokenInfo = tokenList.get(listedTokens[0]); + if (typeof tokenInfo !== "undefined") { + listedToken = tokenInfo; + } }); describe("Integers", () => { @@ -34,6 +32,7 @@ describe("Build Transfers:", () => { amount: fromWei(MAX_U256, listedToken.decimals), tokenAddress: listedToken.address, decimals: listedToken.decimals, + symbol: "LIT", }, // Unlisted ERC20 { @@ -41,17 +40,19 @@ describe("Build Transfers:", () => { amount: fromWei(MAX_U256, testData.unlistedToken.decimals), tokenAddress: testData.unlistedToken.address, decimals: testData.unlistedToken.decimals, + symbol: "ULT", }, // Native Asset { receiver, amount: fromWei(MAX_U256, 18), tokenAddress: null, - decimals: null, + decimals: 18, + symbol: "ETH", }, ]; - const [listedTransfer, unlistedTransfer, nativeTransfer] = buildTransfers(largePayments, tokenList); + const [listedTransfer, unlistedTransfer, nativeTransfer] = buildTransfers(largePayments); expect(listedTransfer.value).to.be.equal("0"); expect(listedTransfer.to).to.be.equal(listedToken.address); expect(listedTransfer.data).to.be.equal( @@ -80,6 +81,7 @@ describe("Build Transfers:", () => { amount: tinyAmount, tokenAddress: listedToken.address, decimals: listedToken.decimals, + symbol: "LIT", }, // Unlisted ERC20 { @@ -87,17 +89,19 @@ describe("Build Transfers:", () => { amount: tinyAmount, tokenAddress: testData.unlistedToken.address, decimals: testData.unlistedToken.decimals, + symbol: "ULT", }, // Native Asset { receiver, amount: tinyAmount, tokenAddress: null, - decimals: null, + decimals: 18, + symbol: "ETH", }, ]; - const [listed, unlisted, native] = buildTransfers(smallPayments, tokenList); + const [listed, unlisted, native] = buildTransfers(smallPayments); expect(listed.value).to.be.equal("0"); expect(listed.to).to.be.equal(listedToken.address); expect(listed.data).to.be.equal( @@ -129,6 +133,7 @@ describe("Build Transfers:", () => { amount: mixedAmount, tokenAddress: listedToken.address, decimals: listedToken.decimals, + symbol: "LIT", }, // Unlisted ERC20 { @@ -136,17 +141,19 @@ describe("Build Transfers:", () => { amount: mixedAmount, tokenAddress: testData.unlistedToken.address, decimals: testData.unlistedToken.decimals, + symbol: "ULT", }, // Native Asset { receiver, amount: mixedAmount, tokenAddress: null, - decimals: null, + decimals: 18, + symbol: "ETH", }, ]; - const [listed, unlisted, native] = buildTransfers(mixedPayments, tokenList); + const [listed, unlisted, native] = buildTransfers(mixedPayments); expect(listed.value).to.be.equal("0"); expect(listed.to).to.be.equal(listedToken.address); expect(listed.data).to.be.equal( @@ -184,8 +191,9 @@ describe("Build Transfers:", () => { amount: amount, tokenAddress: crappyToken.address, decimals: crappyToken.decimals, + symbol: "BTC", }; - const [transfer] = buildTransfers([payment], tokenList); + const [transfer] = buildTransfers([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/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index b15cc97..a3816ae 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -48,20 +48,26 @@ describe("transferToSummary()", () => { tokenAddress: null, amount: new BigNumber(1), receiver: testData.addresses.receiver1, + decimals: 18, + symbol: "ETH", }, { tokenAddress: null, amount: new BigNumber(2), receiver: testData.addresses.receiver2, + decimals: 18, + symbol: "ETH", }, { tokenAddress: null, amount: new BigNumber(3), receiver: testData.addresses.receiver3, + decimals: 18, + symbol: "ETH", }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(null).amount.toFixed()).to.equal("6"); + expect(summary.get(null)?.amount.toFixed()).to.equal("6"); }); it("works for decimals in native currency", () => { @@ -70,20 +76,26 @@ describe("transferToSummary()", () => { tokenAddress: null, amount: new BigNumber(0.1), receiver: testData.addresses.receiver1, + decimals: 18, + symbol: "ETH", }, { tokenAddress: null, amount: new BigNumber(0.01), receiver: testData.addresses.receiver2, + decimals: 18, + symbol: "ETH", }, { tokenAddress: null, amount: new BigNumber(0.001), receiver: testData.addresses.receiver3, + decimals: 18, + symbol: "ETH", }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(null).amount.toFixed()).to.equal("0.111"); + expect(summary.get(null)?.amount.toFixed()).to.equal("0.111"); }); it("works for decimals in erc20", () => { @@ -92,20 +104,26 @@ describe("transferToSummary()", () => { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(0.1), receiver: testData.addresses.receiver1, + decimals: 18, + symbol: "ULT", }, { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(0.01), receiver: testData.addresses.receiver2, + decimals: 18, + symbol: "ULT", }, { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(0.001), receiver: testData.addresses.receiver3, + decimals: 18, + symbol: "ULT", }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(testData.unlistedToken.address).amount.toFixed()).to.equal("0.111"); + expect(summary.get(testData.unlistedToken.address)?.amount.toFixed()).to.equal("0.111"); }); it("works for integer in erc20", () => { @@ -114,20 +132,26 @@ describe("transferToSummary()", () => { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(1), receiver: testData.addresses.receiver1, + decimals: 18, + symbol: "ULT", }, { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(2), receiver: testData.addresses.receiver2, + decimals: 18, + symbol: "ULT", }, { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(3), receiver: testData.addresses.receiver3, + decimals: 18, + symbol: "ULT", }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(testData.unlistedToken.address).amount.toFixed()).to.equal("6"); + expect(summary.get(testData.unlistedToken.address)?.amount.toFixed()).to.equal("6"); }); it("works for mixed payments", () => { @@ -136,30 +160,40 @@ describe("transferToSummary()", () => { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(1.1), receiver: testData.addresses.receiver1, + decimals: 18, + symbol: "ULT", }, { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(2), receiver: testData.addresses.receiver2, + decimals: 18, + symbol: "ULT", }, { tokenAddress: testData.unlistedToken.address, amount: new BigNumber(3.3), receiver: testData.addresses.receiver3, + decimals: 18, + symbol: "ULT", }, { tokenAddress: null, amount: new BigNumber(3), receiver: testData.addresses.receiver1, + decimals: 18, + symbol: "ETH", }, { tokenAddress: null, amount: new BigNumber(0.33), receiver: testData.addresses.receiver1, + decimals: 18, + symbol: "ETH", }, ]; const summary = transfersToSummary(transfers); - expect(summary.get(testData.unlistedToken.address).amount.toFixed()).to.equal("6.4"); - expect(summary.get(null).amount.toFixed()).to.equal("3.33"); + expect(summary.get(testData.unlistedToken.address)?.amount.toFixed()).to.equal("6.4"); + expect(summary.get(null)?.amount.toFixed()).to.equal("3.33"); }); }); diff --git a/src/components/CSVForm.tsx b/src/components/CSVForm.tsx index c4f6e00..784161f 100644 --- a/src/components/CSVForm.tsx +++ b/src/components/CSVForm.tsx @@ -3,7 +3,7 @@ import { useContext } from "react"; import styled from "styled-components"; import { MessageContext } from "../../src/contexts/MessageContextProvider"; -import { TokenMap } from "../hooks/tokenList"; +import { TokenMap } from "../hooks/token"; import { Payment } from "../parser"; import { CSVEditor } from "./CSVEditor"; @@ -44,7 +44,7 @@ export const CSVForm = (props: CSVFormProps): JSX.Element => { marginRight: 3, }} />{" "} - {tokenList.get(payment.tokenAddress)?.symbol || payment.tokenAddress} + {payment.symbol || payment.tokenAddress} ); }; diff --git a/src/components/CSVUpload.tsx b/src/components/CSVUpload.tsx index e08f3c7..f83871c 100644 --- a/src/components/CSVUpload.tsx +++ b/src/components/CSVUpload.tsx @@ -46,31 +46,21 @@ export const CSVUpload = (props: CSVUploadProps): JSX.Element => {
- - +
+ + + or drop file here + +
diff --git a/src/erc20.ts b/src/erc20.ts index 5a68956..655b4f4 100644 --- a/src/erc20.ts +++ b/src/erc20.ts @@ -1,9 +1,9 @@ import { ethers } from "ethers"; -import { IERC20, IERC20__factory } from "./contracts"; +import { ERC20, ERC20__factory } from "./contracts"; -export const erc20Interface = IERC20__factory.createInterface(); +export const erc20Interface = ERC20__factory.createInterface(); -export function erc20Instance(address: string, provider: ethers.providers.Provider): IERC20 { - return IERC20__factory.connect(address, provider); +export function erc20Instance(address: string, provider: ethers.providers.Provider): ERC20 { + return ERC20__factory.connect(address, provider); } diff --git a/src/hooks/token.ts b/src/hooks/token.ts new file mode 100644 index 0000000..e156530 --- /dev/null +++ b/src/hooks/token.ts @@ -0,0 +1,101 @@ +import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider"; +import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; +import { TokenInfo } from "@uniswap/token-lists"; +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"; + +export type TokenMap = Map; + +function tokenMap(tokenList: TokenInfo[]): TokenMap { + const res: TokenMap = new Map(); + for (const token of tokenList) { + res.set(utils.getAddress(token.address), token); + } + return res; +} + +export const fetchTokenList = async (networkName: string): Promise => { + let tokens: TokenInfo[]; + if (networkName === "MAINNET") { + const mainnetTokenURL = "https://tokens.coingecko.com/uniswap/all.json"; + tokens = (await (await fetch(mainnetTokenURL)).json()).tokens; + } else if (networkName === "RINKEBY") { + // Hardcoded this because the list provided at + // https://github.com/Uniswap/default-token-list/blob/master/src/tokens/rinkeby.json + // Doesn't have GNO or OWL and/or many others. + tokens = rinkeby; + } else if (networkName === "XDAI") { + tokens = xdaiTokens.tokens; + } else { + console.error(`Unimplemented token list for ${networkName} network`); + throw new Error(`Unimplemented token list for ${networkName} network`); + } + console.log(`Fetched ${tokens.length} for ${networkName} network`); + return tokenMap(tokens); +}; + +/** + * Hook which fetches the tokenList for Components. + * Will Execute only once on initial load. + */ +export function useTokenList(): { + tokenList: TokenMap; + isLoading: boolean; +} { + const { safe } = useSafeAppsSDK(); + const [tokenList, setTokenList] = useState(new Map()); + const [isLoading, setIsLoading] = useState(false); + useEffect(() => { + const fetchToken = async () => { + setIsLoading(true); + const result = await fetchTokenList(safe.network); + setTokenList(result); + setIsLoading(false); + }; + fetchToken(); + }, [safe.network]); + return { tokenList, isLoading }; +} + +export type MinimalTokenInfo = { + decimals: number; + address: string; + symbol?: string; + logoURI?: string; +}; + +export interface TokenInfoProvider { + getTokenInfo: (tokenAddress: string) => Promise; +} + +export const useTokenInfoProvider: () => TokenInfoProvider = () => { + const { safe, sdk } = useSafeAppsSDK(); + const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [sdk, safe]); + const { tokenList } = useTokenList(); + + return { + getTokenInfo: async (tokenAddress: string) => { + if (tokenList?.has(tokenAddress)) { + return tokenList.get(tokenAddress); + } else { + const tokenContract = erc20Instance(tokenAddress, web3Provider); + const decimals = await tokenContract.decimals().catch((reason) => undefined); + const symbol = await tokenContract.symbol().catch((reason) => undefined); + if (typeof decimals !== "undefined") { + tokenList?.set(tokenAddress, { + decimals, + symbol, + address: tokenAddress, + }); + return { decimals, symbol, address: tokenAddress }; + } else { + return undefined; + } + } + }, + }; +}; diff --git a/src/hooks/tokenList.ts b/src/hooks/tokenList.ts deleted file mode 100644 index 5c0b70c..0000000 --- a/src/hooks/tokenList.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { TokenInfo } from "@uniswap/token-lists"; -import { utils } from "ethers"; -import xdaiTokens from "honeyswap-default-token-list"; -import { useState, useEffect } from "react"; - -import rinkeby from "../static/rinkebyTokens.json"; - -export type TokenMap = Map; - -// TODO - shouldn't there be a more convenient way of converting a list into a map? -function tokenMap(tokenList: TokenInfo[]): TokenMap { - const res: TokenMap = new Map(); - for (const token of tokenList) { - res.set(utils.getAddress(token.address), token); - } - return res; -} - -export const fetchTokenList = async (networkName: string): Promise => { - let tokens: TokenInfo[]; - if (networkName === "MAINNET") { - const mainnetTokenURL = "https://tokens.coingecko.com/uniswap/all.json"; - tokens = (await (await fetch(mainnetTokenURL)).json()).tokens; - } else if (networkName === "RINKEBY") { - // Hardcoded this because the list provided at - // https://github.com/Uniswap/default-token-list/blob/master/src/tokens/rinkeby.json - // Doesn't have GNO or OWL and/or many others. - tokens = rinkeby; - } else if (networkName === "XDAI") { - tokens = xdaiTokens.tokens; - } else { - console.error(`Unimplemented token list for ${networkName} network`); - } - console.log(`Fetched ${tokens.length} for ${networkName} network`); - return tokenMap(tokens); -}; - -/** - * Hook which fetches the tokenList for Components. - * Will Execute only once on initial load because useEffect gets passed an empty array. - */ -export function useTokenList(): { tokenList: TokenMap; isLoading: boolean } { - const { safe } = useSafeAppsSDK(); - const [tokenList, setTokenList] = useState(); - const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - const fetchToken = async () => { - setIsLoading(true); - const result = await fetchTokenList(safe.network); - setTokenList(result); - setIsLoading(false); - }; - fetchToken(); - }, [safe.network]); - return { tokenList, isLoading }; -} diff --git a/src/parser.ts b/src/parser.ts index 7903634..312a812 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,9 +1,9 @@ -import { parseString, RowValidateCallback } from "@fast-csv/parse"; +import { parseString, RowTransformCallback, RowValidateCallback } from "@fast-csv/parse"; import { BigNumber } from "bignumber.js"; import { utils } from "ethers"; import { CodeWarning } from "./contexts/MessageContextProvider"; -import { TokenMap } from "./hooks/tokenList"; +import { TokenInfoProvider } from "./hooks/token"; /** * Includes methods to parse, transform and validate csv content @@ -13,7 +13,8 @@ export interface Payment { receiver: string; amount: BigNumber; tokenAddress: string | null; - decimals?: number; + decimals: number; + symbol?: string; } export type CSVRow = { @@ -23,6 +24,12 @@ export type CSVRow = { 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, @@ -37,13 +44,16 @@ const generateWarnings = ( return messages; }; -export const parseCSV = (csvText: string, tokenList: TokenMap): Promise<[Payment[], CodeWarning[]]> => { +export const parseCSV = ( + csvText: string, + tokenInfoProvider: TokenInfoProvider, +): Promise<[Payment[], CodeWarning[]]> => { return new Promise<[Payment[], CodeWarning[]]>((resolve, reject) => { const results: any[] = []; const resultingWarnings: CodeWarning[] = []; parseString(csvText, { headers: true }) - .transform(transformRow) - .validate((row: Payment, callback: RowValidateCallback) => validateRow(row, tokenList, callback)) + .transform((row: CSVRow, callback) => transformRow(row, tokenInfoProvider, callback)) + .validate((row: Payment, callback: RowValidateCallback) => validateRow(row, callback)) .on("data", (data) => results.push(data)) .on("end", () => resolve([results, resultingWarnings])) .on("data-invalid", (row: Payment, rowNumber: number, warnings: string) => @@ -56,24 +66,32 @@ export const parseCSV = (csvText: string, tokenList: TokenMap): Promise<[Payment /** * Transforms each row into a payment object. */ -const transformRow = (row: CSVRow): Payment => ({ - // 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, - decimals: row.decimals ? Number(row.decimals) : undefined, -}); +const transformRow = ( + row: CSVRow, + tokenInfoProvider: TokenInfoProvider, + 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) + .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, tokenList: TokenMap, callback: RowValidateCallback) => { - const warnings = [...areAddressesValid(row), ...isAmountPositive(row), ...isDecimalValid(row, tokenList)]; +const validateRow = (row: Payment, callback: RowValidateCallback) => { + const warnings = [...areAddressesValid(row), ...isAmountPositive(row), ...isTokenValid(row)]; callback(null, warnings.length === 0, warnings.join(";")); }; @@ -91,13 +109,38 @@ const areAddressesValid = (row: Payment): string[] => { const isAmountPositive = (row: Payment): string[] => row.amount.isGreaterThan(0) ? [] : ["Only positive amounts possible: " + row.amount.toFixed()]; -const isDecimalValid = (row: Payment, tokenList: TokenMap): string[] => { - if (row.tokenAddress == null || row.tokenAddress === "") { - return []; +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): Promise { + if (prePayment.tokenAddress === null) { + // Native asset payment. + return { + receiver: prePayment.receiver, + amount: prePayment.amount, + tokenAddress: prePayment.tokenAddress, + decimals: 18, + symbol: "ETH", + }; + } + const tokenInfo = await tokenInfoProvider.getTokenInfo(prePayment.tokenAddress); + if (typeof tokenInfo !== "undefined") { + let decimals = tokenInfo.decimals; + let symbol = tokenInfo.symbol; + return { + receiver: prePayment.receiver, + amount: prePayment.amount, + tokenAddress: prePayment.tokenAddress, + decimals, + symbol, + }; } else { - const decimals = - tokenList.get(utils.isAddress(row.tokenAddress) ? utils.getAddress(row.tokenAddress) : row.tokenAddress) - ?.decimals || row.decimals; - return decimals >= 0 ? [] : ["Invalid decimals: " + decimals]; + return { + receiver: prePayment.receiver, + amount: prePayment.amount, + tokenAddress: prePayment.tokenAddress, + decimals: -1, + symbol: "TOKEN_NOT_FOUND", + }; } -}; +} diff --git a/src/transfers.ts b/src/transfers.ts index a9da133..8ac5da1 100644 --- a/src/transfers.ts +++ b/src/transfers.ts @@ -1,11 +1,10 @@ import { Transaction } from "@gnosis.pm/safe-apps-sdk"; import { erc20Interface } from "./erc20"; -import { TokenMap } from "./hooks/tokenList"; import { Payment } from "./parser"; import { toWei } from "./utils"; -export function buildTransfers(transferData: Payment[], tokenList: TokenMap): Transaction[] { +export function buildTransfers(transferData: Payment[]): Transaction[] { const txList: Transaction[] = transferData.map((transfer) => { if (transfer.tokenAddress === null) { // Native asset transfer @@ -16,7 +15,7 @@ export function buildTransfers(transferData: Payment[], tokenList: TokenMap): Tr }; } else { // ERC20 transfer - const decimals = tokenList.get(transfer.tokenAddress)?.decimals || transfer.decimals; + const decimals = transfer.decimals; const amountData = toWei(transfer.amount, decimals); return { to: transfer.tokenAddress, diff --git a/src/utils.ts b/src/utils.ts index e8e11a1..779be27 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,6 @@ import { BigNumber } from "bignumber.js"; import { ethers, utils } from "ethers"; import { erc20Instance } from "./erc20"; -import { TokenMap } from "./hooks/tokenList"; import { Payment } from "./parser"; export const ZERO = new BigNumber(0); @@ -31,26 +30,28 @@ export function fromWei(amount: BigNumber, decimals: number): BigNumber { } export type SummaryEntry = { - tokenAddress: string; + tokenAddress: string | null; amount: BigNumber; decimals: number; + symbol?: string; }; export const transfersToSummary = (transfers: Payment[]) => { - return transfers.reduce((previousValue, currentValue): Map => { + return transfers.reduce((previousValue, currentValue): Map => { let tokenSummary = previousValue.get(currentValue.tokenAddress); if (typeof tokenSummary === "undefined") { tokenSummary = { tokenAddress: currentValue.tokenAddress, amount: new BigNumber(0), decimals: currentValue.decimals, + symbol: currentValue.symbol, }; previousValue.set(currentValue.tokenAddress, tokenSummary); } tokenSummary.amount = tokenSummary.amount.plus(currentValue.amount); return previousValue; - }, new Map()); + }, new Map()); }; export type InsufficientBalanceInfo = { @@ -59,13 +60,12 @@ export type InsufficientBalanceInfo = { }; export const checkAllBalances = async ( - summary: Map, + summary: Map, web3Provider: ethers.providers.Web3Provider, safe: SafeInfo, - tokenList: TokenMap, ): Promise => { const insufficientTokens: InsufficientBalanceInfo[] = []; - for (const { tokenAddress, amount, decimals } of summary.values()) { + for (const { tokenAddress, amount, decimals, symbol } of summary.values()) { if (tokenAddress === null) { // Check ETH Balance const tokenBalance = await web3Provider.getBalance(safe.safeAddress, "latest"); @@ -81,10 +81,9 @@ export const checkAllBalances = async ( console.error(reason); return ethers.BigNumber.from(-1); }); - const tokenInfo = tokenList.get(tokenAddress); - if (!isSufficientBalance(tokenBalance, amount, tokenInfo?.decimals || decimals)) { + if (!isSufficientBalance(tokenBalance, amount, decimals)) { insufficientTokens.push({ - token: tokenInfo?.symbol || tokenAddress, + token: symbol || tokenAddress, transferAmount: amount.toFixed(), }); } diff --git a/test_data/rinkeby-example.csv b/test_data/rinkeby-example.csv index 4242ab2..991503e 100644 --- a/test_data/rinkeby-example.csv +++ b/test_data/rinkeby-example.csv @@ -8,4 +8,5 @@ token_address,receiver,amount 0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b,0x7000000000000000000000000000000000000000,0.0007 0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b,0x8000000000000000000000000000000000000000,0.0008 0x784b46a4331f5c7c495f296ae700652265ab2fc6,0x9000000000000000000000000000000000000000,0.01 -0x784b46a4331f5c7c495f296ae700652265ab2fc6,0xa000000000000000000000000000000000000000,0.02 \ No newline at end of file +0x784b46a4331f5c7c495f296ae700652265ab2fc6,0xa000000000000000000000000000000000000000,0.02 +0x4dcf5ac4509888714dd43a5ccc46d7ab389d9c23,0xb000000000000000000000000000000000000000,2 diff --git a/tsconfig.json b/tsconfig.json index b01fe74..26788ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "noImplicitThis": true, "noUnusedLocals": false, "skipLibCheck": true, - "strictNullChecks": false, + "strictNullChecks": true, "suppressImplicitAnyIndexErrors": true, "types": ["jest", "node"], "baseUrl": ".",