Compare commits

1 Commits

Author SHA1 Message Date
Tom You
94463e48cc Show tables and status 2025-10-02 16:00:37 +09:00
3 changed files with 402 additions and 106 deletions

View File

@@ -36,3 +36,155 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
/* Status display styles */
.status-container {
margin-top: 30px;
width: 80%;
margin: 30px auto 0;
}
.status-header {
margin-bottom: 10px;
font-weight: bold;
}
.status-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
border-radius: 4px;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 8px;
margin: 5px 0;
border-radius: 4px;
transition: all 0.3s ease;
}
.status-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.status-address {
font-size: 14px;
font-family: monospace;
word-break: break-all;
max-width: 60%;
}
.status-details {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.status-amount {
font-size: 12px;
margin-bottom: 2px;
}
.status-text {
font-size: 12px;
font-weight: bold;
}
.status-error {
font-size: 10px;
margin-top: 2px;
color: #721c24;
max-width: 200px;
word-wrap: break-word;
}
/* Status colors */
.status-pending {
background-color: #f8f9fa;
border: 1px solid #ddd;
}
.status-pending .status-amount,
.status-pending .status-text {
color: #6c757d;
}
.status-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
}
.status-success .status-amount,
.status-success .status-text {
color: #155724;
}
.status-failed {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
}
.status-failed .status-amount,
.status-failed .status-text {
color: #721c24;
}
/* Error message styles */
.error-container {
margin-top: 20px;
padding: 10px;
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
width: 80%;
margin: 20px auto 0;
}
.error-title {
font-weight: bold;
margin-right: 5px;
}
/* Upload area improvements */
.upload-area {
border: 2px dashed gray;
padding: 50px;
text-align: center;
margin: 50px auto 20px;
width: 200px;
transition: all 0.3s ease;
}
.upload-area:hover {
border-color: #007bff;
background-color: #f8f9ff;
}
/* Button improvements */
.action-button {
margin-top: 20px;
padding: 8px;
width: 100px;
font-size: 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.action-button:hover {
background-color: #0069d9;
}
.action-button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}

View File

