From 86de3971790c1baab7c6705eed4c5090aefb2491 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Fri, 3 Dec 2021 11:42:38 +0100 Subject: [PATCH] Nft transfers (#311) * First working version of nft transfers * 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 * small ui cleanup * Cache for erc721 token info provider * rework of nft sending ui * one combined CSV file for nft / asset transfers * tables are wrapped in accordions * erc1155 support, some redesigns * small refactoring of modal * fixes existing unittests * small refactoring, parser tests * 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 * remove unused global styles * 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 * Update src/components/FAQModal.tsx Co-authored-by: schmanu Co-authored-by: Benjamin Smith --- customabis/ERC721.json | 327 ++++++++++++++++++ package.json | 2 +- public/sample.csv | 9 +- src/App.tsx | 124 +++---- src/GlobalStyle.ts | 11 + src/__tests__/parser.test.ts | 285 +++++++++++++-- src/__tests__/transfers.test.ts | 105 ++++-- src/__tests__/utils.test.ts | 53 ++- src/components/CSVForm.tsx | 146 -------- src/components/FAQModal.tsx | 106 ++++++ src/components/Summary.tsx | 74 ++++ src/components/Token.tsx | 37 -- .../AssetTransferTable.tsx} | 22 +- src/components/assets/CSVForm.tsx | 122 +++++++ .../assets/CollectiblesTransferTable.tsx | 55 +++ src/components/assets/ERC20Token.tsx | 40 +++ src/components/assets/ERC721Token.tsx | 100 ++++++ src/hooks/collectibleTokenInfoProvider.ts | 158 +++++++++ src/hooks/token.ts | 2 +- src/parser.ts | 171 --------- src/parser/csvParser.ts | 93 +++++ src/parser/transformation.ts | 210 +++++++++++ src/parser/validation.ts | 75 ++++ src/test/util.ts | 21 +- src/transfers.ts | 28 -- src/transfers/erc1155.ts | 9 + src/transfers/erc165.ts | 9 + src/{ => transfers}/erc20.ts | 2 +- src/transfers/erc721.ts | 9 + src/transfers/transfers.ts | 61 ++++ src/utils.ts | 6 +- test_data/rinkeby-mixed.csv | 5 + 32 files changed, 1945 insertions(+), 532 deletions(-) create mode 100644 customabis/ERC721.json delete mode 100644 src/components/CSVForm.tsx create mode 100644 src/components/FAQModal.tsx create mode 100644 src/components/Summary.tsx delete mode 100644 src/components/Token.tsx rename src/components/{TransferTable.tsx => assets/AssetTransferTable.tsx} (52%) create mode 100644 src/components/assets/CSVForm.tsx create mode 100644 src/components/assets/CollectiblesTransferTable.tsx create mode 100644 src/components/assets/ERC20Token.tsx create mode 100644 src/components/assets/ERC721Token.tsx create mode 100644 src/hooks/collectibleTokenInfoProvider.ts delete mode 100644 src/parser.ts create mode 100644 src/parser/csvParser.ts create mode 100644 src/parser/transformation.ts create mode 100644 src/parser/validation.ts delete mode 100644 src/transfers.ts create mode 100644 src/transfers/erc1155.ts create mode 100644 src/transfers/erc165.ts rename src/{ => transfers}/erc20.ts (82%) create mode 100644 src/transfers/erc721.ts create mode 100644 src/transfers/transfers.ts create mode 100644 test_data/rinkeby-mixed.csv diff --git a/customabis/ERC721.json b/customabis/ERC721.json new file mode 100644 index 0000000..7295b27 --- /dev/null +++ b/customabis/ERC721.json @@ -0,0 +1,327 @@ +{ + "contractName": "ERC721", + "abi": [ + { + "inputs": [ + { + "internalType": "string", + "name": "name_", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol_", + "type": "string" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "approved", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "getApproved", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ownerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "tokenURI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ], + "bytecode": "0x60806040523480156200001157600080fd5b50604051620014d2380380620014d28339810160408190526200003491620001c1565b81516200004990600090602085019062000068565b5080516200005f90600190602084019062000068565b5050506200027b565b828054620000769062000228565b90600052602060002090601f0160209004810192826200009a5760008555620000e5565b82601f10620000b557805160ff1916838001178555620000e5565b82800160010185558215620000e5579182015b82811115620000e5578251825591602001919060010190620000c8565b50620000f3929150620000f7565b5090565b5b80821115620000f35760008155600101620000f8565b600082601f8301126200011f578081fd5b81516001600160401b03808211156200013c576200013c62000265565b604051601f8301601f19908116603f0116810190828211818310171562000167576200016762000265565b8160405283815260209250868385880101111562000183578485fd5b8491505b83821015620001a6578582018301518183018401529082019062000187565b83821115620001b757848385830101525b9695505050505050565b60008060408385031215620001d4578182fd5b82516001600160401b0380821115620001eb578384fd5b620001f9868387016200010e565b935060208501519150808211156200020f578283fd5b506200021e858286016200010e565b9150509250929050565b600181811c908216806200023d57607f821691505b602082108114156200025f57634e487b7160e01b600052602260045260246000fd5b50919050565b634e487b7160e01b600052604160045260246000fd5b611247806200028b6000396000f3fe608060405234801561001057600080fd5b50600436106100cf5760003560e01c80636352211e1161008c578063a22cb46511610066578063a22cb465146101b3578063b88d4fde146101c6578063c87b56dd146101d9578063e985e9c5146101ec576100cf565b80636352211e1461017757806370a082311461018a57806395d89b41146101ab576100cf565b806301ffc9a7146100d457806306fdde03146100fc578063081812fc14610111578063095ea7b31461013c57806323b872dd1461015157806342842e0e14610164575b600080fd5b6100e76100e2366004610f3f565b610228565b60405190151581526020015b60405180910390f35b61010461027c565b6040516100f39190611027565b61012461011f366004610f77565b61030e565b6040516001600160a01b0390911681526020016100f3565b61014f61014a366004610f16565b6103a8565b005b61014f61015f366004610dcc565b6104be565b61014f610172366004610dcc565b6104ef565b610124610185366004610f77565b61050a565b61019d610198366004610d80565b610581565b6040519081526020016100f3565b610104610608565b61014f6101c1366004610edc565b610617565b61014f6101d4366004610e07565b6106e9565b6101046101e7366004610f77565b610721565b6100e76101fa366004610d9a565b6001600160a01b03918216600090815260056020908152604080832093909416825291909152205460ff1690565b60006001600160e01b031982166380ac58cd60e01b148061025957506001600160e01b03198216635b5e139f60e01b145b8061027457506301ffc9a760e01b6001600160e01b03198316145b90505b919050565b60606000805461028b9061114c565b80601f01602080910402602001604051908101604052809291908181526020018280546102b79061114c565b80156103045780601f106102d957610100808354040283529160200191610304565b820191906000526020600020905b8154815290600101906020018083116102e757829003601f168201915b5050505050905090565b6000818152600260205260408120546001600160a01b031661038c5760405162461bcd60e51b815260206004820152602c60248201527f4552433732313a20617070726f76656420717565727920666f72206e6f6e657860448201526b34b9ba32b73a103a37b5b2b760a11b60648201526084015b60405180910390fd5b506000908152600460205260409020546001600160a01b031690565b60006103b38261050a565b9050806001600160a01b0316836001600160a01b031614156104215760405162461bcd60e51b815260206004820152602160248201527f4552433732313a20617070726f76616c20746f2063757272656e74206f776e656044820152603960f91b6064820152608401610383565b336001600160a01b038216148061043d575061043d81336101fa565b6104af5760405162461bcd60e51b815260206004820152603860248201527f4552433732313a20617070726f76652063616c6c6572206973206e6f74206f7760448201527f6e6572206e6f7220617070726f76656420666f7220616c6c00000000000000006064820152608401610383565b6104b98383610809565b505050565b6104c83382610877565b6104e45760405162461bcd60e51b81526004016103839061108c565b6104b983838361096e565b6104b9838383604051806020016040528060008152506106e9565b6000818152600260205260408120546001600160a01b0316806102745760405162461bcd60e51b815260206004820152602960248201527f4552433732313a206f776e657220717565727920666f72206e6f6e657869737460448201526832b73a103a37b5b2b760b91b6064820152608401610383565b60006001600160a01b0382166105ec5760405162461bcd60e51b815260206004820152602a60248201527f4552433732313a2062616c616e636520717565727920666f7220746865207a65604482015269726f206164647265737360b01b6064820152608401610383565b506001600160a01b031660009081526003602052604090205490565b60606001805461028b9061114c565b6001600160a01b0382163314156106705760405162461bcd60e51b815260206004820152601960248201527f4552433732313a20617070726f766520746f2063616c6c6572000000000000006044820152606401610383565b3360008181526005602090815260408083206001600160a01b0387168085529252909120805460ff1916841515179055906001600160a01b03167f17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31836040516106dd911515815260200190565b60405180910390a35050565b6106f33383610877565b61070f5760405162461bcd60e51b81526004016103839061108c565b61071b84848484610b0e565b50505050565b6000818152600260205260409020546060906001600160a01b03166107a05760405162461bcd60e51b815260206004820152602f60248201527f4552433732314d657461646174613a2055524920717565727920666f72206e6f60448201526e3732bc34b9ba32b73a103a37b5b2b760891b6064820152608401610383565b60006107b760408051602081019091526000815290565b905060008151116107d75760405180602001604052806000815250610802565b806107e184610b41565b6040516020016107f2929190610fbb565b6040516020818303038152906040525b9392505050565b600081815260046020526040902080546001600160a01b0319166001600160a01b038416908117909155819061083e8261050a565b6001600160a01b03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560405160405180910390a45050565b6000818152600260205260408120546001600160a01b03166108f05760405162461bcd60e51b815260206004820152602c60248201527f4552433732313a206f70657261746f7220717565727920666f72206e6f6e657860448201526b34b9ba32b73a103a37b5b2b760a11b6064820152608401610383565b60006108fb8361050a565b9050806001600160a01b0316846001600160a01b031614806109365750836001600160a01b031661092b8461030e565b6001600160a01b0316145b8061096657506001600160a01b0380821660009081526005602090815260408083209388168352929052205460ff165b949350505050565b826001600160a01b03166109818261050a565b6001600160a01b0316146109e95760405162461bcd60e51b815260206004820152602960248201527f4552433732313a207472616e73666572206f6620746f6b656e2074686174206960448201526839903737ba1037bbb760b91b6064820152608401610383565b6001600160a01b038216610a4b5760405162461bcd60e51b8152602060048201526024808201527f4552433732313a207472616e7366657220746f20746865207a65726f206164646044820152637265737360e01b6064820152608401610383565b610a56600082610809565b6001600160a01b0383166000908152600360205260408120805460019290610a7f908490611109565b90915550506001600160a01b0382166000908152600360205260408120805460019290610aad9084906110dd565b909155505060008181526002602052604080822080546001600160a01b0319166001600160a01b0386811691821790925591518493918716917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef91a4505050565b610b1984848461096e565b610b2584848484610c5c565b61071b5760405162461bcd60e51b81526004016103839061103a565b606081610b6657506040805180820190915260018152600360fc1b6020820152610277565b8160005b8115610b905780610b7a81611187565b9150610b899050600a836110f5565b9150610b6a565b60008167ffffffffffffffff811115610bb957634e487b7160e01b600052604160045260246000fd5b6040519080825280601f01601f191660200182016040528015610be3576020820181803683370190505b5090505b841561096657610bf8600183611109565b9150610c05600a866111a2565b610c109060306110dd565b60f81b818381518110610c3357634e487b7160e01b600052603260045260246000fd5b60200101906001600160f81b031916908160001a905350610c55600a866110f5565b9450610be7565b60006001600160a01b0384163b15610d5e57604051630a85bd0160e11b81526001600160a01b0385169063150b7a0290610ca0903390899088908890600401610fea565b602060405180830381600087803b158015610cba57600080fd5b505af1925050508015610cea575060408051601f3d908101601f19168201909252610ce791810190610f5b565b60015b610d44573d808015610d18576040519150601f19603f3d011682016040523d82523d6000602084013e610d1d565b606091505b508051610d3c5760405162461bcd60e51b81526004016103839061103a565b805181602001fd5b6001600160e01b031916630a85bd0160e11b149050610966565b506001949350505050565b80356001600160a01b038116811461027757600080fd5b600060208284031215610d91578081fd5b61080282610d69565b60008060408385031215610dac578081fd5b610db583610d69565b9150610dc360208401610d69565b90509250929050565b600080600060608486031215610de0578081fd5b610de984610d69565b9250610df760208501610d69565b9150604084013590509250925092565b60008060008060808587031215610e1c578081fd5b610e2585610d69565b9350610e3360208601610d69565b925060408501359150606085013567ffffffffffffffff80821115610e56578283fd5b818701915087601f830112610e69578283fd5b813581811115610e7b57610e7b6111e2565b604051601f8201601f19908116603f01168101908382118183101715610ea357610ea36111e2565b816040528281528a6020848701011115610ebb578586fd5b82602086016020830137918201602001949094529598949750929550505050565b60008060408385031215610eee578182fd5b610ef783610d69565b915060208301358015158114610f0b578182fd5b809150509250929050565b60008060408385031215610f28578182fd5b610f3183610d69565b946020939093013593505050565b600060208284031215610f50578081fd5b8135610802816111f8565b600060208284031215610f6c578081fd5b8151610802816111f8565b600060208284031215610f88578081fd5b5035919050565b60008151808452610fa7816020860160208601611120565b601f01601f19169290920160200192915050565b60008351610fcd818460208801611120565b835190830190610fe1818360208801611120565b01949350505050565b6001600160a01b038581168252841660208201526040810183905260806060820181905260009061101d90830184610f8f565b9695505050505050565b6000602082526108026020830184610f8f565b60208082526032908201527f4552433732313a207472616e7366657220746f206e6f6e20455243373231526560408201527131b2b4bb32b91034b6b83632b6b2b73a32b960711b606082015260800190565b60208082526031908201527f4552433732313a207472616e736665722063616c6c6572206973206e6f74206f6040820152701ddb995c881b9bdc88185c1c1c9bdd9959607a1b606082015260800190565b600082198211156110f0576110f06111b6565b500190565b600082611104576111046111cc565b500490565b60008282101561111b5761111b6111b6565b500390565b60005b8381101561113b578181015183820152602001611123565b8381111561071b5750506000910152565b600181811c9082168061116057607f821691505b6020821081141561118157634e487b7160e01b600052602260045260246000fd5b50919050565b600060001982141561119b5761119b6111b6565b5060010190565b6000826111b1576111b16111cc565b500690565b634e487b7160e01b600052601160045260246000fd5b634e487b7160e01b600052601260045260246000fd5b634e487b7160e01b600052604160045260246000fd5b6001600160e01b03198116811461120e57600080fd5b5056fea26469706673582212202d938d3b28324cc89e6d2076ba56ed8ae7428805a112a58e80ba9ad3b13c770964736f6c63430008030033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100cf5760003560e01c80636352211e1161008c578063a22cb46511610066578063a22cb465146101b3578063b88d4fde146101c6578063c87b56dd146101d9578063e985e9c5146101ec576100cf565b80636352211e1461017757806370a082311461018a57806395d89b41146101ab576100cf565b806301ffc9a7146100d457806306fdde03146100fc578063081812fc14610111578063095ea7b31461013c57806323b872dd1461015157806342842e0e14610164575b600080fd5b6100e76100e2366004610f3f565b610228565b60405190151581526020015b60405180910390f35b61010461027c565b6040516100f39190611027565b61012461011f366004610f77565b61030e565b6040516001600160a01b0390911681526020016100f3565b61014f61014a366004610f16565b6103a8565b005b61014f61015f366004610dcc565b6104be565b61014f610172366004610dcc565b6104ef565b610124610185366004610f77565b61050a565b61019d610198366004610d80565b610581565b6040519081526020016100f3565b610104610608565b61014f6101c1366004610edc565b610617565b61014f6101d4366004610e07565b6106e9565b6101046101e7366004610f77565b610721565b6100e76101fa366004610d9a565b6001600160a01b03918216600090815260056020908152604080832093909416825291909152205460ff1690565b60006001600160e01b031982166380ac58cd60e01b148061025957506001600160e01b03198216635b5e139f60e01b145b8061027457506301ffc9a760e01b6001600160e01b03198316145b90505b919050565b60606000805461028b9061114c565b80601f01602080910402602001604051908101604052809291908181526020018280546102b79061114c565b80156103045780601f106102d957610100808354040283529160200191610304565b820191906000526020600020905b8154815290600101906020018083116102e757829003601f168201915b5050505050905090565b6000818152600260205260408120546001600160a01b031661038c5760405162461bcd60e51b815260206004820152602c60248201527f4552433732313a20617070726f76656420717565727920666f72206e6f6e657860448201526b34b9ba32b73a103a37b5b2b760a11b60648201526084015b60405180910390fd5b506000908152600460205260409020546001600160a01b031690565b60006103b38261050a565b9050806001600160a01b0316836001600160a01b031614156104215760405162461bcd60e51b815260206004820152602160248201527f4552433732313a20617070726f76616c20746f2063757272656e74206f776e656044820152603960f91b6064820152608401610383565b336001600160a01b038216148061043d575061043d81336101fa565b6104af5760405162461bcd60e51b815260206004820152603860248201527f4552433732313a20617070726f76652063616c6c6572206973206e6f74206f7760448201527f6e6572206e6f7220617070726f76656420666f7220616c6c00000000000000006064820152608401610383565b6104b98383610809565b505050565b6104c83382610877565b6104e45760405162461bcd60e51b81526004016103839061108c565b6104b983838361096e565b6104b9838383604051806020016040528060008152506106e9565b6000818152600260205260408120546001600160a01b0316806102745760405162461bcd60e51b815260206004820152602960248201527f4552433732313a206f776e657220717565727920666f72206e6f6e657869737460448201526832b73a103a37b5b2b760b91b6064820152608401610383565b60006001600160a01b0382166105ec5760405162461bcd60e51b815260206004820152602a60248201527f4552433732313a2062616c616e636520717565727920666f7220746865207a65604482015269726f206164647265737360b01b6064820152608401610383565b506001600160a01b031660009081526003602052604090205490565b60606001805461028b9061114c565b6001600160a01b0382163314156106705760405162461bcd60e51b815260206004820152601960248201527f4552433732313a20617070726f766520746f2063616c6c6572000000000000006044820152606401610383565b3360008181526005602090815260408083206001600160a01b0387168085529252909120805460ff1916841515179055906001600160a01b03167f17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31836040516106dd911515815260200190565b60405180910390a35050565b6106f33383610877565b61070f5760405162461bcd60e51b81526004016103839061108c565b61071b84848484610b0e565b50505050565b6000818152600260205260409020546060906001600160a01b03166107a05760405162461bcd60e51b815260206004820152602f60248201527f4552433732314d657461646174613a2055524920717565727920666f72206e6f60448201526e3732bc34b9ba32b73a103a37b5b2b760891b6064820152608401610383565b60006107b760408051602081019091526000815290565b905060008151116107d75760405180602001604052806000815250610802565b806107e184610b41565b6040516020016107f2929190610fbb565b6040516020818303038152906040525b9392505050565b600081815260046020526040902080546001600160a01b0319166001600160a01b038416908117909155819061083e8261050a565b6001600160a01b03167f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560405160405180910390a45050565b6000818152600260205260408120546001600160a01b03166108f05760405162461bcd60e51b815260206004820152602c60248201527f4552433732313a206f70657261746f7220717565727920666f72206e6f6e657860448201526b34b9ba32b73a103a37b5b2b760a11b6064820152608401610383565b60006108fb8361050a565b9050806001600160a01b0316846001600160a01b031614806109365750836001600160a01b031661092b8461030e565b6001600160a01b0316145b8061096657506001600160a01b0380821660009081526005602090815260408083209388168352929052205460ff165b949350505050565b826001600160a01b03166109818261050a565b6001600160a01b0316146109e95760405162461bcd60e51b815260206004820152602960248201527f4552433732313a207472616e73666572206f6620746f6b656e2074686174206960448201526839903737ba1037bbb760b91b6064820152608401610383565b6001600160a01b038216610a4b5760405162461bcd60e51b8152602060048201526024808201527f4552433732313a207472616e7366657220746f20746865207a65726f206164646044820152637265737360e01b6064820152608401610383565b610a56600082610809565b6001600160a01b0383166000908152600360205260408120805460019290610a7f908490611109565b90915550506001600160a01b0382166000908152600360205260408120805460019290610aad9084906110dd565b909155505060008181526002602052604080822080546001600160a01b0319166001600160a01b0386811691821790925591518493918716917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef91a4505050565b610b1984848461096e565b610b2584848484610c5c565b61071b5760405162461bcd60e51b81526004016103839061103a565b606081610b6657506040805180820190915260018152600360fc1b6020820152610277565b8160005b8115610b905780610b7a81611187565b9150610b899050600a836110f5565b9150610b6a565b60008167ffffffffffffffff811115610bb957634e487b7160e01b600052604160045260246000fd5b6040519080825280601f01601f191660200182016040528015610be3576020820181803683370190505b5090505b841561096657610bf8600183611109565b9150610c05600a866111a2565b610c109060306110dd565b60f81b818381518110610c3357634e487b7160e01b600052603260045260246000fd5b60200101906001600160f81b031916908160001a905350610c55600a866110f5565b9450610be7565b60006001600160a01b0384163b15610d5e57604051630a85bd0160e11b81526001600160a01b0385169063150b7a0290610ca0903390899088908890600401610fea565b602060405180830381600087803b158015610cba57600080fd5b505af1925050508015610cea575060408051601f3d908101601f19168201909252610ce791810190610f5b565b60015b610d44573d808015610d18576040519150601f19603f3d011682016040523d82523d6000602084013e610d1d565b606091505b508051610d3c5760405162461bcd60e51b81526004016103839061103a565b805181602001fd5b6001600160e01b031916630a85bd0160e11b149050610966565b506001949350505050565b80356001600160a01b038116811461027757600080fd5b600060208284031215610d91578081fd5b61080282610d69565b60008060408385031215610dac578081fd5b610db583610d69565b9150610dc360208401610d69565b90509250929050565b600080600060608486031215610de0578081fd5b610de984610d69565b9250610df760208501610d69565b9150604084013590509250925092565b60008060008060808587031215610e1c578081fd5b610e2585610d69565b9350610e3360208601610d69565b925060408501359150606085013567ffffffffffffffff80821115610e56578283fd5b818701915087601f830112610e69578283fd5b813581811115610e7b57610e7b6111e2565b604051601f8201601f19908116603f01168101908382118183101715610ea357610ea36111e2565b816040528281528a6020848701011115610ebb578586fd5b82602086016020830137918201602001949094529598949750929550505050565b60008060408385031215610eee578182fd5b610ef783610d69565b915060208301358015158114610f0b578182fd5b809150509250929050565b60008060408385031215610f28578182fd5b610f3183610d69565b946020939093013593505050565b600060208284031215610f50578081fd5b8135610802816111f8565b600060208284031215610f6c578081fd5b8151610802816111f8565b600060208284031215610f88578081fd5b5035919050565b60008151808452610fa7816020860160208601611120565b601f01601f19169290920160200192915050565b60008351610fcd818460208801611120565b835190830190610fe1818360208801611120565b01949350505050565b6001600160a01b038581168252841660208201526040810183905260806060820181905260009061101d90830184610f8f565b9695505050505050565b6000602082526108026020830184610f8f565b60208082526032908201527f4552433732313a207472616e7366657220746f206e6f6e20455243373231526560408201527131b2b4bb32b91034b6b83632b6b2b73a32b960711b606082015260800190565b60208082526031908201527f4552433732313a207472616e736665722063616c6c6572206973206e6f74206f6040820152701ddb995c881b9bdc88185c1c1c9bdd9959607a1b606082015260800190565b600082198211156110f0576110f06111b6565b500190565b600082611104576111046111cc565b500490565b60008282101561111b5761111b6111b6565b500390565b60005b8381101561113b578181015183820152602001611123565b8381111561071b5750506000910152565b600181811c9082168061116057607f821691505b6020821081141561118157634e487b7160e01b600052602260045260246000fd5b50919050565b600060001982141561119b5761119b6111b6565b5060010190565b6000826111b1576111b16111cc565b500690565b634e487b7160e01b600052601160045260246000fd5b634e487b7160e01b600052601260045260246000fd5b634e487b7160e01b600052604160045260246000fd5b6001600160e01b03198116811461120e57600080fd5b5056fea26469706673582212202d938d3b28324cc89e6d2076ba56ed8ae7428805a112a58e80ba9ad3b13c770964736f6c63430008030033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/package.json b/package.json index b266258..be93bf8 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' './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/public/sample.csv b/public/sample.csv index 099a019..317185d 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/App.tsx b/src/App.tsx index 1560cc8..7c23068 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,53 @@ import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; -import { GenericModal, Icon, Loader, Text, Title, Divider, Button } from "@gnosis.pm/safe-react-components"; -import { Fab } from "@material-ui/core"; +import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk"; +import { Button, Card, Divider, Loader, Text } from "@gnosis.pm/safe-react-components"; import { setUseWhatChange } from "@simbathesailor/use-what-changed"; -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import styled from "styled-components"; -import { CSVForm } from "./components/CSVForm"; +import { FAQModal } from "./components/FAQModal"; import { Header } from "./components/Header"; +import { Summary } from "./components/Summary"; +import { CSVForm } from "./components/assets/CSVForm"; import { useTokenList, networkMap } from "./hooks/token"; +import { AssetTransfer, CollectibleTransfer, Transfer } from "./parser/csvParser"; +import { buildAssetTransfers, buildCollectibleTransfers } from "./transfers/transfers"; setUseWhatChange(process.env.NODE_ENV === "development"); const App: React.FC = () => { const { isLoading } = useTokenList(); const { safe } = useSafeAppsSDK(); - const [showHelp, setShowHelp] = useState(false); + const [tokenTransfers, setTokenTransfers] = useState([]); + + const [submitting, setSubmitting] = useState(false); + const [parsing, setParsing] = useState(false); + const { sdk } = useSafeAppsSDK(); + + const assetTransfers = tokenTransfers.filter( + (transfer) => transfer.token_type === "erc20" || transfer.token_type === "native", + ) as AssetTransfer[]; + const collectibleTransfers = tokenTransfers.filter( + (transfer) => transfer.token_type === "erc1155" || transfer.token_type === "erc721", + ) as CollectibleTransfer[]; + + const submitTx = useCallback(async () => { + setSubmitting(true); + try { + const txs: BaseTransaction[] = []; + txs.push(...buildAssetTransfers(assetTransfers)); + txs.push(...buildCollectibleTransfers(collectibleTransfers)); + + console.log(`Encoded ${txs.length} 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]); + return (
@@ -26,67 +59,36 @@ const App: React.FC = () => { Loading Tokenlist... ) : ( - + + + + + {submitting ? ( + <> + +
+ + + ) : ( + + )} + )} ) : ( 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/GlobalStyle.ts b/src/GlobalStyle.ts index f9ee4cc..286ea78 100644 --- a/src/GlobalStyle.ts +++ b/src/GlobalStyle.ts @@ -57,6 +57,17 @@ 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: 1; + } + + .tableContainer { + display: flex; + flex-direction: horizontal; + gap: 16px; + width: 100%; + } `; export default GlobalStyle; diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 5e73213..8d1bcea 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -4,9 +4,10 @@ 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 { TokenMap, MinimalTokenInfo, fetchTokenList, TokenInfoProvider } from "../hooks/token"; -import { parseCSV } from "../parser"; +import { AssetTransfer, CollectibleTransfer, 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 mockCollectibleTokenInfoProvider: CollectibleTokenInfoProvider; let mockEnsResolver: EnsResolver; beforeAll(async () => { @@ -45,6 +47,21 @@ describe("Parsing CSVs ", () => { getNativeTokenSymbol: () => "ETH", }; + mockCollectibleTokenInfoProvider = { + getFromAddress: () => testData.dummySafeInfo.safeAddress, + getTokenInfo: async (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 = { resolveName: async (ensName: string) => { if (ensName.startsWith("0x")) { @@ -86,32 +103,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(parseCSV(invalidCSV, mockTokenInfoProvider, mockEnsResolver)).to.be.rejectedWith( - "column header mismatch expected: 2 columns got: 3", - ); + expect( + CSVParser.parseCSV(invalidCSV, mockTokenInfoProvider, mockCollectibleTokenInfoProvider, 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( - parseCSV(csvStringFromRows(rowWithErrorReceiver), mockTokenInfoProvider, mockEnsResolver), + CSVParser.parseCSV( + csvStringFromRows(rowWithErrorReceiver), + mockTokenInfoProvider, + mockCollectibleTokenInfoProvider, + 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 parseCSV( + const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(rowWithoutDecimal, rowWithDecimalAmount, rowWithoutTokenAddress), mockTokenInfoProvider, + mockCollectibleTokenInfoProvider, 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); @@ -131,14 +154,19 @@ describe("Parsing CSVs ", () => { expect(paymentWithoutTokenAddress.receiverEnsName).to.be.null; }); - it("should generate validation warnings", async () => { - const rowWithNegativeAmount = [listedToken.address, validReceiverAddress, "-1"]; + it("should generate erc20 validation warnings", async () => { + 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 parseCSV( + const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows( rowWithNegativeAmount, unlistedTokenWithoutDecimalInContract, @@ -146,6 +174,7 @@ describe("Parsing CSVs ", () => { rowWithInvalidReceiverAddress, ), mockTokenInfoProvider, + mockCollectibleTokenInfoProvider, mockEnsResolver, ); expect(warnings).to.have.lengthOf(5); @@ -162,7 +191,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); @@ -175,20 +204,21 @@ describe("Parsing CSVs ", () => { expect(warningInvalidReceiverAddress.lineNo).to.equal(4); }); - 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"]; + 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"]; + const unknownTokenEnsName = ["erc20", "unknown.eth", "receiver1.eth", "1"]; - const [payment, warnings] = await parseCSV( + const [payment, warnings] = await CSVParser.parseCSV( csvStringFromRows(receiverEnsName, tokenEnsName, unknownReceiverEnsName, unknownTokenEnsName), mockTokenInfoProvider, + mockCollectibleTokenInfoProvider, 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); @@ -211,4 +241,207 @@ 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 = ["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, + mockCollectibleTokenInfoProvider, + 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 = [ + "nft", + testData.addresses.dummyErc1155Address, + validReceiverAddress, + "-1", + "5", + ]; + + const rowErc1155WithDecimalValue = [ + "nft", + testData.addresses.dummyErc1155Address, + validReceiverAddress, + "1.5", + "5", + ]; + + const rowErc1155WithMissingValue = ["nft", testData.addresses.dummyErc1155Address, validReceiverAddress, "", "5"]; + + const rowErc1155WithMissingId = ["nft", testData.addresses.dummyErc1155Address, validReceiverAddress, "5", ""]; + + const rowErc1155WithInvalidTokenAddress = ["nft", "0xwhoopsie", validReceiverAddress, "5", "5"]; + + const rowErc1155WithInvalidReceiverAddress = [ + "nft", + testData.addresses.dummyErc1155Address, + "0xwhoopsie", + "5", + "5", + ]; + + const rowErc721WithNegativeId = ["nft", testData.addresses.dummyErc721Address, validReceiverAddress, "", "-20"]; + + const rowErc721WithMissingId = ["nft", testData.addresses.dummyErc721Address, validReceiverAddress, "", ""]; + + const rowErc721WithDecimalId = ["nft", testData.addresses.dummyErc721Address, validReceiverAddress, "", "69.420"]; + + const rowErc721WithInvalidToken = ["nft", "0xwhoopsie", validReceiverAddress, "", "69"]; + + const rowErc721WithInvalidReceiver = ["nft", testData.addresses.dummyErc721Address, "0xwhoopsie", "", "69"]; + + const [payment, warnings] = await CSVParser.parseCSV( + csvStringFromRows( + rowErc1155WithNegativeValue, + rowErc1155WithDecimalValue, + rowErc1155WithMissingValue, + rowErc1155WithMissingId, + rowErc1155WithInvalidTokenAddress, + rowErc1155WithInvalidReceiverAddress, + rowErc721WithNegativeId, + rowErc721WithDecimalId, + rowErc721WithMissingId, + rowErc721WithInvalidToken, + rowErc721WithInvalidReceiver, + ), + mockTokenInfoProvider, + mockCollectibleTokenInfoProvider, + mockEnsResolver, + ); + expect(warnings).to.have.lengthOf(15); + const [ + warningErc1155WithNegativeValue, + warningErc1155WithDecimalValue, + warningErc1155WithMissingValue, + warningErc1155WithMissingId, + warningErc1155WithMissingId2, + warningErc1155WithInvalidTokenAddress, + warningErc1155WithInvalidTokenAddress2, + 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(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(4); + expect(warningErc1155WithMissingId.message).to.equal("Only positive Token IDs possible: NaN"); + + expect(warningErc1155WithMissingId2.lineNo).to.equal(4); + expect(warningErc1155WithMissingId2.message).to.equal("Token IDs must be integer numbers: NaN"); + + 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"); + + expect(warningErc721WithNegativeId.lineNo).to.equal(7); + expect(warningErc721WithNegativeId.message).to.equal("Only positive Token IDs possible: -20"); + + expect(warningErc721WithDecimalId.lineNo).to.equal(8); + expect(warningErc721WithDecimalId.message).to.equal("Token IDs must be integer numbers: 69.42"); + + expect(warningErc721WithMissingId.lineNo).to.equal(9); + expect(warningErc721WithMissingId.message).to.equal("Only positive Token IDs possible: NaN"); + + expect(warningErc721WithMissingId2.lineNo).to.equal(9); + expect(warningErc721WithMissingId2.message).to.equal("Token IDs must be integer numbers: NaN"); + + expect(warningErc721WithInvalidToken.lineNo).to.equal(10); + expect(warningErc721WithInvalidToken.message).to.equal("Invalid Token Address: 0xwhoopsie"); + + expect(warningErc721WithInvalidToken2.lineNo).to.equal(10); + expect(warningErc721WithInvalidToken2.message).to.equal("No token contract was found at 0xwhoopsie"); + + expect(warningErc721WithInvalidReceiver.lineNo).to.equal(11); + 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, + mockCollectibleTokenInfoProvider, + 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/__tests__/transfers.test.ts b/src/__tests__/transfers.test.ts index 813bb6d..ac58b2c 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 { erc20Interface } from "../erc20"; import { fetchTokenList, MinimalTokenInfo } from "../hooks/token"; -import { Payment } from "../parser"; +import { AssetTransfer, CollectibleTransfer } from "../parser/csvParser"; import { testData } from "../test/util"; -import { buildTransfers } from "../transfers"; +import { erc1155Interface } from "../transfers/erc1155"; +import { erc20Interface } from "../transfers/erc20"; +import { erc721Interface } from "../transfers/erc721"; +import { buildAssetTransfers, buildCollectibleTransfers } from "../transfers/transfers"; import { toWei, fromWei, MAX_U256, TokenInfo } from "../utils"; const dummySafeInfo = testData.dummySafeInfo; @@ -24,9 +27,10 @@ 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 { + token_type: "erc20", receiver, amount: fromWei(MAX_U256, listedToken.decimals), tokenAddress: listedToken.address, @@ -36,15 +40,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, @@ -54,7 +60,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( @@ -62,7 +68,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()]), ); @@ -76,9 +82,10 @@ 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 { + token_type: "erc20", receiver, amount: tinyAmount, tokenAddress: listedToken.address, @@ -88,15 +95,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, @@ -106,7 +115,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( @@ -114,11 +123,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(), ]), ); @@ -131,9 +140,10 @@ 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 { + token_type: "erc20", receiver, amount: mixedAmount, tokenAddress: listedToken.address, @@ -143,15 +153,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, @@ -161,7 +173,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( @@ -169,11 +181,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(), ]), ); @@ -194,7 +206,8 @@ describe("Build Transfers:", () => { chainId: -1, }; - const payment: Payment = { + const payment: AssetTransfer = { + token_type: "erc20", receiver, amount: amount, tokenAddress: crappyToken.address, @@ -202,7 +215,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( @@ -210,4 +223,50 @@ 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"), + hasMetaData: false, + }, + { + 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"), + hasMetaData: false, + }, + ]; + + 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/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 92fe87d..d8766c9 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 "../parser"; +import { AssetTransfer } from "../parser/csvParser"; import { testData } from "../test/util"; import { fromWei, toWei, TEN, ONE, ZERO, transfersToSummary } from "../utils"; @@ -43,8 +43,9 @@ describe("fromWei()", () => { describe("transferToSummary()", () => { it("works for integer native currency", () => { - const transfers: Payment[] = [ + 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, @@ -74,8 +77,9 @@ describe("transferToSummary()", () => { }); it("works for decimals in native currency", () => { - const transfers: Payment[] = [ + 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, @@ -105,9 +111,10 @@ describe("transferToSummary()", () => { }); it("works for decimals in erc20", () => { - const transfers: Payment[] = [ + 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: Payment[] = [ + 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: Payment[] = [ + 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/components/CSVForm.tsx b/src/components/CSVForm.tsx deleted file mode 100644 index 34a6e5f..0000000 --- a/src/components/CSVForm.tsx +++ /dev/null @@ -1,146 +0,0 @@ -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 { ethers } from "ethers"; -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 { CSVEditor } from "./CSVEditor"; -import { CSVUpload } from "./CSVUpload"; -import { TransferTable } from "./TransferTable"; - -const Form = styled.div` - flex: 1; - flex-direction: column; - display: flex; - justify-content: space-around; - gap: 8px; -`; - -export interface CSVFormProps {} - -export const CSVForm = (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 { safe, sdk } = useSafeAppsSDK(); - const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [safe, sdk]); - const tokenInfoProvider = useTokenInfoProvider(); - const ensResolver = useEnsResolver(); - - const submitTx = useCallback(async () => { - setSubmitting(true); - try { - 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); - 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 = parseCSV(csvText, tokenInfoProvider, 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, - }, - ), - ); - } - const summary = transfersToSummary(transfers); - checkAllBalances(summary, web3Provider, safe).then((insufficientBalances) => - setMessages( - insufficientBalances.map((insufficientBalanceInfo) => ({ - message: `Insufficient Balance: ${insufficientBalanceInfo.transferAmount} of ${insufficientBalanceInfo.token}`, - severity: "warning", - })), - ), - ); - setTransferContent(transfers); - setCodeWarnings(warnings); - setParsing(false); - }) - .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); - }, 1000), - [ensResolver, safe, setCodeWarnings, setMessages, tokenInfoProvider, 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 transfer CSV
(token_address,receiver,amount) -
- - - - - - {transferContent.length > 0 && } - - {submitting ? ( - <> - -
- - - ) : ( - - )} - -
- ); -}; diff --git a/src/components/FAQModal.tsx b/src/components/FAQModal.tsx new file mode 100644 index 0000000..9aeca4b --- /dev/null +++ b/src/components/FAQModal.tsx @@ -0,0 +1,106 @@ +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 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 saves gas ⛽ and a substantial amount of time ⌚ by requiring less signatures and transactions. +

+
+ + + Preparing a Transfer File + + + Transfer files are expected to be in CSV format with the following required columns: +
    +
  • + + token_type + + : The type of token that is being transferred. One of erc20,nft or native. + NFT Tokens can be either ERC721 or ERC1155. +
  • +
  • + + 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. +
  • +
  • + + value + + : the amount of token to be transferred. This can be left blank for erc721 transfers. +
  • +
  • + + id + + : The id of the collectible token (erc721 or erc1155) to transfer. This can be left blank for native + and erc20 transfers. +
  • +
+

+ + 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/Summary.tsx b/src/components/Summary.tsx new file mode 100644 index 0000000..ab26058 --- /dev/null +++ b/src/components/Summary.tsx @@ -0,0 +1,74 @@ +import { Accordion, AccordionDetails, AccordionSummary, 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 : "no"} transfer${ + assetTxCount > 1 || assetTxCount === 0 ? "s" : "" + }`} + +
+
+
+ + + +
+ + +
+ + + Collectibles + +
+ + {`${collectibleTxCount > 0 ? collectibleTxCount : "no"} transfer${ + collectibleTxCount > 1 || collectibleTxCount === 0 ? "s" : "" + }`} + +
+
+
+ + + +
+ + ); +}; diff --git a/src/components/Token.tsx b/src/components/Token.tsx deleted file mode 100644 index a0fec87..0000000 --- a/src/components/Token.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Text } from "@gnosis.pm/safe-react-components"; -import styled from "styled-components"; - -import { useTokenList } from "../hooks/token"; - -type TokenProps = { - tokenAddress: string | null; - symbol?: string; -}; - -const Container = styled.div` - flex: 1; - flex-direction: row; - display: flex; - justify-content: start; - align-items: center; - gap: 8px; -`; - -export const Token = (props: TokenProps) => { - const { tokenAddress, symbol } = props; - const { tokenList } = useTokenList(); - return ( - - {" "} - {symbol || tokenAddress} - - ); -}; diff --git a/src/components/TransferTable.tsx b/src/components/assets/AssetTransferTable.tsx similarity index 52% rename from src/components/TransferTable.tsx rename to src/components/assets/AssetTransferTable.tsx index affbfe0..ff6f4d1 100644 --- a/src/components/TransferTable.tsx +++ b/src/components/assets/AssetTransferTable.tsx @@ -1,37 +1,39 @@ import { Table, Text } from "@gnosis.pm/safe-react-components"; import React from "react"; -import { Payment } from "../parser"; +import { AssetTransfer } from "../../parser/csvParser"; +import { Receiver } from "../Receiver"; -import { Receiver } from "./Receiver"; -import { Token } from "./Token"; +import { ERC20Token } from "./ERC20Token"; type TransferTableProps = { - transferContent: Payment[]; + transferContent: AssetTransfer[]; }; -export const TransferTable = (props: TransferTableProps) => { +export const AssetTransferTable = (props: TransferTableProps) => { const { transferContent } = props; return ( -
+
{ return { id: "" + index, cells: [ - { id: "token", content: }, + { 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 new file mode 100644 index 0000000..db3b60d --- /dev/null +++ b/src/components/assets/CSVForm.tsx @@ -0,0 +1,122 @@ +import { SafeAppProvider } from "@gnosis.pm/safe-apps-provider"; +import { useSafeAppsSDK } from "@gnosis.pm/safe-apps-react-sdk"; +import { Text } from "@gnosis.pm/safe-react-components"; +import { ethers } from "ethers"; +import debounce from "lodash.debounce"; +import React, { useContext, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { MessageContext } from "../../contexts/MessageContextProvider"; +import { useCollectibleTokenInfoProvider } from "../../hooks/collectibleTokenInfoProvider"; +import { useEnsResolver } from "../../hooks/ens"; +import { useTokenInfoProvider } from "../../hooks/token"; +import { AssetTransfer, CSVParser, Transfer } from "../../parser/csvParser"; +import { checkAllBalances, transfersToSummary } from "../../utils"; +import { CSVEditor } from "../CSVEditor"; +import { CSVUpload } from "../CSVUpload"; + +const Form = styled.div` + flex: 1; + flex-direction: column; + display: flex; + justify-content: space-around; + gap: 8px; +`; +export interface CSVFormProps { + updateTransferTable: (transfers: Transfer[]) => void; + setParsing: (parsing: boolean) => void; +} + +export const CSVForm = (props: CSVFormProps): JSX.Element => { + const { updateTransferTable, setParsing } = props; + const [csvText, setCsvText] = useState("token_type,token_address,receiver,value,id"); + + const { setCodeWarnings, setMessages } = useContext(MessageContext); + + const { safe, sdk } = useSafeAppsSDK(); + const web3Provider = useMemo(() => new ethers.providers.Web3Provider(new SafeAppProvider(safe, sdk)), [safe, sdk]); + const tokenInfoProvider = useTokenInfoProvider(); + const ensResolver = useEnsResolver(); + const erc721TokenInfoProvider = useCollectibleTokenInfoProvider(); + + const onChangeTextHandler = (csvText: string) => { + setCsvText(csvText); + parseAndValidateCSV(csvText); + }; + + const parseAndValidateCSV = useMemo( + () => + debounce((csvText: string) => { + setParsing(true); + CSVParser.parseCSV(csvText, tokenInfoProvider, erc721TokenInfoProvider, ensResolver) + .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, + }, + ), + ); + } + transfers = transfers.map((transfer, idx) => ({ ...transfer, position: idx + 1 })); + const summary = transfersToSummary( + transfers.filter( + (value) => value.token_type === "erc20" || value.token_type === "native", + ) as AssetTransfer[], + ); + updateTransferTable(transfers); + + checkAllBalances(summary, web3Provider, safe).then((insufficientBalances) => + setMessages( + insufficientBalances.map((insufficientBalanceInfo) => ({ + message: `Insufficient Balance: ${insufficientBalanceInfo.transferAmount} of ${insufficientBalanceInfo.token}`, + severity: "warning", + })), + ), + ); + setCodeWarnings(warnings); + setParsing(false); + }) + .catch((reason: any) => setMessages([{ severity: "error", message: reason.message }])); + }, 750), + [ + ensResolver, + erc721TokenInfoProvider, + safe, + setCodeWarnings, + setMessages, + setParsing, + tokenInfoProvider, + updateTransferTable, + 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_type,token_address,receiver,value,id) +
+ + + + + + ); +}; diff --git a/src/components/assets/CollectiblesTransferTable.tsx b/src/components/assets/CollectiblesTransferTable.tsx new file mode 100644 index 0000000..b99f74b --- /dev/null +++ b/src/components/assets/CollectiblesTransferTable.tsx @@ -0,0 +1,55 @@ +import { Table, Text } from "@gnosis.pm/safe-react-components"; +import React from "react"; + +import { CollectibleTransfer } from "../../parser/csvParser"; +import { Receiver } from "../Receiver"; + +import { ERC721Token } from "./ERC721Token"; + +type TransferTableProps = { + transferContent: CollectibleTransfer[]; +}; + +export const CollectiblesTransferTable = (props: TransferTableProps) => { + const { transferContent } = props; + return ( +
+
{ + return { + id: "" + index, + cells: [ + { + id: "token", + content: ( + + ), + }, + { id: "type", content: row.token_type.toUpperCase() }, + { + 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 new file mode 100644 index 0000000..f83ed46 --- /dev/null +++ b/src/components/assets/ERC20Token.tsx @@ -0,0 +1,40 @@ +import { Icon, Text } from "@gnosis.pm/safe-react-components"; +import styled from "styled-components"; + +import { useTokenList } from "../../hooks/token"; + +type TokenProps = { + tokenAddress: string | null; + symbol?: string; +}; + +const Container = styled.div` + flex: 1; + flex-direction: row; + display: flex; + justify-content: start; + align-items: center; + gap: 8px; +`; + +export const ERC20Token = (props: TokenProps) => { + const { tokenAddress, symbol } = props; + const { tokenList } = useTokenList(); + return ( + + {tokenList.get(tokenAddress) && ( + + )} + {tokenAddress === null && } + {symbol || tokenAddress} + + ); +}; diff --git a/src/components/assets/ERC721Token.tsx b/src/components/assets/ERC721Token.tsx new file mode 100644 index 0000000..198d54f --- /dev/null +++ b/src/components/assets/ERC721Token.tsx @@ -0,0 +1,100 @@ +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 { CollectibleTokenMetaInfo, useCollectibleTokenInfoProvider } from "../../hooks/collectibleTokenInfoProvider"; + +const Container = styled.div` + flex: 1; + flex-direction: row; + display: flex; + justify-content: start; + align-items: center; + gap: 8px; +`; + +type TokenProps = { + tokenAddress: string; + id: BigNumber; + token_type: "erc721" | "erc1155"; + hasMetaData: boolean; +}; + +export const ERC721Token = (props: TokenProps) => { + 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 ( + + {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 0000000..dfb4e5d --- /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/token.ts b/src/hooks/token.ts index 69d96c6..ad09a7d 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 40a5b03..0000000 --- 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/parser/csvParser.ts b/src/parser/csvParser.ts new file mode 100644 index 0000000..84bbe13 --- /dev/null +++ b/src/parser/csvParser.ts @@ -0,0 +1,93 @@ +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 { 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; + position?: number; +} + +export interface CollectibleTransfer { + token_type: CollectibleTokenType; + from: string; + receiver: string; + tokenAddress: string; + tokenName?: string; + tokenId: BigNumber; + value?: BigNumber; + receiverEnsName: string | null; + hasMetaData: boolean; +} + +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: CollectibleTokenInfoProvider, + 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 0000000..aecb109 --- /dev/null +++ b/src/parser/transformation.ts @@ -0,0 +1,210 @@ +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 { TokenInfoProvider } from "../hooks/token"; + +import { AssetTransfer, CollectibleTransfer, CSVRow, Transfer, UnknownTransfer } from "./csvParser"; + +interface PrePayment { + receiver: string; + amount: BigNumber; + tokenAddress: string | null; + tokenType: "erc20" | "native"; +} + +interface PreCollectibleTransfer { + receiver: string; + tokenId: BigNumber; + tokenAddress: string; + tokenType: "nft"; + value?: BigNumber; +} + +export const transform = ( + row: CSVRow, + tokenInfoProvider: TokenInfoProvider, + erc721InfoProvider: CollectibleTokenInfoProvider, + ensResolver: EnsResolver, + callback: RowTransformCallback, +): void => { + switch (row.token_type.toLowerCase()) { + case "erc20": + transformAsset({ ...row, token_type: "erc20" }, tokenInfoProvider, ensResolver, callback); + break; + case "native": + transformAsset({ ...row, token_type: "native" }, tokenInfoProvider, ensResolver, callback); + break; + case "nft": + transformCollectible({ ...row, token_type: "nft" }, erc721InfoProvider, ensResolver, callback); + break; + default: + callback(null, { token_type: "unknown" }); + break; + } +}; + +export const transformAsset = ( + row: Omit & { token_type: "erc20" | "native" }, + 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, + }; + + 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: Omit & { token_type: "nft" }, + erc721InfoProvider: CollectibleTokenInfoProvider, + 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, + value: new BigNumber(row.value ?? ""), + }; + + toCollectibleTransfer(prePayment, erc721InfoProvider, ensResolver) + .then((row) => callback(null, row)) + .catch((reason) => callback(reason)); +}; + +const toCollectibleTransfer = async ( + preCollectible: PreCollectibleTransfer, + collectibleTokenInfoProvider: CollectibleTokenInfoProvider, + ensResolver: EnsResolver, +): Promise => { + const fromAddress = collectibleTokenInfoProvider.getFromAddress(); + + let [resolvedReceiverAddress, receiverEnsName] = utils.isAddress(preCollectible.receiver) + ? [preCollectible.receiver, null] + : [ + (await ensResolver.isEnsEnabled()) ? await ensResolver.resolveName(preCollectible.receiver) : null, + preCollectible.receiver, + ]; + resolvedReceiverAddress = resolvedReceiverAddress !== null ? resolvedReceiverAddress : preCollectible.receiver; + + 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 : preCollectible.receiver, + tokenId: preCollectible.tokenId, + tokenAddress: preCollectible.tokenAddress, + receiverEnsName, + 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, + }; + } +}; + +/** + * 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 0000000..2778925 --- /dev/null +++ b/src/parser/validation.ts @@ -0,0 +1,75 @@ +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), + ...isTokenValueValid(row), + ...isTokenValueInteger(row), + ...isTokenIdInteger(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 Token IDs possible: ${row.tokenId.toFixed()}`]; + +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)) + ? [] + : [`ERC1155 Tokens need a defined value > 0: ${row.value?.toFixed()}`]; diff --git a/src/test/util.ts b/src/test/util.ts index 8bc2b07..e7b37c4 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -1,5 +1,6 @@ import { SafeInfo } from "@gnosis.pm/safe-apps-sdk"; +import { CollectibleTokenInfo } from "../hooks/collectibleTokenInfoProvider"; 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,30 @@ const unlistedToken: TokenInfo = { chainId: -1, }; +const dummyERC721Token: CollectibleTokenInfo = { + token_type: "erc721", + address: "0x5500000000000000000000000000000000000000", + hasMetaInfo: false, +}; + +const dummyERC1155Token: CollectibleTokenInfo = { + token_type: "erc1155", + address: "0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656", + hasMetaInfo: false, +}; + const addresses = { receiver1: "0x1000000000000000000000000000000000000000", receiver2: "0x2000000000000000000000000000000000000000", receiver3: "0x3000000000000000000000000000000000000000", + dummyErc721Address: "0x5500000000000000000000000000000000000000", + dummyErc1155Address: "0x88b48f654c30e99bc2e4a1559b4dcf1ad93fa656", }; export const testData = { dummySafeInfo, - unlistedToken, + unlistedERC20Token, addresses, + dummyERC721Token, + dummyERC1155Token, }; diff --git a/src/transfers.ts b/src/transfers.ts deleted file mode 100644 index 40e8268..0000000 --- a/src/transfers.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseTransaction } from "@gnosis.pm/safe-apps-sdk"; - -import { erc20Interface } from "./erc20"; -import { Payment } from "./parser"; -import { toWei } from "./utils"; - -export function buildTransfers(transferData: Payment[]): BaseTransaction[] { - const txList: BaseTransaction[] = transferData.map((transfer) => { - if (transfer.tokenAddress === null) { - // Native asset transfer - return { - to: transfer.receiver, - value: toWei(transfer.amount, 18).toFixed(), - data: "0x", - }; - } else { - // ERC20 transfer - const decimals = transfer.decimals; - const amountData = toWei(transfer.amount, decimals); - return { - to: transfer.tokenAddress, - value: "0", - data: erc20Interface.encodeFunctionData("transfer", [transfer.receiver, amountData.toFixed()]), - }; - } - }); - return txList; -} diff --git a/src/transfers/erc1155.ts b/src/transfers/erc1155.ts new file mode 100644 index 0000000..7479683 --- /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/erc165.ts b/src/transfers/erc165.ts new file mode 100644 index 0000000..61c3b9a --- /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); +} 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 655b4f4..617c630 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 0000000..6172211 --- /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/transfers.ts b/src/transfers/transfers.ts new file mode 100644 index 0000000..002179e --- /dev/null +++ b/src/transfers/transfers.ts @@ -0,0 +1,61 @@ +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"; + +export function buildAssetTransfers(transferData: AssetTransfer[]): BaseTransaction[] { + const txList: BaseTransaction[] = transferData.map((transfer) => { + if (transfer.tokenAddress === null) { + // Native asset transfer + return { + to: transfer.receiver, + value: toWei(transfer.amount, 18).toFixed(), + data: "0x", + }; + } else { + // ERC20 transfer + const decimals = transfer.decimals; + const amountData = toWei(transfer.amount, decimals); + return { + to: transfer.tokenAddress, + value: "0", + data: erc20Interface.encodeFunctionData("transfer", [transfer.receiver, amountData.toFixed()]), + }; + } + }); + return txList; +} + +export function buildCollectibleTransfers(transferData: CollectibleTransfer[]): BaseTransaction[] { + const txList: BaseTransaction[] = transferData.map((transfer) => { + 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/src/utils.ts b/src/utils.ts index d7b209f..8334bf0 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 { AssetTransfer } from "./parser/csvParser"; +import { erc20Instance } from "./transfers/erc20"; export const ZERO = new BigNumber(0); export const ONE = new BigNumber(1); @@ -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") { diff --git a/test_data/rinkeby-mixed.csv b/test_data/rinkeby-mixed.csv new file mode 100644 index 0000000..fe00d88 --- /dev/null +++ b/test_data/rinkeby-mixed.csv @@ -0,0 +1,5 @@ +token_type,token_address,receiver,value,id +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