diff --git a/sample-dapps/ethereum-address-appearances/.env.example b/sample-dapps/ethereum-address-appearances/.env.example new file mode 100644 index 0000000..0609644 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/.env.example @@ -0,0 +1 @@ +VITE_QUICKNODE_ENDPOINT = "YOUR_QUICKNODE_ETHEREUM_ENDPOINT_URL" \ No newline at end of file diff --git a/sample-dapps/ethereum-address-appearances/.eslintrc.cjs b/sample-dapps/ethereum-address-appearances/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/sample-dapps/ethereum-address-appearances/.gitignore b/sample-dapps/ethereum-address-appearances/.gitignore new file mode 100644 index 0000000..50c8dda --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env diff --git a/sample-dapps/ethereum-address-appearances/README.md b/sample-dapps/ethereum-address-appearances/README.md new file mode 100644 index 0000000..fb39aa4 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/README.md @@ -0,0 +1,97 @@ +# Ethereum Address Appearances Application + +## Introduction + +This application is designed to fetch and analyze Ethereum transactions associated with a specific address, leveraging the capabilities of QuickNode's [Address Appearances API](https://marketplace.quicknode.com/add-on/address-appearances-api). The primary focus of this application is to provide users with transaction appearances using the **Address Appearances API**. However, if you would like to compare these results with Etherscan's API, you can provide an Etherscan API key to enable the comparison feature. + +For an in-depth guide on how to fetch data and develop further functionalities, refer to [our comprehensive guide on QuickNode](https://www.quicknode.com/guides/quicknode-products/marketplace/improve-your-ethereum-audits-with-address-appearances-api). + +### Tech Stack +- Frontend Framework/Library: React +- Language: TypeScript +- Build Tool/Development Server: Vite +- Styling: Tailwind CSS + +## Features + +- **Transaction Appearances**: Provides detailed transaction appearances using QuickNode's Address Appearances API. +- **Optional Transaction Comparison**: Enables comparison with Etherscan API if an Etherscan API key is provided. + +## Getting Started + +### Prerequisites + +Before you begin, ensure you have the following: +- [Node.js](https://nodejs.org/en/) installed on your system. +- A QuickNode account with the [Address Appearances API](https://marketplace.quicknode.com/add-on/address-appearances-api) enabled. +- A code editor or an IDE (e.g., [VS Code](https://code.visualstudio.com/)) +- [TypeScript](https://www.typescriptlang.org/) and [ts-node](https://typestrong.org/ts-node/) + +> You can run the commands below to install TypeScript and ts-node globally to have TypeScript available across all projects. + +```bash +npm install -g typescript ts-node +``` + +### Installation Dependencies + +1. Clone the repository to your local machine: +```bash +git clone https://github.com/quiknode-labs/qn-guide-examples.git +``` + +2. Navigate to the project directory: +```bash +cd sample-dapps/ethereum-address-appearances +``` + +3. Install the necessary dependencies: +```bash +npm install +``` + +### Setting Environment Variables + +Rename `.env.example` to `.env` and replace the `YOUR_QUICKNODE_ETHEREUM_ENDPOINT_URL` placeholder with your QuickNode Ethereum Node Endpoint. Make sure that the [Address Appearances API](https://marketplace.quicknode.com/add-on/address-appearances-api) is enabled. + +```env +VITE_QUICKNODE_ENDPOINT = "YOUR_QUICKNODE_ETHEREUM_ENDPOINT_URL" +``` + +If you provide an Etherscan API key in the .env file like the one below, the app displays appearance results from both sources for a specified address. + +```env +VITE_QUICKNODE_ENDPOINT="YOUR_QUICKNODE_ETHEREUM_ENDPOINT_URL" +VITE_ETHERSCAN_API_KEY="YOUR_ETHERSCAN_API_KEY" +``` + +> Please note that while we utilize `dotenv` for environment variable management, sensitive information like endpoints can still be visible on the frontend. This configuration is not recommended for production environments as-is. + +### Running the Application + +Run the development server: + +```bash +npm run dev +``` + +Open [http://localhost:5173/](http://localhost:5173/) with your browser to see the application. + +## Using the App +1. Input an Ethereum address. +2. Press Generate. +3. View the transaction appearances. + +The **Ethereum Address Appearances Application** will query the Ethereum blockchain for the address's transactions, fetch the data using QuickNode's Address Appearances API, and display the results. + +![Results with Address Appearances API](public/results-address-appearances.png) + +If an Etherscan API key is provided, the app will display the comparison of transactions found by QuickNode's Address Appearances API and Etherscan API. + +![Results with both API](public/image.png) + +## Conclusion + +QuickNode's [Address Appearances API](https://marketplace.quicknode.com/add-on/address-appearances-api) excels in identifying more transaction appearances compared to other sources. This enhanced capability provides developers and businesses with more comprehensive and accurate transaction data. By leveraging this API, users can gain deeper insights into blockchain interactions. + +Whether for audit purposes, regulatory compliance, or market analysis, QuickNode's Address Appearances API ensures you have the most detailed and accurate transaction data available. To discover more about how QuickNode assists companies and individuals in extracting comprehensive blockchain data, please [contact us](https://www.quicknode.com/contact-us); we're eager to engage with you! \ No newline at end of file diff --git a/sample-dapps/ethereum-address-appearances/index.html b/sample-dapps/ethereum-address-appearances/index.html new file mode 100644 index 0000000..f23ad97 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/index.html @@ -0,0 +1,13 @@ + + + + + + + Ethereum Transactions Index Comparison by QuickNode + + +
+ + + diff --git a/sample-dapps/ethereum-address-appearances/package.json b/sample-dapps/ethereum-address-appearances/package.json new file mode 100644 index 0000000..e418c11 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/package.json @@ -0,0 +1,35 @@ +{ + "name": "ethereum-address-appearances", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@quicknode/sdk": "^2.2.2", + "axios": "^1.6.8", + "chart.js": "^4.4.2", + "dotenv": "^16.4.5", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} diff --git a/sample-dapps/ethereum-address-appearances/postcss.config.js b/sample-dapps/ethereum-address-appearances/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/sample-dapps/ethereum-address-appearances/public/image-1.png b/sample-dapps/ethereum-address-appearances/public/image-1.png new file mode 100644 index 0000000..d589ff8 Binary files /dev/null and b/sample-dapps/ethereum-address-appearances/public/image-1.png differ diff --git a/sample-dapps/ethereum-address-appearances/public/image.png b/sample-dapps/ethereum-address-appearances/public/image.png new file mode 100644 index 0000000..be5f235 Binary files /dev/null and b/sample-dapps/ethereum-address-appearances/public/image.png differ diff --git a/sample-dapps/ethereum-address-appearances/public/results-address-appearances.png b/sample-dapps/ethereum-address-appearances/public/results-address-appearances.png new file mode 100644 index 0000000..95076a4 Binary files /dev/null and b/sample-dapps/ethereum-address-appearances/public/results-address-appearances.png differ diff --git a/sample-dapps/ethereum-address-appearances/public/vite.svg b/sample-dapps/ethereum-address-appearances/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sample-dapps/ethereum-address-appearances/src/App.css b/sample-dapps/ethereum-address-appearances/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/sample-dapps/ethereum-address-appearances/src/App.tsx b/sample-dapps/ethereum-address-appearances/src/App.tsx new file mode 100644 index 0000000..b76a653 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/App.tsx @@ -0,0 +1,100 @@ +import React, { useState, useEffect } from "react"; +import Header from "./components/Header"; +import Footer from "./components/Footer"; +import AddressInputForm from "./components/AddressInputForm"; +import ComparisonTable from "./components/ComparisonTable"; +import TransactionSummary from "./components/TransactionSummary"; +import AddressAppearancesResults from "./components/AddressAppearancesResults"; // New component for Address Appearances results + +import compareData from "./helpers/compareData"; +import fetchTransactions from "./helpers/fetchData"; +import { + Appearance, + SimplifiedEtherscanTransaction, + CombinedTransactionData, +} from "./interfaces"; + +const ETHERSCAN_API_KEY = import.meta.env.VITE_ETHERSCAN_API_KEY || undefined; + +const App: React.FC = () => { + const [address, setAddress] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const [customData, setCustomData] = useState([]); + const [etherscanData, setEtherscanData] = useState<{ + [key: string]: SimplifiedEtherscanTransaction[]; + }>({}); + const [comparisonTable, setComparisonTable] = useState< + CombinedTransactionData[] + >([]); + const [loading, setLoading] = useState(false); + const [customTotal, setCustomTotal] = useState(0); + const [etherscanTotals, setEtherscanTotals] = useState<{ + [key: string]: number; + }>({}); + + const handleFormSubmit = async (address: string) => { + try { + setLoading(true); + setCustomData([]); + setEtherscanData({}); + setErrorMsg(""); + + const { customMethodData, esData, customTotal, etherscanTotals } = + await fetchTransactions(address); + + setCustomData(customMethodData); + setEtherscanData(esData); + setCustomTotal(customTotal); + setEtherscanTotals(etherscanTotals); + } catch (error) { + console.error("Error getting data:", error); + setErrorMsg("Error getting data"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (customData.length > 0) { + if (ETHERSCAN_API_KEY && Object.keys(etherscanData).length > 0) { + const comparisonResult = compareData(customData, etherscanData); + setComparisonTable(comparisonResult); + } else { + setComparisonTable([]); + } + } + }, [customData, etherscanData]); + + return ( +
+
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {ETHERSCAN_API_KEY && comparisonTable.length > 0 && customTotal > 0 && ( +
+ + +
+ )} + {!ETHERSCAN_API_KEY && customData.length > 0 && ( + + )} +
+
+ ); +}; + +export default App; diff --git a/sample-dapps/ethereum-address-appearances/src/assets/react.svg b/sample-dapps/ethereum-address-appearances/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sample-dapps/ethereum-address-appearances/src/components/AddressAppearancesResults.tsx b/sample-dapps/ethereum-address-appearances/src/components/AddressAppearancesResults.tsx new file mode 100644 index 0000000..b036ce3 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/components/AddressAppearancesResults.tsx @@ -0,0 +1,47 @@ +// src/components/AddressAppearancesResults.tsx +import React from "react"; +import { Appearance } from "../interfaces"; + +interface AddressAppearancesResultsProps { + data: Appearance[]; +} + +const AddressAppearancesResults: React.FC = ({ + data, +}) => { + return ( +
+

