Skip to content

Commit

Permalink
Fetch Token Data from EVM (#62)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
bh2smith and schmanu authored May 21, 2021
1 parent 7a54b08 commit 4aaf65f
Show file tree
Hide file tree
Showing 17 changed files with 331 additions and 195 deletions.
10 changes: 8 additions & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
14 changes: 7 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Payment[]>([]);
const [csvText, setCsvText] = useState<string>("token_address,receiver,amount,decimals");
Expand All @@ -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}`,
Expand All @@ -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);
Expand All @@ -62,7 +62,7 @@ const App: React.FC = () => {
console.error(e);
}
setSubmitting(false);
}, [transferContent, tokenList, sdk.txs]);
}, [transferContent, sdk.txs]);
return (
<Container>
<Header />
Expand Down
72 changes: 42 additions & 30 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -24,90 +21,105 @@ 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;
});

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);
});
});
2 changes: 1 addition & 1 deletion src/__tests__/tokenList.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from "chai";

import { fetchTokenList } from "../hooks/tokenList";
import { fetchTokenList } from "../hooks/token";

const configuredNetworks = ["MAINNET", "RINKEBY", "XDAI"];

Expand Down
42 changes: 25 additions & 17 deletions src/__tests__/transfers.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -34,24 +32,27 @@ describe("Build Transfers:", () => {
amount: fromWei(MAX_U256, listedToken.decimals),
tokenAddress: listedToken.address,
decimals: listedToken.decimals,
symbol: "LIT",
},
// Unlisted ERC20
{
receiver,
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(
Expand Down Expand Up @@ -80,24 +81,27 @@ describe("Build Transfers:", () => {
amount: tinyAmount,
tokenAddress: listedToken.address,
decimals: listedToken.decimals,
symbol: "LIT",
},
// Unlisted ERC20
{
receiver,
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(
Expand Down Expand Up @@ -129,24 +133,27 @@ describe("Build Transfers:", () => {
amount: mixedAmount,
tokenAddress: listedToken.address,
decimals: listedToken.decimals,
symbol: "LIT",
},
// Unlisted ERC20
{
receiver,
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(
Expand Down Expand Up @@ -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(
Expand Down
Loading

0 comments on commit 4aaf65f

Please sign in to comment.