@@ -1,20 +1,35 @@
import './App.css'; import './App.css';
import { read, utils } from "xlsx"; import { read, utils } from 'xlsx';
import { ethers, formatEther, parseEther } from "ethers"; import { ethers, formatEther, parseEther } from 'ethers';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { tokenAbi } from "./tokenAbi"; import { tokenAbi } from './tokenAbi';
const contractAddress = "0x1ebA64fDe3BF54545c86B9e3bB40c72f50f8D012"; const contractAddress = '0x1ebA64fDe3BF54545c86B9e3bB40c72f50f8D012';
function App() { function App() {
const abi = [{"inputs": [{"internalType": "address","name": "token","type": "address"},{"internalType": "address[]","name": "addresses","type": "address[]"},{"internalType": "uint256[]","name": "amounts","type": "uint256[]"},{"internalType": "bool","name": "isToken","type": "bool"}],"name": "batchTransferFrom","outputs": [{"internalType": "bool","name": "","type": "bool"}],"stateMutability": "payable","type": "function"}] const abi = [
{
inputs: [
{ internalType: 'address', name: 'token', type: 'address' },
{ internalType: 'address[]', name: 'addresses', type: 'address[]' },
{ internalType: 'uint256[]', name: 'amounts', type: 'uint256[]' },
{ internalType: 'bool', name: 'isToken', type: 'bool' },
],
name: 'batchTransferFrom',
outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
stateMutability: 'payable',
type: 'function',
},
];
const [from, setFrom] = useState(""); const [from, setFrom] = useState('');
const [toArray, setToArray] = useState([]); const [toArray, setToArray] = useState([]);
const [amountArray, setAmountArray] = useState([]); const [amountArray, setAmountArray] = useState([]);
const [transferStatuses, setTransferStatuses] = useState([]);
const [errorMessage, setErrorMessage] = useState('');
const isToken = true; const isToken = true;
const [provider, setProvider] = useState(); const [provider, setProvider] = useState();
const [text, setText] = useState("파일 업로드") const [text, setText] = useState('파일 업로드');
const tokenRef = useRef(null); const tokenRef = useRef(null);
const handleFile = (file) => { const handleFile = (file) => {
@@ -28,28 +43,36 @@ function App() {
const workbook = read(data, { type: 'array' }); const workbook = read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName];
const jsonData = utils.sheet_to_json(worksheet, {raw:true}); const jsonData = utils.sheet_to_json(worksheet, { raw: true });
const filteredData = jsonData.filter(row => { const filteredData = jsonData.filter((row) => {
return ( return (
typeof row.address === "string" && typeof row.address === 'string' &&
!row.address.toUpperCase().includes("EX") && !row.address.toUpperCase().includes('EX') &&
!String(row.amount).toUpperCase().includes("EX") !String(row.amount).toUpperCase().includes('EX')
); );
}); });
const addresses = filteredData.map((row) => row.address); const addresses = filteredData.map((row) => row.address.trim());
const amount = filteredData.map((row) => { const amount = filteredData.map((row) => {
// 소수점 2자리까지 반올림 처리 // 소수점 2자리까지 반올림 처리
const rounded = Math.round(Number(row.amount) * 100) / 100; const rounded = Math.round(Number(row.amount) * 100) / 100;
return parseEther(rounded.toFixed(2)); return parseEther(rounded.toFixed(2));
}); });
// Initialize transfer statuses for each address
const statuses = addresses.map(() => ({
status: 'pending',
message: '',
}));
setToArray(addresses); setToArray(addresses);
setAmountArray(amount); setAmountArray(amount);
setTransferStatuses(statuses);
setErrorMessage(''); // Clear any previous errors
} catch (error) { } catch (error) {
alert(error) setErrorMessage(`파일 처리 오류: ${error.message}`);
} }
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}; };
@@ -73,38 +96,40 @@ function App() {
e.preventDefault(); e.preventDefault();
}; };
useEffect(()=>{ useEffect(() => {
if (!window.ethereum) return alert("MetaMask not installed"); if (!window.ethereum)
setProvider(new ethers.BrowserProvider(window.ethereum)) return setErrorMessage('MetaMask가 설치되지 않았습니다.');
},[]) setProvider(new ethers.BrowserProvider(window.ethereum));
}, []);
const walletProvider = useCallback(async () => {
const walletProvider = useCallback(async ()=>{
try { try {
await window.ethereum.request({ await window.ethereum.request({
method: 'wallet_switchEthereumChain', method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x38' }] //0x38 params: [{ chainId: '0x38' }], //0x38 56
}); });
} catch (err) { } catch (err) {
await window.ethereum.request({ await window.ethereum.request({
method: 'wallet_addEthereumChain', method: 'wallet_addEthereumChain',
params: [{ params: [
{
chainId: '0x38', //0x38 chainId: '0x38', //0x38
chainName: 'BNB Smart Chain Mainnet', chainName: 'BNB Smart Chain Mainnet',
rpcUrls: ['https://binance.llamarpc.com'], rpcUrls: ['https://binance.llamarpc.com'],
nativeCurrency: { nativeCurrency: {
name: 'BNB', name: 'BNB',
symbol: 'BNB', symbol: 'BNB',
decimals: 18 decimals: 18,
}, },
blockExplorerUrls: ['https://bscscan.com/'] blockExplorerUrls: ['https://bscscan.com/'],
}] },
],
}); });
} }
await provider.send("eth_requestAccounts", []); await provider.send('eth_requestAccounts', []);
window.ethereum.on("accountsChanged", (accounts) => { window.ethereum.on('accountsChanged', (accounts) => {
if (accounts.length > 0) { if (accounts.length > 0) {
setFrom(accounts[0]); setFrom(accounts[0]);
} else { } else {
@@ -113,73 +138,91 @@ function App() {
}); });
const signer = provider.getSigner(); const signer = provider.getSigner();
const senderAddress = await signer; const senderAddress = await signer;
setFrom(senderAddress.address) setFrom(senderAddress.address);
},[provider]) }, [provider]);
useEffect(()=>{ useEffect(() => {
if(provider){ if (provider) {
walletProvider() walletProvider();
} }
return () => { return () => {
if (window.ethereum?.removeListener) { if (window.ethereum?.removeListener) {
window.ethereum.removeListener("accountsChanged", () => {}); window.ethereum.removeListener('accountsChanged', () => {});
window.ethereum.removeListener("chainChanged", () => {}); window.ethereum.removeListener('chainChanged', () => {});
} }
}; };
}, [provider, walletProvider]);
},[provider,walletProvider])
const sendToken = async () => { const sendToken = async () => {
const tokenAddress = tokenRef.current?.value; const tokenAddress = tokenRef.current?.value;
let totalAmount = 0; let totalAmount = 0;
amountArray.map((row)=> totalAmount += parseFloat(formatEther(row))); amountArray.map((row) => (totalAmount += parseFloat(formatEther(row))));
const signer = await provider.getSigner(); const signer = await provider.getSigner();
const contract = new ethers.Contract(contractAddress, abi, signer); const contract = new ethers.Contract(contractAddress, abi, signer);
const batchSize = 200; const batchSize = 100;
const loopCount = Math.ceil(toArray.length / batchSize); const loopCount = Math.ceil(toArray.length / batchSize);
let status; let status;
if(toArray.length !== 0 && amountArray.length !== 0 && toArray.length === amountArray.length) if (
{ toArray.length !== 0 &&
amountArray.length !== 0 &&
toArray.length === amountArray.length
) {
try { try {
const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, signer); const tokenContract = new ethers.Contract(
tokenAddress,
tokenAbi,
signer
);
let balance = isToken ? await tokenContract.balanceOf(from) : await provider.getBalance(from); let balance = isToken
console.log(balance, parseEther(totalAmount.toString())) ? await tokenContract.balanceOf(from)
if(balance >= parseEther(totalAmount.toString())){ : await provider.getBalance(from);
if(isToken) console.log(balance, parseEther(totalAmount.toString()));
{ if (balance >= parseEther(totalAmount.toString())) {
if (isToken) {
//token approve //token approve
const tx = await tokenContract.approve( const tx = await tokenContract.approve(
contractAddress, contractAddress,
parseEther(totalAmount.toString()) parseEther(totalAmount.toString())
) );
await provider.waitForTransaction(tx.hash); await provider.waitForTransaction(tx.hash);
} }
} else { } else {
throw new Error("잔액부족") throw new Error('잔액부족');
} }
} catch (error) { } catch (error) {
alert(error) console.error('failed to send');
setErrorMessage(`전송 준비 중 오류: ${error.message}`);
return;
} }
} else { } else {
alert("양식이 맞지 않습니다.") setErrorMessage('파일 양식이 맞지 않습니다. 주소와 금액을 확인해주세요.');
return;
} }
for (let i = 0; i < loopCount; i++) for (let i = 0; i < loopCount; i++) {
{
const start = i * batchSize; const start = i * batchSize;
const end = start + batchSize; const end = start + batchSize;
const chunkAddresses = toArray.slice(start, end); const chunkAddresses = toArray.slice(start, end);
const chunkAmounts = amountArray.slice(start, end); const chunkAmounts = amountArray.slice(start, end);
const chunkTotal = chunkAmounts.reduce((acc, amt) => acc + parseFloat(formatEther(amt)), 0); const chunkTotal = chunkAmounts.reduce(
console.log(tokenAddress, chunkAddresses, chunkAmounts, isToken,chunkTotal) (acc, amt) => acc + parseFloat(formatEther(amt)),
0
);
console.log(
tokenAddress,
chunkAddresses,
chunkAmounts,
isToken,
chunkTotal
);
//multisender //multisender
try { try {
@@ -188,22 +231,57 @@ function App() {
chunkAddresses, chunkAddresses,
chunkAmounts, chunkAmounts,
isToken, isToken,
{value : isToken ? 0 : parseEther(chunkTotal.toString())} { value: isToken ? 0 : parseEther(chunkTotal.toString()) }
); );
status = await provider.waitForTransaction(tx.hash); const result = await provider.waitForTransaction(tx.hash);
// Update statuses for this batch
if (result && result.status === 1) {
// Success - update all addresses in this batch to success
setTransferStatuses((prev) => {
const newStatuses = [...prev];
for (let j = start; j < end && j < newStatuses.length; j++) {
newStatuses[j] = { status: 'success', message: '전송 성공' };
}
return newStatuses;
});
} else {
// Failed - update all addresses in this batch to failed
setTransferStatuses((prev) => {
const newStatuses = [...prev];
for (let j = start; j < end && j < newStatuses.length; j++) {
newStatuses[j] = { status: 'failed', message: '트랜잭션 실패' };
}
return newStatuses;
});
}
status = result;
} catch (error) { } catch (error) {
alert(error) // Update all addresses in this batch to failed with error message
setTransferStatuses((prev) => {
const newStatuses = [...prev];
for (let j = start; j < end && j < newStatuses.length; j++) {
newStatuses[j] = {
status: 'failed',
message: `전송 실패: ${error.message || '알 수 없는 오류'}`,
};
}
return newStatuses;
});
setErrorMessage(
`배치 전송 실패: ${error.message || '알 수 없는 오류'}`
);
}
} }
} if (status) {
setText('파일 업로드');
if(status){
setText("파일 업로드")
setToArray([]); setToArray([]);
setAmountArray([]); setAmountArray([]);
} }
}; };
return ( return (
@@ -211,11 +289,11 @@ function App() {
<h1>Admin MultiSender</h1> <h1>Admin MultiSender</h1>
<div <div
style={{ style={{
width : "600px", width: '600px',
height : "100px", height: '100px',
margin : "0 Auto", margin: '0 Auto',
display : "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
}} }}
> >
{/* <div> {/* <div>
@@ -223,37 +301,103 @@ function App() {
<input type="checkbox" onChange={handleCheckboxChange} /> <input type="checkbox" onChange={handleCheckboxChange} />
</div> */} </div> */}
<div> <div>
<span style={{fontSize: "18px"}}>토큰 컨트랙트 : </span> <span style={{ fontSize: '18px' }}>토큰 컨트랙트 : </span>
<input type="text" ref={tokenRef} placeholder='' style={{fontSize: "18px", marginTop : "10px", width : "450px"}} defaultValue={"0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18"}/> <input
type="text"
ref={tokenRef}
placeholder=""
style={{ fontSize: '18px', marginTop: '10px', width: '450px' }}
defaultValue={'0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18'}
/>
</div> </div>
<div> <div>
<span style={{fontSize: "18px"}}>보내는 계좌 : </span> <span style={{ fontSize: '18px' }}>보내는 계좌 : </span>
<input type='text' placeholder={from} style={{fontSize: "18px", marginTop : "10px", width: "450px", border: "none"}} disabled /> <input
type="text"
placeholder={from}
style={{
fontSize: '18px',
marginTop: '10px',
width: '450px',
border: 'none',
}}
disabled
/>
</div> </div>
</div> </div>
<input type="file" id="fileInput" style={{ display: "none" }} onChange={handleFileChange} /> <input
type="file"
id="fileInput"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<label htmlFor="fileInput"> <label htmlFor="fileInput">
<div <div
id="upload" id="upload"
className="upload-area"
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
style={{
// display : toArray.length == 0 ? "block" : "none",
border: '2px dashed gray',
padding: '50px',
textAlign: 'center',
marginTop: '50px',
marginBottom: '20px',
margin: "0 Auto",
width: "200px"
}}
> >
{text} {text}
</div> </div>
</label> </label>
<button type='button' onClick={sendToken} style={{marginTop : "20px", padding: "8px", width: "100px", fontSize: "20px"}}>전송</button> <button type="button" className="action-button" onClick={sendToken}>
전송
</button>
{/* Display addresses with transfer status */}
{toArray.length > 0 && (
<div className="status-container">
<h3 className="status-header">전송 상태</h3>
<div className="status-list">
{toArray.map((address, index) => (
<div
key={index}
className={`status-item status-${
transferStatuses[index]?.status || 'pending'
}`}
>
<span className="status-address">{address}</span>
<div className="status-details">
<div className="status-amount">
{formatEther(amountArray[index])} 토큰
</div>
<div className="status-text">
{transferStatuses[index]?.status === 'success'
? '✓ 성공'
: transferStatuses[index]?.status === 'failed'
? '✗ 실패'
: '대기 중'}
</div>
{transferStatuses[index]?.status === 'failed' &&
transferStatuses[index]?.message && (
<div
className="status-error"
style={{
fontSize: '10px',
marginTop: '2px',
color: '#721c24',
}}
>
{transferStatuses[index].message}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Error message display */}
{errorMessage && (
<div className="error-container">
<span className="error-title">오류:</span>
{errorMessage}
</div>
)}
</div> </div>
); );
} }

View File

@@ -14,4 +14,4 @@ root.render(
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log)) // to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals(); reportWebVitals(console.log);