From d8860b811bc66ac0bc3ffe73cfb9d006acec2121 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Wed, 31 Jan 2024 19:07:51 +0800 Subject: [PATCH 1/8] update html for keywords --- public/index.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/public/index.html b/public/index.html index 6622b7f..17eae01 100644 --- a/public/index.html +++ b/public/index.html @@ -7,7 +7,11 @@ + - One Click DApp + One Click DApp | a tool to instantly build a dApp From adf41af0d237defb0281d03fa5cddc6f825eede0 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Thu, 22 May 2025 00:31:53 +0800 Subject: [PATCH 2/8] fix: font issue --- src/components/AppDetail.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AppDetail.jsx b/src/components/AppDetail.jsx index 9bce2d0..fa12750 100644 --- a/src/components/AppDetail.jsx +++ b/src/components/AppDetail.jsx @@ -67,7 +67,7 @@ const Title = styled.h1` font-size: 18px; text-align: center; // color: palevioletred; - text-weight: bold; + font-weight: bold; `; From 1e8144fc2cd372f533fbb9b0c2d4b360a779f046 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Thu, 22 May 2025 01:18:06 +0800 Subject: [PATCH 3/8] fix: update to new ui --- src/components/AppCreate.jsx | 519 ++++++++++++++++++++++++++++------- src/components/AppMethod.jsx | 519 ++++++++++++++++++++++++++++++----- src/components/Header.jsx | 301 ++++++++++++++++++-- 3 files changed, 1137 insertions(+), 202 deletions(-) diff --git a/src/components/AppCreate.jsx b/src/components/AppCreate.jsx index 1b36f1b..f2fb261 100644 --- a/src/components/AppCreate.jsx +++ b/src/components/AppCreate.jsx @@ -1,13 +1,13 @@ import styled from 'styled-components'; -import { Input, Button, notification } from 'antd'; - +import { Input, Button, notification, Alert, Typography, Form, Card, Tooltip } from 'antd'; +import { InfoCircleOutlined, CheckCircleOutlined } from '@ant-design/icons'; import { useEffect, useMemo, useState } from 'react'; - import { useDappContext } from '../store/contextProvider'; - import { useNavigate } from 'react-router-dom'; import { ethers } from 'ethers'; +const { Text, Title: AntTitle } = Typography; + const WD = styled.div` padding: 40px 5%; @@ -15,36 +15,105 @@ const WD = styled.div` flex-grow: 1; box-shadow: 0 0 5px #e5e5e5; border-radius: 10px; + max-width: 800px; + margin: 0 auto; `; const Title = styled.h1` - font-size: 18px; + font-size: 24px; text-align: center; - text-weight: bold; + font-weight: bold; + margin-bottom: 30px; +`; + +const FormContainer = styled.div` + margin-bottom: 30px; +`; + +const FormItem = styled(Form.Item)` + margin-bottom: 24px; `; -const List = styled.ul` -li { - margin-bottom: 20px; - &>div { - padding-bottom: 5px; +const FieldLabel = styled.div` + font-weight: 500; + margin-bottom: 8px; + display: flex; + align-items: center; + + .required-mark { + color: #ff4d4f; + margin-left: 4px; + } + + .info-icon { + margin-left: 8px; + color: #1890ff; + } +`; + +const FieldDescription = styled.div` + color: #888; + font-size: 12px; + margin-bottom: 8px; +`; + +const ValidationStatus = styled.div` + display: flex; + align-items: center; + margin-top: 4px; + + &.valid { + color: #52c41a; + } + + &.invalid { + color: #ff4d4f; } -} -` +`; + +const ButtonContainer = styled.div` + display: flex; + justify-content: center; + margin-top: 20px; +`; export default function AppCreate() { const navigate = useNavigate(); + const [form] = Form.useForm(); const [appName, setAppName] = useState(''); const [appDesc, setAppDesc] = useState(''); const [appAbi, setAppAbi] = useState(''); const [contractAddress, setContractAddress] = useState(''); const [networkName, setNetworkName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [abiValidation, setAbiValidation] = useState({ + isValid: false, + message: '', + status: '' + }); + const [addressValidation, setAddressValidation] = useState({ + isValid: false, + message: '', + status: '' + }); const { dispatch, state: { account } } = useDappContext(); + // Check for MetaMask on component mount useEffect(() => { - }, []) + const checkMetaMask = async () => { + if (typeof window.ethereum === 'undefined') { + notification.warning({ + message: 'MetaMask Not Detected', + description: 'MetaMask is not installed. Some features may not work properly.', + duration: 10, + }); + } + }; + + checkMetaMask(); + }, []); const onNameChange = (e) => { @@ -56,11 +125,82 @@ export default function AppCreate() { } const onAbiChange = (e) => { - setAppAbi(e.target.value); + const value = e.target.value; + setAppAbi(value); + + // Validate ABI JSON format + if (!value) { + setAbiValidation({ + isValid: false, + message: '', + status: '' + }); + return; + } + + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + setAbiValidation({ + isValid: false, + message: 'ABI must be a JSON array', + status: 'error' + }); + return; + } + + // Check if it contains function definitions + const hasFunctions = parsed.some(item => item.type === 'function'); + if (!hasFunctions) { + setAbiValidation({ + isValid: false, + message: 'ABI must contain at least one function definition', + status: 'warning' + }); + return; + } + + setAbiValidation({ + isValid: true, + message: 'ABI format is valid', + status: 'success' + }); + } catch (error) { + setAbiValidation({ + isValid: false, + message: 'Invalid JSON format', + status: 'error' + }); + } } const onAddressChange = (e) => { - setContractAddress(e.target.value); + const value = e.target.value; + setContractAddress(value); + + // Validate Ethereum address + if (!value) { + setAddressValidation({ + isValid: false, + message: '', + status: '' + }); + return; + } + + if (ethers.utils.isAddress(value)) { + setAddressValidation({ + isValid: true, + message: 'Valid Ethereum address', + status: 'success' + }); + } else { + setAddressValidation({ + isValid: false, + message: 'Invalid Ethereum address format', + status: 'error' + }); + } } const onNetworkNameChange = (e) => { @@ -68,103 +208,278 @@ export default function AppCreate() { } const saveButtonDisabled = useMemo(() => { + // Always enable in development mode for testing if (process.env.NODE_ENV === 'development') { return false; } - if (appAbi && contractAddress && ethers.utils.isAddress(contractAddress)) { + + // Check if required fields are valid + if (appAbi && abiValidation.isValid && + contractAddress && addressValidation.isValid) { return false; } + return true; - }, [appAbi, contractAddress]) - + }, [appAbi, contractAddress, abiValidation.isValid, addressValidation.isValid]); + + // Determine if we should show test data button + const showTestDataButton = useMemo(() => { + return process.env.NODE_ENV === 'development'; + }, []); + + // Function to fill test data + const fillTestData = () => { + const testAbi = '[{"inputs":[{"internalType":"string","name":"name","type":"string"},{"internalType":"string","name":"symbol","type":"string"},{"internalType":"uint256","name":"supply","type":"uint256"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"subtractedValue","type":"uint256"}],"name":"decreaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"addedValue","type":"uint256"}],"name":"increaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"user","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"mint","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"}]'; + const testAddress = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const testName = 'My First Dapp'; + const testDesc = 'A simple ERC20 token contract'; + const testNetwork = 'homestead'; + + setAppAbi(testAbi); + setContractAddress(testAddress); + setAppName(testName); + setAppDesc(testDesc); + setNetworkName(testNetwork); + + // Trigger validation + onAbiChange({ target: { value: testAbi } }); + onAddressChange({ target: { value: testAddress } }); + }; - const saveApp = () => { - if ('undefined' === typeof window.ethereum) { - console.log('Not installed!') - notification.open({ - message: 'WTF?', - description: - 'Metamask is not installed. Please go to hell and download metamask before you come back.', - onClick: () => { - console.log('Metamask not installed!'); - }, + const saveApp = async () => { + setIsSubmitting(true); + + try { + // Check if MetaMask is installed + if (typeof window.ethereum === 'undefined') { + notification.error({ + message: 'MetaMask Required', + description: 'MetaMask is not installed. Please install MetaMask to interact with the Ethereum blockchain.', + duration: 10, + }); + return; + } + + // Check if wallet is connected + if (!account) { + notification.warning({ + message: 'Wallet Connection Required', + description: 'Please connect your wallet using the button in the header before creating a dApp.', + duration: 5, + }); + return; + } + + // Handle test data in development mode + let abijson = appAbi; + let contractAddr = contractAddress; + let name = appName || 'Untitled dApp'; + + // Use test data if fields are empty in development mode + if ((!appAbi || !contractAddress) && process.env.NODE_ENV === 'development') { + fillTestData(); + abijson = appAbi; + contractAddr = contractAddress; + } + + // Validate required fields + if (!abijson) { + notification.error({ + message: 'Missing ABI', + description: 'Please provide a valid contract ABI.', + }); + return; + } + + if (!contractAddr) { + notification.error({ + message: 'Missing Contract Address', + description: 'Please provide a valid contract address.', + }); + return; + } + + if (!ethers.utils.isAddress(contractAddr)) { + notification.error({ + message: 'Invalid Address', + description: 'The contract address format is invalid.', + }); + return; + } + + // Validate ABI JSON format + try { + JSON.parse(abijson); + } catch (error) { + notification.error({ + message: 'Invalid ABI Format', + description: 'The ABI is not a valid JSON. Please check the format.', + }); + return; + } + + // Optional network validation + if (networkName) { + const validNetworks = ['homestead', 'mainnet', 'goerli', 'sepolia', 'rinkeby', 'ropsten', 'kovan', 'polygon', 'mumbai', 'arbitrum', 'optimism']; + if (!validNetworks.includes(networkName.toLowerCase()) && !networkName.startsWith('http')) { + notification.warning({ + message: 'Network Warning', + description: `"${networkName}" is not a recognized network name. Make sure it's correct.`, + }); + } + } + + // Dispatch data to context + dispatch({ + type: 'set_appData', + payload: { + appName: name, + appDesc, + appAbi: abijson, + appNetwork: networkName, + appAddress: contractAddr, + } }); - return; - } - if (!account) { - notification.open({ - message: 'Require connect wallet', + + notification.success({ + message: 'dApp Created', + description: 'Your dApp has been created successfully!', }); - return; - } - let abijson = appAbi; - if (!appAbi && !contractAddress && process.env.NODE_ENV === 'development') { - // test code here!!! - abijson = '[{"inputs":[{"internalType":"string","name":"name","type":"string"},{"internalType":"string","name":"symbol","type":"string"},{"internalType":"uint256","name":"supply","type":"uint256"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"subtractedValue","type":"uint256"}],"name":"decreaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"addedValue","type":"uint256"}],"name":"increaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"user","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"mint","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"nonpayable","type":"function"}]' - setAppAbi(abijson); - - let contractAddress = '0x16226A92214B0DaFFc97E1A5b9e7BCb65F2b74F0'; - setContractAddress(contractAddress); - - let appName = 'My First Dapp'; - setAppName(appName); - } else if (!appAbi || !contractAddress || !ethers.utils.isAddress(contractAddress)) { - return; - } - - try { - JSON.parse(abijson); + + // Navigate to detail page + navigate('/detail'); + } catch (error) { - console.error('parse abi failed', error) - return; + console.error('Error creating dApp:', error); + notification.error({ + message: 'Error', + description: `Failed to create dApp: ${error.message}`, + }); + } finally { + setIsSubmitting(false); } + }; - //TODO: should check networkName? - - // Dispatch data - dispatch({ - type: 'set_appData', - payload: { - appName, - appDesc, - appAbi: abijson, - appNetwork: networkName, - appAddress: contractAddress, - } - }); - - // Goto new page - navigate(`/detail`); - } - - return - Create your dApp - -
  • -
    Name
    -
    -
  • -
  • -
    Description
    -
    -
  • -
  • -
    ABI *
    -
    -
  • -
  • -
    Contract Address *
    -
    -
  • -
  • -
    -
    Network Name
    -
    Use "homestead" for ethereum mainnet. Leave blank for a custom network.
    -
    -
    -
  • -
    - -
    + return ( + + Create your dApp + + {!account && ( + + )} + +
    + + + Name + + + + + Description + + + + + + Contract ABI + * + + + + + + {abiValidation.message && ( + + {abiValidation.isValid ? : null} + {abiValidation.message} + + )} + + + + + Contract Address + * + + + + + + {addressValidation.message && ( + + {addressValidation.isValid ? : null} + {addressValidation.message} + + )} + + + + + Network Name + + + + + + Use "homestead" for Ethereum mainnet. Leave blank for a custom network. + + + + + + + {showTestDataButton && ( + + )} + + + +
    +
    + ); } \ No newline at end of file diff --git a/src/components/AppMethod.jsx b/src/components/AppMethod.jsx index 2c384f0..8447c9f 100644 --- a/src/components/AppMethod.jsx +++ b/src/components/AppMethod.jsx @@ -1,11 +1,10 @@ import styled from 'styled-components'; -import { Input, Button } from 'antd'; -import { useEffect, useState } from 'react'; +import { Input, Button, notification, Spin, Typography, Divider } from 'antd'; +import { useEffect, useState, useRef } from 'react'; import { useDappContext } from '../store/contextProvider'; - import { ethers } from 'ethers'; -console.log(ethers); +const { Text } = Typography; // const StyleMethods = styled.div` @@ -34,6 +33,45 @@ const List = styled.ul` } ` +const ResultContainer = styled.div` + margin-top: 15px; + padding: 15px; + border-radius: 5px; + background-color: #f9f9f9; + word-break: break-all; + border: 1px solid #e8e8e8; +`; + +const ErrorText = styled(Text)` + color: #ff4d4f; + font-size: 14px; +`; + +const SuccessText = styled(Text)` + color: #52c41a; + font-size: 14px; + white-space: pre-wrap; +`; + +const ResultTitle = styled.div` + font-weight: bold; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const ResultValue = styled.div` + background-color: white; + padding: 10px; + border-radius: 4px; + border: 1px solid #eee; + margin-top: 5px; + max-height: 200px; + overflow-y: auto; + font-family: monospace; +`; + export default function AppMethod({ itemData, contract }) { const { state } = useDappContext(); @@ -42,115 +80,454 @@ export default function AppMethod({ itemData, contract }) { const [methodValues, setMethodValues] = useState([]); const [methodStateMutability, setMethodStateMutability] = useState(''); const [methodName, setMethodName] = useState(''); - const [payableValue, setPayableValue] = useState(0); - const [callResult, setCallResult] = useState(null); + const [payableValue, setPayableValue] = useState('0'); + + // Result state management + const [callResult, setCallResult] = useState(''); + const [displayResult, setDisplayResult] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [resultType, setResultType] = useState(null); // 'success', 'error', or null + const [showResult, setShowResult] = useState(false); + + // Use a ref to track if component is mounted + const isMounted = useRef(true); + // Set isMounted to false when component unmounts + useEffect(() => { + return () => { + isMounted.current = false; + }; + }, []); + + // Store the previous itemData to detect actual changes + const prevItemDataRef = useRef(''); + useEffect(() => { - if (callResult) setCallResult(undefined); + // Only reset results if the method actually changed + if (itemData !== prevItemDataRef.current) { + console.log('Method changed from', prevItemDataRef.current, 'to', itemData); + prevItemDataRef.current = itemData; + + // Reset result when method changes + setCallResult(''); + setDisplayResult(''); + setResultType(null); + setShowResult(false); + } + if (!itemData) return; - let name = itemData.split('(')[0]; - console.log(`methodName ${name}`); - setMethodName(name); - - let method = JSON.parse(appAbi).filter(e => e.name === name)[0]; - console.log(method); + try { + let name = itemData.split('(')[0]; + setMethodName(name); - setMethodInputs(method.inputs); - setMethodValues(method.inputs.map(e => null)); - setMethodStateMutability(method.stateMutability); + let method = JSON.parse(appAbi).filter(e => e.name === name)[0]; + if (!method) { + notification.error({ + message: 'Method Error', + description: `Method ${name} not found in ABI`, + }); + return; + } - }, [itemData]); + setMethodInputs(method.inputs); + setMethodValues(method.inputs.map(e => null)); + setMethodStateMutability(method.stateMutability); + } catch (error) { + notification.error({ + message: 'Error', + description: `Failed to parse method: ${error.message}`, + }); + } + }, [itemData, appAbi]); const formatTypeValue = (type, value) => { - if (type.startsWith("uint")) { - return ethers.BigNumber.from(value) + if (!value && value !== 0) { + throw new Error(`Value for type ${type} is empty or undefined`); + } + + if (type.startsWith("uint") || type.startsWith("int")) { + try { + return ethers.BigNumber.from(value.toString()); + } catch (error) { + throw new Error(`Invalid number format for ${type}: ${error.message}`); + } } else if (type.endsWith("[]")) { try { - console.log(value); const list = JSON.parse(value); + if (!Array.isArray(list)) { + throw new Error(`Expected array for type ${type}`); + } const itemType = type.replace("[]", ""); - return list.map(item => formatTypeValue(itemType, item)) + return list.map(item => formatTypeValue(itemType, item)); } catch (error) { - // TODO alert invalid array json - return value + throw new Error(`Invalid array format for ${type}: ${error.message}`); } } else if (type === "address") { - // TODO check address - return value + if (!ethers.utils.isAddress(value)) { + throw new Error(`Invalid Ethereum address: ${value}`); + } + return value; + } else if (type === "bool") { + const lowerValue = value.toString().toLowerCase(); + if (!(lowerValue === 'true' || lowerValue === 'false' || lowerValue === '1' || lowerValue === '0')) { + throw new Error(`Invalid boolean value: ${value}. Use true/false or 1/0`); + } + return lowerValue === 'true' || lowerValue === '1'; } else { return value; } } - const onValueChange = async (e, index) => { + const onValueChange = (e, index) => { const values = [...methodValues]; - let v = e.target.value - values[index] = v; + values[index] = e.target.value; setMethodValues(values); } + + const onPayableValueChange = (e) => { + const value = e.target.value; + // Only allow numbers and decimals + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setPayableValue(value); + } + } const onSubmit = async () => { - console.log(`onSubmit`); - - //TODO: check all values - - //Interact with wallet. - const method = JSON.parse(appAbi).find(e => e.name === methodName) - const values = []; - for (let i = 0; i < method.inputs.length; i++) { - const inputDef = method.inputs[i]; - values.push(formatTypeValue(inputDef.type, methodValues[i])) - } - console.log("values", values); - if (method?.type === "function") { - if (method.stateMutability === "view") { - try { - let result = await contract.functions[methodName](...values); - // TODO: handle result... - // console.log(ethers.utils.formatEther(result[0])); - console.log(result); - setCallResult(result[0].toString()); - } catch (error) { - setCallResult(error.message); - } + setIsLoading(true); + setCallResult(''); + setDisplayResult(''); + setShowResult(false); + setResultType(null); + + try { + // Parse the ABI to get the method details + const method = JSON.parse(appAbi).find(e => e.name === methodName); + if (!method) { + throw new Error(`Method ${methodName} not found in ABI`); } - - if (method.stateMutability === "nonpayable") { + + // Validate all inputs before proceeding + const values = []; + for (let i = 0; i < method.inputs.length; i++) { + const inputDef = method.inputs[i]; + const inputValue = methodValues[i]; + + // Check if required inputs are provided + if (inputValue === null || inputValue === undefined || inputValue === '') { + throw new Error(`Parameter ${inputDef.name || i+1} (${inputDef.type}) is required`); + } + try { - let result = await contract.functions[methodName](...values); - console.log(result); - let receipt = await result.wait(); - console.log(receipt); - setCallResult("Done") + values.push(formatTypeValue(inputDef.type, inputValue)); } catch (error) { - setCallResult(error.message); + throw new Error(`Parameter ${inputDef.name || i+1}: ${error.message}`); } } - - if (method.stateMutability === "payable") { - try { - let result = await contract.functions[methodName](...values, { value: ethers.utils.parseEther(payableValue) }); - await result.wait(); - setCallResult("Done") - } catch (error) { - setCallResult(error.message); + + // Execute the contract call based on method type + if (method?.type === "function") { + // Read-only function call + if (method.stateMutability === "view" || method.stateMutability === "pure") { + try { + console.log('Calling view function:', methodName, 'with values:', values); + const result = await contract.functions[methodName](...values); + console.log('Raw result:', result); + + // Format the result based on its type + let formattedResult; + + // Handle different result types + if (result[0] === undefined) { + formattedResult = 'No return value'; + } else if (result[0] === null) { + formattedResult = 'null'; + } else if (result[0] instanceof ethers.BigNumber) { + // Check if it might be representing ETH + if (method.outputs && method.outputs[0] && + (method.outputs[0].type.includes('uint') || method.outputs[0].name.toLowerCase().includes('balance'))) { + formattedResult = `${result[0].toString()}`; + } else { + formattedResult = result[0].toString(); + } + } else if (Array.isArray(result[0])) { + formattedResult = JSON.stringify(result[0], null, 2); + } else if (typeof result[0] === 'boolean') { + formattedResult = result[0].toString(); + } else if (typeof result[0] === 'object') { + try { + formattedResult = JSON.stringify(result[0], null, 2); + } catch (e) { + formattedResult = 'Complex object: ' + Object.prototype.toString.call(result[0]); + } + } else if (typeof result[0] === 'string') { + formattedResult = result[0]; + } else { + formattedResult = String(result[0]); + } + + console.log('Formatted result:', formattedResult); + // Make sure we're setting a string value that can be displayed + let displayResult; + if (formattedResult === undefined) { + displayResult = 'No result'; + } else if (formattedResult === null) { + displayResult = 'null'; + } else if (typeof formattedResult === 'object') { + try { + displayResult = JSON.stringify(formattedResult, null, 2); + } catch (e) { + displayResult = 'Complex object (see console)'; + } + } else { + // Ensure we have a string + displayResult = String(formattedResult); + } + + // Debug the actual value + console.log('Display result type:', typeof displayResult); + console.log('Display result value:', displayResult); + + console.log('Setting call result to:', displayResult); + // Force a string value and ensure it's not empty + const finalResult = displayResult || 'Empty result'; + console.log('Final result value:', finalResult, 'type:', typeof finalResult); + + // Make sure component is still mounted before updating state + if (isMounted.current) { + // Create a global variable for debugging + window.lastResult = finalResult; + + // Update state in a specific order to ensure UI updates + setIsLoading(false); + + // Use a single state update batch with a timeout to avoid React batching issues + setTimeout(() => { + if (isMounted.current) { + setResultType('success'); + setDisplayResult(finalResult); + setCallResult(finalResult); + setShowResult(true); + + // Create a direct DOM element to show the result + const debugElement = document.getElementById('debug-result'); + if (debugElement) { + debugElement.textContent = finalResult; + } + } + }, 0); + } + + notification.success({ + message: 'Call Successful', + description: 'The read operation completed successfully.' + }); + } catch (error) { + console.error('Error calling view function:', error); + if (isMounted.current) { + setDisplayResult(error.message); + setCallResult(error.message); + setShowResult(true); + setResultType('error'); + + // Force a UI update with a small timeout + setTimeout(() => { + if (isMounted.current) { + setShowResult(true); + } + }, 50); + } + + notification.error({ + message: 'Call Failed', + description: `Error: ${error.message}` + }); + } + } + + // State-changing function call (non-payable) + else if (method.stateMutability === "nonpayable") { + const result = await contract.functions[methodName](...values); + + notification.info({ + message: 'Transaction Submitted', + description: 'Waiting for confirmation...' + }); + + const receipt = await result.wait(); + + const txResult = `Transaction confirmed in block ${receipt.blockNumber}\nTransaction hash: ${receipt.transactionHash}`; + if (isMounted.current) { + setCallResult(txResult); + setDisplayResult(txResult); + setResultType('success'); + setShowResult(true); + } + + notification.success({ + message: 'Transaction Confirmed', + description: `Transaction completed in block ${receipt.blockNumber}` + }); + } + + // Payable function call + else if (method.stateMutability === "payable") { + // Validate ETH value + if (!payableValue || isNaN(parseFloat(payableValue))) { + throw new Error('Please enter a valid ETH amount'); + } + + const result = await contract.functions[methodName]( + ...values, + { value: ethers.utils.parseEther(payableValue) } + ); + + notification.info({ + message: 'Transaction Submitted', + description: `Sending ${payableValue} ETH. Waiting for confirmation...` + }); + + const receipt = await result.wait(); + + const txResult = `Transaction confirmed in block ${receipt.blockNumber}\nTransaction hash: ${receipt.transactionHash}\nSent ${payableValue} ETH`; + if (isMounted.current) { + setCallResult(txResult); + setDisplayResult(txResult); + setResultType('success'); + setShowResult(true); + } + + notification.success({ + message: 'Transaction Confirmed', + description: `Transaction with ${payableValue} ETH completed in block ${receipt.blockNumber}` + }); } + } else { + throw new Error(`Unsupported method type: ${method.type}`); } + } catch (error) { + console.error('Contract interaction error:', error); + if (isMounted.current) { + setCallResult(error.message); + setDisplayResult(error.message); + setResultType('error'); + setShowResult(true); + } + + notification.error({ + message: 'Error', + description: error.message + }); + } finally { + setIsLoading(false); } } + // For debugging + useEffect(() => { + console.log('Result state:', { + callResult, + displayResult, + resultType, + showResult + }); + }, [callResult, displayResult, resultType, showResult]); + return
    {itemData}
    - {methodInputs.map((item, index) => (
  • {item.name}
    onValueChange(e, index)} />
  • ))} - { methodStateMutability === "payable" && (
  • ETH Value
    {payableValue = e.target.value}} />
  • )} + {methodInputs.map((item, index) => ( +
  • +
    {item.name || `Parameter ${index+1}`} ({item.type})
    +
    + onValueChange(e, index)} + status={methodValues[index] === '' ? 'error' : ''} + /> +
    +
  • + ))} + { methodStateMutability === "payable" && ( +
  • +
    ETH Value
    +
    + +
    +
  • + )}
    - onSubmit()}>SUBMIT + 0 && methodValues.some(v => v === null || v === undefined || v === ''))} + > + {isLoading ? 'PROCESSING' : 'SUBMIT'} + - {!!callResult &&
    Result
    {callResult}
    } + + {isLoading && ( +
    + +
    + )} + + {/* Result section */} + + + + + Function Result + {isLoading && } + + + {/* Direct debug output */} +
    + Debug: showResult={showResult ? 'true' : 'false'}, + resultType={resultType || 'none'}, + callResult={callResult ? `"${callResult}"` : 'empty'} +
    + + {showResult ? ( +
    + + {resultType === 'error' ? 'Error' : 'Success'} + + + +
    + Function Output: +
    + + {/* Static result display - always shows the last result */} +
    +                            {window.lastResult || displayResult || 'No result data available'}
    +                        
    +
    + + {/* Direct DOM element for result display */} +
    +
    + Raw Result: +
    +
    
    +                    
    +
    + ) : ( +
    + {isLoading ? 'Processing transaction...' : 'No result yet. Click Submit to call the function.'} +
    + )} +
    } \ No newline at end of file diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 8ea4668..6c6ee1b 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,62 +1,305 @@ -import { Button, notification } from 'antd'; -import { useState } from 'react'; -// import { useDappContext } from '../store/contextProvider'; +import { Button, notification, Tooltip, Dropdown, Menu, Typography } from 'antd'; +import { WalletOutlined, DisconnectOutlined, CopyOutlined, HomeOutlined } from '@ant-design/icons'; +import { useState, useEffect } from 'react'; import styled from 'styled-components'; import { useNavigate } from 'react-router-dom'; import logo from '../res/oneclick.png'; import { useDappContext } from '../store/contextProvider'; +import { ethers } from 'ethers'; const HeaderTop = styled.div` display: flex; - align-items: center; + align-items: center; justify-content: space-between; width: 100vw; - padding: 20px 5%; + padding: 15px 5%; box-sizing: border-box; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + background-color: #ffffff; `; const Logo = styled.div` - padding-top: .1rem; - img { - width: 80px; + padding-top: .1rem; + cursor: pointer; + display: flex; + align-items: center; + img { + width: 80px; } `; -export default function Header() { +const LogoText = styled(Typography.Title)` + margin: 0 0 0 10px !important; + font-size: 18px !important; +`; + +const AccountDisplay = styled.div` + display: flex; + align-items: center; + background-color: #f5f5f5; + border-radius: 20px; + padding: 5px 15px; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background-color: #e6f7ff; + } + + .address { + margin-left: 8px; + font-size: 14px; + } +`; + +const NetworkBadge = styled.div` + background-color: ${props => props.color || '#52c41a'}; + color: white; + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; + margin-right: 10px; +`; +const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 15px; +`; + +export default function Header() { const [account, setAccount] = useState(''); + const [network, setNetwork] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [copied, setCopied] = useState(false); - const { dispatch } = useDappContext(); + const { dispatch, state } = useDappContext(); const navigate = useNavigate(); + // Check if account is already connected on component mount + useEffect(() => { + const checkConnection = async () => { + if (typeof window.ethereum !== 'undefined') { + try { + // Get current accounts + const accounts = await window.ethereum.request({ method: 'eth_accounts' }); + if (accounts.length > 0) { + setAccount(accounts[0]); + dispatch({ type: 'set_account', payload: accounts[0] }); + + // Get current network + await updateNetworkInfo(); + } + + // Listen for account changes + window.ethereum.on('accountsChanged', handleAccountsChanged); + + // Listen for network changes + window.ethereum.on('chainChanged', handleChainChanged); + } catch (error) { + console.error('Error checking wallet connection:', error); + } + } + }; + + checkConnection(); + + // Cleanup listeners on unmount + return () => { + if (window.ethereum) { + window.ethereum.removeListener('accountsChanged', handleAccountsChanged); + window.ethereum.removeListener('chainChanged', handleChainChanged); + } + }; + }, []); + + // Handle account changes + const handleAccountsChanged = (accounts) => { + if (accounts.length === 0) { + // User disconnected their wallet + setAccount(''); + dispatch({ type: 'set_account', payload: '' }); + notification.info({ + message: 'Wallet Disconnected', + description: 'Your wallet has been disconnected.' + }); + } else { + // User switched accounts + setAccount(accounts[0]); + dispatch({ type: 'set_account', payload: accounts[0] }); + } + }; + + // Handle network changes + const handleChainChanged = async () => { + // Reload the page on network change as recommended by MetaMask + window.location.reload(); + }; + + // Get network information + const updateNetworkInfo = async () => { + if (typeof window.ethereum !== 'undefined') { + try { + const provider = new ethers.providers.Web3Provider(window.ethereum); + const network = await provider.getNetwork(); + setNetwork(network); + } catch (error) { + console.error('Error getting network info:', error); + } + } + }; + + // Format address for display + const formatAddress = (address) => { + if (!address) return ''; + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; + }; + + // Connect wallet const connectWallet = async () => { - if ('undefined' !== typeof window.ethereum) { + if (typeof window.ethereum === 'undefined') { + notification.error({ + message: 'MetaMask Required', + description: 'Please install MetaMask to connect your wallet.', + duration: 10, + btn: ( + + ), + }); + return; + } + + setIsConnecting(true); + + try { const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); - console.log(accounts); setAccount(accounts[0]); dispatch({ type: 'set_account', payload: accounts[0] }); - } else { - notification.open({ - message: 'WTF?', - description: - 'Metamask is not installed. Please go to hell and download metamask before you come back.', - onClick: () => { - console.log('Metamask not installed!'); - }, + + await updateNetworkInfo(); + + notification.success({ + message: 'Wallet Connected', + description: 'Your wallet has been successfully connected!', + duration: 3, }); + } catch (error) { + console.error('Error connecting wallet:', error); + notification.error({ + message: 'Connection Failed', + description: error.message || 'Failed to connect wallet. Please try again.', + }); + } finally { + setIsConnecting(false); } }; - const backToHome = async() => { + // Navigate to home + const backToHome = () => { navigate('/home'); - } + }; + + // Copy address to clipboard + const copyAddress = () => { + if (account) { + navigator.clipboard.writeText(account); + setCopied(true); + notification.success({ + message: 'Address Copied', + description: 'Address copied to clipboard!', + duration: 2, + }); + setTimeout(() => setCopied(false), 2000); + } + }; + + // Disconnect wallet (for UI purposes) + const disconnectWallet = () => { + setAccount(''); + dispatch({ type: 'set_account', payload: '' }); + notification.info({ + message: 'Wallet Disconnected', + description: 'Your wallet has been disconnected from this app.', + }); + }; + + // Get network display name and color + const getNetworkInfo = () => { + if (!network) return { name: 'Unknown', color: '#999999' }; + + switch (network.chainId) { + case 1: + return { name: 'Ethereum', color: '#627EEA' }; + case 5: + return { name: 'Goerli', color: '#3099f2' }; + case 11155111: + return { name: 'Sepolia', color: '#5f4bb6' }; + case 137: + return { name: 'Polygon', color: '#8247E5' }; + case 80001: + return { name: 'Mumbai', color: '#92b5d8' }; + case 42161: + return { name: 'Arbitrum', color: '#28a0f0' }; + case 10: + return { name: 'Optimism', color: '#ff0420' }; + default: + return { name: `Chain ID: ${network.chainId}`, color: '#f5a623' }; + } + }; + + // Wallet menu items + const walletMenu = ( + + }> + Copy Address + + }> + Disconnect + + + ); - return - - {backToHome()}}/> - + return ( + + + OneClick dApp Logo + OneClick dApp + - {!account.length && } - {!!account.length &&
    {account}
    } -
    + + + + {!account ? ( + + ) : ( + + + {network && ( + + {getNetworkInfo().name} + + )} + + {formatAddress(account)} + + + )} + +
    + ) } \ No newline at end of file From b4b05f653513a078c919d542b97c3569109291c9 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Thu, 22 May 2025 01:22:28 +0800 Subject: [PATCH 4/8] fix: update runner ubuntu version; --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 75b9c56..6792e9d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,7 +8,7 @@ on: jobs: deploy: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-${{ github.ref }} steps: From ae0f166e5693c8aef464a8165221cae4bdf563d7 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Thu, 22 May 2025 01:24:31 +0800 Subject: [PATCH 5/8] fix: update action config --- .github/workflows/main.yml | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6792e9d..65abe28 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,31 +12,22 @@ jobs: concurrency: group: ${{ github.workflow }}-${{ github.ref }} steps: - - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v3 - name: Setup Node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: - node-version: '14' + node-version: '18' + cache: 'yarn' - - name: Get yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" + - name: Install dependencies + run: yarn install --frozen-lockfile - - name: Cache dependencies - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: Build env: CI: false - run: | - yarn install --frozen-lockfile - yarn build + run: yarn build - name: Deploy to gh-pages uses: peaceiris/actions-gh-pages@v3 From 7066ec57298901ece303783b6693c3cba8d218f6 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Thu, 22 May 2025 15:48:47 +0800 Subject: [PATCH 6/8] fix: display result --- src/components/AppMethod.jsx | 30 ++- src/components/TransactionStatus.jsx | 271 +++++++++++++++++++++++++++ src/hooks/useTransactionTracker.js | 165 ++++++++++++++++ src/store/methodReducer.js | 125 ++++++++++++ src/utils/contractUtils.js | 186 ++++++++++++++++++ 5 files changed, 767 insertions(+), 10 deletions(-) create mode 100644 src/components/TransactionStatus.jsx create mode 100644 src/hooks/useTransactionTracker.js create mode 100644 src/store/methodReducer.js create mode 100644 src/utils/contractUtils.js diff --git a/src/components/AppMethod.jsx b/src/components/AppMethod.jsx index 8447c9f..41bc93c 100644 --- a/src/components/AppMethod.jsx +++ b/src/components/AppMethod.jsx @@ -88,6 +88,8 @@ export default function AppMethod({ itemData, contract }) { const [isLoading, setIsLoading] = useState(false); const [resultType, setResultType] = useState(null); // 'success', 'error', or null const [showResult, setShowResult] = useState(false); + const [transactionHash, setTransactionHash] = useState(null); + const [blockNumber, setBlockNumber] = useState(null); // Use a ref to track if component is mounted const isMounted = useRef(true); @@ -362,6 +364,8 @@ export default function AppMethod({ itemData, contract }) { setDisplayResult(txResult); setResultType('success'); setShowResult(true); + setTransactionHash(receipt.transactionHash); + setBlockNumber(receipt.blockNumber); } notification.success({ @@ -395,6 +399,8 @@ export default function AppMethod({ itemData, contract }) { setDisplayResult(txResult); setResultType('success'); setShowResult(true); + setTransactionHash(receipt.transactionHash); + setBlockNumber(receipt.blockNumber); } notification.success({ @@ -481,8 +487,7 @@ export default function AppMethod({ itemData, contract }) { )} - - {/* Result section */} + {/* Result section */} @@ -491,7 +496,7 @@ export default function AppMethod({ itemData, contract }) { {isLoading && } - {/* Direct debug output */} + {/* Hidden debug info */}
    Debug: showResult={showResult ? 'true' : 'false'}, resultType={resultType || 'none'}, @@ -509,19 +514,24 @@ export default function AppMethod({ itemData, contract }) { Function Output:
    - {/* Static result display - always shows the last result */} + {/* Single result display */}
                                 {window.lastResult || displayResult || 'No result data available'}
                             
    - {/* Direct DOM element for result display */} -
    -
    - Raw Result: + {/* Only show additional details for transactions, not for simple function calls */} + {transactionHash && ( +
    +
    + Transaction Details: +
    +
    +
    Hash: {transactionHash}
    + {blockNumber &&
    Block: {blockNumber}
    } +
    -
    
    -                    
    + )}
    ) : (
    diff --git a/src/components/TransactionStatus.jsx b/src/components/TransactionStatus.jsx new file mode 100644 index 0000000..58f9614 --- /dev/null +++ b/src/components/TransactionStatus.jsx @@ -0,0 +1,271 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { Spin, Progress, Typography, Button } from 'antd'; +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, LinkOutlined } from '@ant-design/icons'; + +const { Text, Link } = Typography; + +const StatusContainer = styled.div` + margin-top: 15px; + padding: 15px; + border-radius: 5px; + background-color: #f9f9f9; + border: 1px solid #e8e8e8; +`; + +const StatusHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +`; + +const StatusDetails = styled.div` + margin-top: 10px; + font-size: 14px; +`; + +const HashLink = styled(Link)` + font-family: monospace; + word-break: break-all; +`; + +const StepItem = styled.div` + display: flex; + align-items: center; + margin-bottom: 8px; + + .icon { + margin-right: 8px; + } + + .step-content { + flex: 1; + } + + .step-time { + color: #999; + font-size: 12px; + margin-left: 8px; + } +`; + +/** + * Component to display transaction status with progress indicators + */ +const TransactionStatus = ({ + txHash, + status, + blockNumber, + estimatedTime, + networkName, + error, + startTime, + onClose +}) => { + const [elapsedTime, setElapsedTime] = useState(0); + const [explorerUrl, setExplorerUrl] = useState(''); + + // Update elapsed time every second + useEffect(() => { + if (status === 'pending' && startTime) { + const timer = setInterval(() => { + setElapsedTime(Math.floor((Date.now() - startTime) / 1000)); + }, 1000); + + return () => clearInterval(timer); + } + }, [status, startTime]); + + // Set explorer URL based on network + useEffect(() => { + if (!txHash) return; + + let baseUrl; + switch(networkName?.toLowerCase()) { + case 'mainnet': + baseUrl = 'https://etherscan.io/tx/'; + break; + case 'ropsten': + baseUrl = 'https://ropsten.etherscan.io/tx/'; + break; + case 'rinkeby': + baseUrl = 'https://rinkeby.etherscan.io/tx/'; + break; + case 'goerli': + baseUrl = 'https://goerli.etherscan.io/tx/'; + break; + case 'kovan': + baseUrl = 'https://kovan.etherscan.io/tx/'; + break; + case 'polygon': + case 'matic': + baseUrl = 'https://polygonscan.com/tx/'; + break; + case 'mumbai': + baseUrl = 'https://mumbai.polygonscan.com/tx/'; + break; + case 'bsc': + case 'binance': + baseUrl = 'https://bscscan.com/tx/'; + break; + case 'avalanche': + baseUrl = 'https://snowtrace.io/tx/'; + break; + case 'arbitrum': + baseUrl = 'https://arbiscan.io/tx/'; + break; + case 'optimism': + baseUrl = 'https://optimistic.etherscan.io/tx/'; + break; + default: + baseUrl = 'https://etherscan.io/tx/'; + } + + setExplorerUrl(baseUrl + txHash); + }, [txHash, networkName]); + + // Calculate progress percentage + const getProgressPercent = () => { + if (status === 'success') return 100; + if (status === 'error' || status === 'failed') return 100; + if (status === 'not_found') return 30; + + // For pending status, base progress on elapsed time + // Assuming most transactions confirm within 2 minutes (120 seconds) + const timeBasedProgress = Math.min(Math.floor(elapsedTime / 120 * 70), 70); + return 30 + timeBasedProgress; + }; + + // Format elapsed time + const formatElapsedTime = () => { + if (elapsedTime < 60) return `${elapsedTime}s`; + const minutes = Math.floor(elapsedTime / 60); + const seconds = elapsedTime % 60; + return `${minutes}m ${seconds}s`; + }; + + // Get progress status text + const getStatusText = () => { + switch(status) { + case 'success': + return 'Transaction Confirmed'; + case 'failed': + return 'Transaction Failed'; + case 'error': + return 'Transaction Error'; + case 'not_found': + return 'Transaction Not Found'; + case 'pending': + default: + return 'Transaction Pending'; + } + }; + + // Get progress status color + const getStatusColor = () => { + switch(status) { + case 'success': + return '#52c41a'; + case 'failed': + case 'error': + return '#ff4d4f'; + case 'not_found': + return '#faad14'; + case 'pending': + default: + return '#1890ff'; + } + }; + + return ( + + + {getStatusText()} + {onClose && ( + + )} + + + + + + +
    + {status !== 'not_found' ? ( + + ) : ( + + )} +
    +
    Transaction Submitted
    + {startTime && ( +
    + {new Date(startTime).toLocaleTimeString()} +
    + )} +
    + + +
    + {status === 'pending' ? ( + + ) : status === 'success' ? ( + + ) : status === 'failed' || status === 'error' ? ( + + ) : ( + + )} +
    +
    + {status === 'pending' ? ( + <> + Waiting for confirmation + {estimatedTime && ` (Est. ${estimatedTime})`} + {elapsedTime > 0 && ` - ${formatElapsedTime()}`} + + ) : status === 'success' ? ( + `Confirmed in block #${blockNumber}` + ) : status === 'failed' ? ( + 'Transaction failed on-chain' + ) : status === 'error' ? ( + `Error: ${error || 'Unknown error'}` + ) : ( + 'Transaction not found' + )} +
    +
    + + {txHash && ( +
    + Transaction Hash: +
    + + {txHash.substring(0, 10)}...{txHash.substring(txHash.length - 8)} + + +
    +
    + )} +
    +
    + ); +}; + +export default TransactionStatus; diff --git a/src/hooks/useTransactionTracker.js b/src/hooks/useTransactionTracker.js new file mode 100644 index 0000000..242b2b8 --- /dev/null +++ b/src/hooks/useTransactionTracker.js @@ -0,0 +1,165 @@ +import { useState, useEffect, useCallback } from 'react'; + +/** + * Custom hook to track transaction progress + * @param {Object} provider - Ethers.js provider + * @returns {Object} - Transaction tracking utilities + */ +export const useTransactionTracker = (provider) => { + const [transactions, setTransactions] = useState({}); + + // Add a new transaction to track + const trackTransaction = useCallback((txHash, description) => { + setTransactions(prev => ({ + ...prev, + [txHash]: { + hash: txHash, + description, + status: 'pending', + confirmations: 0, + receipt: null, + error: null, + startTime: Date.now(), + } + })); + + return txHash; + }, []); + + // Update transaction status + const updateTransaction = useCallback((txHash, updates) => { + setTransactions(prev => { + if (!prev[txHash]) return prev; + + return { + ...prev, + [txHash]: { + ...prev[txHash], + ...updates, + } + }; + }); + }, []); + + // Listen for transaction confirmations + useEffect(() => { + if (!provider) return; + + const pendingTxHashes = Object.keys(transactions).filter( + hash => transactions[hash].status === 'pending' + ); + + const listeners = {}; + + // Set up listeners for each pending transaction + pendingTxHashes.forEach(txHash => { + if (!listeners[txHash]) { + const onReceipt = (receipt) => { + updateTransaction(txHash, { + status: receipt.status === 1 ? 'success' : 'failed', + receipt, + confirmations: 1, + }); + }; + + const onError = (error) => { + updateTransaction(txHash, { + status: 'error', + error: error.message, + }); + }; + + // Listen for transaction receipt + provider.once(txHash, onReceipt); + + // Store listeners to clean up later + listeners[txHash] = { onReceipt, onError }; + + // Check transaction status immediately + provider.getTransaction(txHash) + .then(tx => { + if (tx) { + // Transaction found but not confirmed yet + updateTransaction(txHash, { + status: 'pending', + gasPrice: tx.gasPrice?.toString(), + }); + + // If transaction has a wait method, use it to get receipt + if (tx.wait) { + tx.wait() + .then(onReceipt) + .catch(onError); + } + } else { + // Transaction not found - might be dropped or not broadcast + updateTransaction(txHash, { + status: 'not_found', + }); + } + }) + .catch(onError); + } + }); + + // Clean up listeners + return () => { + pendingTxHashes.forEach(txHash => { + if (listeners[txHash]) { + provider.removeListener(txHash, listeners[txHash].onReceipt); + } + }); + }; + }, [transactions, provider, updateTransaction]); + + // Get estimated confirmation time based on gas price + const getEstimatedTime = useCallback(async (txHash) => { + if (!provider || !transactions[txHash]) return null; + + try { + const tx = transactions[txHash]; + + // If we don't have gas price info yet, try to get it + if (!tx.gasPrice) { + const txData = await provider.getTransaction(txHash); + if (txData && txData.gasPrice) { + updateTransaction(txHash, { gasPrice: txData.gasPrice.toString() }); + } else { + return null; + } + } + + // Get current gas prices from the network + const feeData = await provider.getFeeData(); + if (!feeData || !feeData.gasPrice) return null; + + const txGasPrice = BigInt(tx.gasPrice || '0'); + const currentGasPrice = feeData.gasPrice.toBigInt(); + + // Calculate estimated time based on gas price difference + // This is a very rough estimation + if (txGasPrice >= currentGasPrice) { + return 'less than 1 minute'; + } else if (txGasPrice >= currentGasPrice * BigInt(80) / BigInt(100)) { + return '1-2 minutes'; + } else if (txGasPrice >= currentGasPrice * BigInt(50) / BigInt(100)) { + return '3-5 minutes'; + } else { + return 'more than 5 minutes'; + } + } catch (error) { + console.error('Error estimating confirmation time:', error); + return null; + } + }, [provider, transactions, updateTransaction]); + + return { + transactions, + trackTransaction, + updateTransaction, + getEstimatedTime, + getTransaction: useCallback((txHash) => transactions[txHash], [transactions]), + }; +}; + +export default useTransactionTracker; diff --git a/src/store/methodReducer.js b/src/store/methodReducer.js new file mode 100644 index 0000000..283a1cf --- /dev/null +++ b/src/store/methodReducer.js @@ -0,0 +1,125 @@ +/** + * Reducer for managing method call state in AppMethod component + */ + +// Action types +export const METHOD_ACTIONS = { + SET_METHOD_DATA: 'SET_METHOD_DATA', + SET_METHOD_VALUES: 'SET_METHOD_VALUES', + UPDATE_METHOD_VALUE: 'UPDATE_METHOD_VALUE', + SET_PAYABLE_VALUE: 'SET_PAYABLE_VALUE', + CALL_METHOD_START: 'CALL_METHOD_START', + CALL_METHOD_SUCCESS: 'CALL_METHOD_SUCCESS', + CALL_METHOD_ERROR: 'CALL_METHOD_ERROR', + RESET_RESULT: 'RESET_RESULT', +}; + +// Initial state +export const initialMethodState = { + methodName: '', + methodInputs: [], + methodValues: [], + methodStateMutability: '', + payableValue: '0', + isLoading: false, + callResult: '', + displayResult: '', + resultType: null, // 'success', 'error', or null + showResult: false, + transactionHash: null, + blockNumber: null, +}; + +/** + * Method reducer function + * @param {Object} state - Current state + * @param {Object} action - Dispatched action + * @returns {Object} - New state + */ +export const methodReducer = (state, action) => { + switch (action.type) { + case METHOD_ACTIONS.SET_METHOD_DATA: + return { + ...state, + methodName: action.payload.name, + methodInputs: action.payload.inputs, + methodValues: action.payload.inputs.map(() => null), + methodStateMutability: action.payload.stateMutability, + // Reset result when method changes + callResult: '', + displayResult: '', + resultType: null, + showResult: false, + transactionHash: null, + blockNumber: null, + }; + + case METHOD_ACTIONS.SET_METHOD_VALUES: + return { + ...state, + methodValues: action.payload, + }; + + case METHOD_ACTIONS.UPDATE_METHOD_VALUE: + const newValues = [...state.methodValues]; + newValues[action.payload.index] = action.payload.value; + return { + ...state, + methodValues: newValues, + }; + + case METHOD_ACTIONS.SET_PAYABLE_VALUE: + return { + ...state, + payableValue: action.payload, + }; + + case METHOD_ACTIONS.CALL_METHOD_START: + return { + ...state, + isLoading: true, + callResult: '', + displayResult: '', + showResult: false, + resultType: null, + transactionHash: null, + blockNumber: null, + }; + + case METHOD_ACTIONS.CALL_METHOD_SUCCESS: + return { + ...state, + isLoading: false, + callResult: action.payload.result, + displayResult: action.payload.result, + resultType: 'success', + showResult: true, + transactionHash: action.payload.transactionHash, + blockNumber: action.payload.blockNumber, + }; + + case METHOD_ACTIONS.CALL_METHOD_ERROR: + return { + ...state, + isLoading: false, + callResult: action.payload, + displayResult: action.payload, + resultType: 'error', + showResult: true, + }; + + case METHOD_ACTIONS.RESET_RESULT: + return { + ...state, + callResult: '', + displayResult: '', + resultType: null, + showResult: false, + transactionHash: null, + blockNumber: null, + }; + + default: + return state; + } +}; diff --git a/src/utils/contractUtils.js b/src/utils/contractUtils.js new file mode 100644 index 0000000..6c8272a --- /dev/null +++ b/src/utils/contractUtils.js @@ -0,0 +1,186 @@ +import { ethers } from 'ethers'; + +/** + * Format contract parameter value based on its type + * @param {string} type - The Solidity parameter type + * @param {string} value - The user input value + * @returns {any} - The formatted value ready for contract call + */ +export const formatTypeValue = (type, value) => { + if (!value && value !== 0 && value !== false) { + throw new Error(`Value is required for type ${type}`); + } + + // Handle array types + if (type.includes('[')) { + try { + // Parse the array from string input + let arrayValue = value; + if (typeof value === 'string') { + // Handle both comma-separated values and JSON arrays + if (value.trim().startsWith('[')) { + arrayValue = JSON.parse(value); + } else { + arrayValue = value.split(',').map(item => item.trim()); + } + } + + // Get the base type (without array brackets) + const baseType = type.substring(0, type.indexOf('[')); + + // Format each element in the array + return arrayValue.map(item => formatTypeValue(baseType, item)); + } catch (error) { + throw new Error(`Invalid array format for ${type}: ${error.message}`); + } + } + + // Handle tuple types + if (type.startsWith('tuple')) { + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid tuple format: ${error.message}`); + } + } + + // Handle address type + if (type === 'address') { + if (!ethers.utils.isAddress(value)) { + throw new Error('Invalid Ethereum address'); + } + return value; + } + + // Handle boolean type + if (type === 'bool') { + if (typeof value === 'boolean') return value; + if (value.toLowerCase() === 'true') return true; + if (value.toLowerCase() === 'false') return false; + throw new Error('Boolean value must be true or false'); + } + + // Handle integer types (uint/int) + if (type.startsWith('uint') || type.startsWith('int')) { + try { + // Check if the value is a valid number + if (isNaN(value)) { + throw new Error(`Not a valid number for ${type}`); + } + return ethers.BigNumber.from(value); + } catch (error) { + throw new Error(`Invalid number format for ${type}: ${error.message}`); + } + } + + // Handle bytes types + if (type.startsWith('bytes')) { + if (type === 'bytes') { + return ethers.utils.arrayify(value); + } + // Handle fixed-size bytes + return value; + } + + // Handle string type + if (type === 'string') { + return value; + } + + // Default case + return value; +}; + +/** + * Format contract result for display + * @param {any} result - The raw result from contract call + * @param {Object} outputDef - The output definition from ABI + * @returns {string} - Formatted result for display + */ +export const formatContractResult = (result, outputDef) => { + if (result === undefined) { + return 'No return value'; + } + + if (result === null) { + return 'null'; + } + + if (result instanceof ethers.BigNumber) { + // Check if it might be representing ETH + if (outputDef && + (outputDef.type.includes('uint') || + (outputDef.name && outputDef.name.toLowerCase().includes('balance')))) { + return result.toString(); + } + return result.toString(); + } + + if (Array.isArray(result)) { + return JSON.stringify(result, null, 2); + } + + if (typeof result === 'boolean') { + return result.toString(); + } + + if (typeof result === 'object') { + try { + return JSON.stringify(result, null, 2); + } catch (e) { + return 'Complex object: ' + Object.prototype.toString.call(result); + } + } + + if (typeof result === 'string') { + return result; + } + + return String(result); +}; + +/** + * Parse and format blockchain errors + * @param {Error} error - The error object from ethers.js + * @returns {string} - User-friendly error message + */ +export const parseBlockchainError = (error) => { + const errorMessage = error.message || 'Unknown error'; + + // Check for common blockchain error patterns + if (errorMessage.includes('insufficient funds')) { + return 'Insufficient funds to complete this transaction'; + } + + if (errorMessage.includes('gas required exceeds allowance')) { + return 'Transaction requires more gas than allowed'; + } + + if (errorMessage.includes('nonce too low')) { + return 'Transaction nonce is too low. Try refreshing the page'; + } + + if (errorMessage.includes('replacement transaction underpriced')) { + return 'Gas price too low for replacement transaction'; + } + + if (errorMessage.includes('execution reverted')) { + // Extract custom error message if available + const revertMatch = errorMessage.match(/execution reverted: (.*?)(?:,|$)/); + if (revertMatch && revertMatch[1]) { + return `Smart contract reverted: ${revertMatch[1]}`; + } + return 'Transaction reverted by the smart contract'; + } + + if (errorMessage.includes('user rejected')) { + return 'Transaction was rejected in your wallet'; + } + + if (errorMessage.includes('network changed')) { + return 'Network changed during transaction. Please refresh the page'; + } + + // Return original error if no specific pattern is matched + return errorMessage; +}; From 81c4748cd29834e65d7aa8d5523a97ee40649df4 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Thu, 22 May 2025 16:07:46 +0800 Subject: [PATCH 7/8] fix: rename filed name --- src/components/AppMethod.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/AppMethod.jsx b/src/components/AppMethod.jsx index 41bc93c..b766243 100644 --- a/src/components/AppMethod.jsx +++ b/src/components/AppMethod.jsx @@ -492,7 +492,7 @@ export default function AppMethod({ itemData, contract }) { - Function Result + Result {isLoading && } @@ -511,7 +511,7 @@ export default function AppMethod({ itemData, contract }) {
    - Function Output: + Output:
    {/* Single result display */} From bf74d33a04dfaca6d78a30ba15faa390c0e12957 Mon Sep 17 00:00:00 2001 From: Frozen <1884084+xrdavies@users.noreply.github.com> Date: Thu, 22 May 2025 16:11:21 +0800 Subject: [PATCH 8/8] docs: update readme --- README.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 18e41a0..266213a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,55 @@ Quick DApp is a tool for smart contract developers to interact with any smart co With Quick DApp, you can just create a simple dapp in minutes and share it with your friends anywhere. -# Plans +## Features + +- **Instant Interface Generation**: Automatically creates a UI for any smart contract from its ABI +- **Multi-network Support**: Works with Ethereum, Polygon, BSC, and other EVM-compatible networks +- **Comprehensive Method Support**: Handles view/pure functions, state-changing functions, and payable functions +- **Enhanced Transaction Tracking**: Real-time status updates with detailed feedback +- **Advanced Type Handling**: Supports complex parameter types (arrays, structs, etc.) +- **Clean Result Display**: Clearly formatted function return values +- **User-friendly Error Messages**: Readable blockchain error information + +## Usage + +1. Connect your MetaMask wallet +2. Enter your contract's ABI (JSON format) and address +3. Select the network where your contract is deployed +4. Click on any method to interact with it +5. For read-only methods, results will display immediately +6. For state-changing methods, you'll see transaction status and confirmations + +## Installation + +### Prerequisites + +- Node.js (v14 or later) +- Yarn or npm + +### Setup + +1. Clone the repository + ```bash + git clone https://github.com/Web3Camp-Labs/quick-dapp.git + cd quick-dapp + ``` + +2. Install dependencies + ```bash + yarn install + # or + npm install + ``` + +3. Start the development server + ```bash + yarn start + # or + npm start + ``` + +## Plans - [ ] Add support for other wallets - [ ] CoinBase - [ ] WalletConnect @@ -15,11 +63,37 @@ With Quick DApp, you can just create a simple dapp in minutes and share it with - [ ] Save the created dapp locally - [ ] Provide share link for dapp - [ ] New UI +- [x] Enhanced transaction tracking +- [x] Improved error handling +- [x] Better result formatting + +## Project Structure + +- `src/components/` - React components + - `AppMethod.jsx` - Core component for interacting with contract methods + - `TransactionStatus.jsx` - Transaction tracking UI +- `src/utils/` - Utility functions + - `contractUtils.js` - Smart contract interaction helpers +- `src/store/` - State management + - `methodReducer.js` - Reducer for method call state +- `src/hooks/` - Custom React hooks + - `useTransactionTracker.js` - Hook for tracking transaction status -# History +## History In the past years, **Patrick Gallagher** created a tool named OneClick DApp for the community. However, this tool has not worked for a long time since mid-2023. So we created a new tool to replace it under the name of Web3Camp. -# Special Thanks +## Technologies + +- React 17 +- ethers.js 5.5 +- Ant Design 4.18 +- Styled Components + +## Special Thanks https://oneclickdapp.com + +## License + +This project is licensed under the MIT License.