Address Appearances Results

+ + + + + + + + + {data.map((appearance, index) => ( + + + + + ))} + +
Block NumberTransaction Index
+ {" "} + + {appearance.blockNumber} + + + {appearance.transactionIndex} +
+
+ ); +}; + +export default AddressAppearancesResults; diff --git a/sample-dapps/ethereum-address-appearances/src/components/AddressInputForm.tsx b/sample-dapps/ethereum-address-appearances/src/components/AddressInputForm.tsx new file mode 100644 index 0000000..db2de5c --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/components/AddressInputForm.tsx @@ -0,0 +1,61 @@ +import React, { useState } from "react"; +import { viem } from "@quicknode/sdk"; + +interface AddressInputFormProps { + onSubmit: (address: string) => void; + setAddress: (address: string) => void; + isLoading: boolean; +} + +const AddressInputForm: React.FC = ({ + onSubmit, + setAddress, + isLoading, +}) => { + const [addressInput, setAddressInput] = useState(""); + const [isValidAddress, setIsValidAddress] = useState(false); + + /* eslint-disable @typescript-eslint/no-explicit-any */ + const handleAddressChange = (e: any) => { + const inputAddress = e.target.value; + setAddressInput(inputAddress); + setIsValidAddress(viem.isAddress(inputAddress)); + setAddress(inputAddress); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(addressInput); + }; + + return ( +
+
+ + +
+ +
+ ); +}; + +export default AddressInputForm; diff --git a/sample-dapps/ethereum-address-appearances/src/components/ComparisonTable.tsx b/sample-dapps/ethereum-address-appearances/src/components/ComparisonTable.tsx new file mode 100644 index 0000000..a6ca044 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/components/ComparisonTable.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { CombinedTransactionData } from "../interfaces"; + + +interface ComparisonTableProps { + data: CombinedTransactionData[]; +} + +const txTypeMap: { [key: string]: string } = { + txlist: "Normal Tx", + txlistinternal: "Internal Tx", + tokentx: "ERC20 Transfer", + tokennfttx: "ERC721 Transfer", + token1155tx: "ERC1155 Transfer", +}; + +const ComparisonTable: React.FC = ({ data }) => { + return ( +
+ + + + + + + + + + + + + + + + {data.map((row, index) => ( + + + + + + + + ))} + +
+ TrueBlocks Data + + Etherscan Data +
+ Block Number + + Tx Index + + Block Number + + Tx Index + + Type +
+ + {row.customBlockNumber} + + + {row.customTxIndex} + + + {row.etherscanBlockNumber} + + + {row.etherscanTxIndex} + + {txTypeMap[row.type] || row.type} +
+
+ ); +}; + +export default ComparisonTable; diff --git a/sample-dapps/ethereum-address-appearances/src/components/Footer.tsx b/sample-dapps/ethereum-address-appearances/src/components/Footer.tsx new file mode 100644 index 0000000..b35c413 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/components/Footer.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +const Footer: React.FC = () => { + return ( + + ); +}; + +export default Footer; diff --git a/sample-dapps/ethereum-address-appearances/src/components/Header.tsx b/sample-dapps/ethereum-address-appearances/src/components/Header.tsx new file mode 100644 index 0000000..c9c6b4f --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/components/Header.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +const Header: React.FC = () => { + return ( +
+ Ethereum Transactions Index Comparison by{" "} + + QuickNode + +
+ ); +}; + +export default Header; diff --git a/sample-dapps/ethereum-address-appearances/src/components/TransactionSummary.tsx b/sample-dapps/ethereum-address-appearances/src/components/TransactionSummary.tsx new file mode 100644 index 0000000..8cba78f --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/components/TransactionSummary.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +interface TransactionSummaryProps { + address: string; + customTotal: number; + etherscanTotals: { [key: string]: number }; +} + +const TransactionSummary: React.FC = ({ + address, + customTotal, + etherscanTotals, +}) => { + return ( +
+

+ Results for{" "} + + {address} + +

+

Transaction Totals

+ + + + + + + + + + + + + + +
+ TrueBlocks Data + + Etherscan Data +
+ Total Transaction: {customTotal} + + Total Transaction: {etherscanTotals.overall} +
    +
  • - Normal: {etherscanTotals.txlist}
  • +
  • - Internal: {etherscanTotals.txlistinternal}
  • +
  • - ERC20: {etherscanTotals.tokentx}
  • +
  • - ERC721: {etherscanTotals.tokennfttx}
  • +
  • - ERC1155: {etherscanTotals.token1155tx}
  • +
+
+
+ ); +}; + +export default TransactionSummary; diff --git a/sample-dapps/ethereum-address-appearances/src/helpers/compareData.ts b/sample-dapps/ethereum-address-appearances/src/helpers/compareData.ts new file mode 100644 index 0000000..208e048 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/helpers/compareData.ts @@ -0,0 +1,84 @@ +import { + Appearance, + SimplifiedEtherscanTransaction, + CombinedTransactionData, +} from "../interfaces"; + +const compareData = ( + customData: Appearance[], + etherscanData: { [key: string]: SimplifiedEtherscanTransaction[] } +) => { + const combinedData: CombinedTransactionData[] = []; + + // Create a map to group Etherscan data by block number and transaction index + const etherscanMap = new Map< + string, + (SimplifiedEtherscanTransaction & { type: string })[] + >(); + for (const [type, transactions] of Object.entries(etherscanData)) { + for (const tx of transactions) { + const key = `${tx.blockNumber}-${tx.transactionIndex ?? "N/A"}`; + if (!etherscanMap.has(key)) { + etherscanMap.set(key, []); + } + etherscanMap.get(key)!.push({ ...tx, type }); + } + } + + // Iterate over custom data and add corresponding Etherscan data if available + for (const customTx of customData) { + const key = `${customTx.blockNumber}-${customTx.transactionIndex}`; + const etherscanTxs = etherscanMap.get(key) || []; + if (etherscanTxs.length === 0) { + combinedData.push({ + customBlockNumber: customTx.blockNumber, + customTxIndex: customTx.transactionIndex, + etherscanBlockNumber: "", + etherscanTxIndex: "", + type: "", + }); + } else { + for (const etherscanTx of etherscanTxs) { + combinedData.push({ + customBlockNumber: customTx.blockNumber, + customTxIndex: customTx.transactionIndex, + etherscanBlockNumber: etherscanTx.blockNumber, + etherscanTxIndex: etherscanTx.transactionIndex, + type: etherscanTx.type, + }); + } + } + } + + // Add remaining Etherscan transactions not matched with custom data + for (const [key, transactions] of etherscanMap) { + for (const tx of transactions) { + const [blockNumber, transactionIndex] = key.split("-"); + const existsInCustomData = customData.some( + (customTx) => + customTx.blockNumber === blockNumber && + customTx.transactionIndex === transactionIndex + ); + if (!existsInCustomData) { + combinedData.push({ + customBlockNumber: "", + customTxIndex: "", + etherscanBlockNumber: tx.blockNumber, + etherscanTxIndex: tx.transactionIndex, + type: tx.type, + }); + } + } + } + + // Sort filteredData by block number in descending order + combinedData.sort( + (a, b) => + Number(b.customBlockNumber || b.etherscanBlockNumber) - + Number(a.customBlockNumber || a.etherscanBlockNumber) + ); + + return combinedData; +}; + +export default compareData; diff --git a/sample-dapps/ethereum-address-appearances/src/helpers/fetchData.ts b/sample-dapps/ethereum-address-appearances/src/helpers/fetchData.ts new file mode 100644 index 0000000..5ab979f --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/helpers/fetchData.ts @@ -0,0 +1,165 @@ +import axios from "axios"; +import { + ApiRoot, + Appearance, + SimplifiedEtherscanTransaction, +} from "../interfaces"; + +const ETHERSCAN_API_KEY = import.meta.env.VITE_ETHERSCAN_API_KEY || undefined; +const RATE_LIMIT_DELAY = 300; +const QUICKNODE_ENDPOINT = import.meta.env.VITE_QUICKNODE_ENDPOINT as string; + +const fetchTransactions = async (address: string) => { + const customMethodData = await fetchCustomMethodData(address); + + let esData = {}; + + if (ETHERSCAN_API_KEY) { + esData = await fetchEtherscanData(address); + esData = filterDuplicates(esData); + } + + const customTotal = customMethodData.length; + const etherscanTotals = ETHERSCAN_API_KEY + ? calculateEtherscanTotals(esData) + : {}; + + return { + customMethodData, + esData, + customTotal, + etherscanTotals, + }; +}; + +const fetchCustomMethodData = async ( + address: string +): Promise => { + const results: Appearance[] = []; + let previousPageId: string | null = null; + + do { + const response: ApiRoot = await axios.post(QUICKNODE_ENDPOINT, { + jsonrpc: "2.0", + method: "tb_getAppearances", + params: [{ address, perPage: 1000, pageId: previousPageId }], + id: 1, + }); + + if (response.data) { + const { data, meta } = response.data.result; + + results.push(...data); + + previousPageId = meta.previousPageId; + } else { + throw new Error("Failed to fetch transactions"); + } + } while (previousPageId); + + return results; +}; + +const fetchEtherscanData = async (address: string) => { + const actions = [ + "txlist", + "txlistinternal", + "tokentx", + "tokennfttx", + "token1155tx", + ]; + const results: { [key: string]: SimplifiedEtherscanTransaction[] } = {}; + + for (const action of actions) { + results[action] = await fetchEtherscanTransactions(address, action); + } + + return results; +}; + +const fetchEtherscanTransactions = async (address: string, action: string) => { + const results: SimplifiedEtherscanTransaction[] = []; + + // Etherscan endpoint returns a maximum of 10000 records only + // TO-DO: Go around this limit by getting endBlock of first request as startBlock + + const response = await axios.get("https://api.etherscan.io/api", { + params: { + module: "account", + action, + address, + startblock: 0, + endblock: 99999999, + page: 1, + offset: 10000, + sort: "asc", + apikey: ETHERSCAN_API_KEY, + }, + }); + + const { result } = response.data; + results.push( + ...result.map((tx: SimplifiedEtherscanTransaction) => ({ + blockNumber: tx.blockNumber, + hash: tx.hash, + transactionIndex: tx.transactionIndex || undefined, + gas: tx.gas, + })) + ); + + await sleep(RATE_LIMIT_DELAY); + + return results; +}; + +const filterDuplicates = (etherscanData: { + [key: string]: SimplifiedEtherscanTransaction[]; +}) => { + const allTransactions: SimplifiedEtherscanTransaction[] = []; + for (const transactions of Object.values(etherscanData)) { + allTransactions.push(...transactions); + } + + const uniqueTransactions = new Map(); + + allTransactions.forEach((tx) => { + const key = `${tx.blockNumber}-${tx.hash}`; + if (!uniqueTransactions.has(key)) { + uniqueTransactions.set(key, tx); + } + }); + + // Reconstruct the etherscanData object with unique transactions + const filteredData: { [key: string]: SimplifiedEtherscanTransaction[] } = {}; + for (const [action, transactions] of Object.entries(etherscanData)) { + filteredData[action] = transactions.filter((tx) => { + const key = `${tx.blockNumber}-${tx.hash}`; + if (uniqueTransactions.has(key)) { + uniqueTransactions.delete(key); + return true; + } + return false; + }); + } + + return filteredData; +}; + +const calculateEtherscanTotals = (etherscanData: { + [key: string]: SimplifiedEtherscanTransaction[]; +}) => { + const totals: { [key: string]: number } = {}; + let overallTotal = 0; + + for (const [type, transactions] of Object.entries(etherscanData)) { + totals[type] = transactions.length; + overallTotal += transactions.length; + } + + totals["overall"] = overallTotal; + return totals; +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export default fetchTransactions; diff --git a/sample-dapps/ethereum-address-appearances/src/index.css b/sample-dapps/ethereum-address-appearances/src/index.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/sample-dapps/ethereum-address-appearances/src/interfaces.ts b/sample-dapps/ethereum-address-appearances/src/interfaces.ts new file mode 100644 index 0000000..177587e --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/interfaces.ts @@ -0,0 +1,67 @@ +export interface Appearance { + blockNumber: string; + transactionIndex: string; +} + +export interface Meta { + lastIndexedBlock: string; + address: string; + previousPageId: string | null; + nextPageId: string | null; +} + +export interface ApiRoot { + data: { result: ApiResponse }; +} + +export interface ApiResponse { + data: Appearance[]; + meta: { + lastIndexedBlock: string; + address: string; + previousPageId: string | null; + nextPageId: string | null; + }; +} + +export interface SimplifiedEtherscanTransaction { + blockNumber: string; + hash: string; + gas: string; + transactionIndex?: string; +} + +export interface EtherscanTransaction { + blockNumber: string; + timeStamp: string; + hash: string; + from: string; + to: string; + value: string; + gas: string; + gasPrice: string; + isError: string; + txreceipt_status: string; + input: string; + contractAddress: string; + cumulativeGasUsed: string; + gasUsed: string; + confirmations: string; +} + +export interface EtherscanResponse { + status: string; + message: string; + result: EtherscanTransaction[]; +} + +export interface CombinedTransactionData { + customBlockNumber: string; + customTxIndex: string; + etherscanBlockNumber: string; + etherscanTxIndex: string; +// etherscanHash: string, + type: string; + gas?: string; + isDuplicate?: boolean; +} diff --git a/sample-dapps/ethereum-address-appearances/src/main.tsx b/sample-dapps/ethereum-address-appearances/src/main.tsx new file mode 100644 index 0000000..3d7150d --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/sample-dapps/ethereum-address-appearances/src/vite-env.d.ts b/sample-dapps/ethereum-address-appearances/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/sample-dapps/ethereum-address-appearances/tailwind.config.js b/sample-dapps/ethereum-address-appearances/tailwind.config.js new file mode 100644 index 0000000..e801255 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/sample-dapps/ethereum-address-appearances/tsconfig.json b/sample-dapps/ethereum-address-appearances/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/sample-dapps/ethereum-address-appearances/tsconfig.node.json b/sample-dapps/ethereum-address-appearances/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/sample-dapps/ethereum-address-appearances/vite.config.ts b/sample-dapps/ethereum-address-appearances/vite.config.ts new file mode 100644 index 0000000..5a33944 --- /dev/null +++ b/sample-dapps/ethereum-address-appearances/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +})