From 20eb3a0e81a4d974b76513e522a0e49b3395ba0a Mon Sep 17 00:00:00 2001 From: gyeongcheol1 Date: Thu, 20 Nov 2025 16:25:41 +0900 Subject: [PATCH] Add multisender functionality with UI components and blockchain integration --- multisender_sample.html | 237 ++++++++++++++++++++ multisender_sample.html:Zone.Identifier | 3 + src/App.jsx | 281 ++++++++++++------------ src/component/list.jsx | 59 +++++ src/component/table.jsx | 26 +++ src/index.css | 217 ++++++++++++++++++ src/utile/blockchain.js | 72 ++++++ src/utile/chunk.js | 33 +++ src/utile/setComma.js | 24 ++ 9 files changed, 813 insertions(+), 139 deletions(-) create mode 100644 multisender_sample.html create mode 100644 multisender_sample.html:Zone.Identifier create mode 100644 src/component/list.jsx create mode 100644 src/component/table.jsx create mode 100644 src/utile/blockchain.js create mode 100644 src/utile/chunk.js create mode 100644 src/utile/setComma.js diff --git a/multisender_sample.html b/multisender_sample.html new file mode 100644 index 0000000..24771bd --- /dev/null +++ b/multisender_sample.html @@ -0,0 +1,237 @@ + + +MULTI SENDER + + + + + +
+ +
+ UPLOAD + +
    +
  • + + +
  • + +
  • + + + +
  • +
+
+ +
+ DETAIL + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목1항목2항목3항목4항목5
값1값2값3값41.25
항목5 합계1.25
+
+ +
+ + + + + + diff --git a/multisender_sample.html:Zone.Identifier b/multisender_sample.html:Zone.Identifier new file mode 100644 index 0000000..995dee2 --- /dev/null +++ b/multisender_sample.html:Zone.Identifier @@ -0,0 +1,3 @@ +[ZoneTransfer] +ZoneId=3 +HostUrl=https://files.slack.com/files-pri/T08K8CRTBD0-F09RSV6BGPK/download/multisender_sample.html?origin_team=T08K8CRTBD0 diff --git a/src/App.jsx b/src/App.jsx index ce0f70b..ddac2f8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,25 +1,25 @@ import './App.css'; import { read, utils } from "xlsx"; -import { ethers, formatEther, parseEther } from "ethers"; +import { ethers, parseEther,formatEther } from "ethers"; import { useCallback, useEffect, useRef, useState } from 'react'; -import { tokenAbi } from "./tokenAbi"; - -const contractAddress = "0x1ebA64fDe3BF54545c86B9e3bB40c72f50f8D012"; +import { Table } from './component/table'; +import { getBalance, multisend } from './utile/blockchain'; +import { divide } from './utile/chunk'; +import { setComma } from './utile/setComma'; +import { List } from './component/list'; 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 [from, setFrom] = useState(""); - const [toArray, setToArray] = useState([]); - const [amountArray, setAmountArray] = useState([]); - const isToken = true; const [provider, setProvider] = useState(); - const [text, setText] = useState("파일 업로드") const tokenRef = useRef(null); + const [chunkArray, setChunkArray] = useState([]); + const [selectedChunks, setSelectedChunks] = useState([]); // 선택된 청크 인덱스 배열 + const [balance, setBalance] = useState(0); + const [totalAmount, setTotalAmount] = useState(0); const handleFile = (file) => { if (!file) return; - setText(file.name); const reader = new FileReader(); reader.onload = (evt) => { @@ -29,24 +29,40 @@ function App() { const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData = utils.sheet_to_json(worksheet, {raw:true}); + let total = totalAmount; const filteredData = jsonData.filter(row => { - return ( - typeof row.address === "string" && - !row.address.toUpperCase().includes("EX") && - !String(row.amount).toUpperCase().includes("EX") - ); + if (typeof row.address !== "string" || + row.address.toUpperCase().includes("EX") || + String(row.amount).toUpperCase().includes("EX")) { + return false; + } + + try { + ethers.getAddress(row.address.split("\r\n").join("").trim()); + return true; + } catch (error) { + console.warn(`Invalid address: ${row.address}`, error); + return false; + } }); - const addresses = filteredData.map((row) => row.address); + const addresses = filteredData.map((row) => { + const a = row.address; + return a; + }); const amount = filteredData.map((row) => { - // 소수점 2자리까지 반올림 처리 const rounded = Math.round(Number(row.amount) * 100) / 100; + total += rounded return parseEther(rounded.toFixed(2)); }); - setToArray(addresses); - setAmountArray(amount); + + setChunkArray(prev => [...prev, ...divide(addresses, amount)]); + console.log(total) + setTotalAmount(total) + } catch (error) { + console.error("Error:", error); alert(error) } @@ -54,25 +70,21 @@ function App() { reader.readAsArrayBuffer(file); }; - const handleDrop = (e) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - handleFile(file); - }; - const handleFileChange = (e) => { const file = e.target.files[0]; handleFile(file); }; - // const handleCheckboxChange = (e) => { - // setIsToken(e.target.checked); - // }; - const handleDragOver = (e) => { e.preventDefault(); }; + const handleDrop = (e) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + handleFile(file); + }; + useEffect(()=>{ if (!window.ethereum) return alert("MetaMask not installed"); setProvider(new ethers.BrowserProvider(window.ethereum)) @@ -103,7 +115,7 @@ function App() { } await provider.send("eth_requestAccounts", []); - + window.ethereum.on("accountsChanged", (accounts) => { if (accounts.length > 0) { setFrom(accounts[0]); @@ -113,7 +125,13 @@ function App() { }); const signer = provider.getSigner(); const senderAddress = await signer; + + const tokenAddress = tokenRef.current?.value; + const balance = await getBalance(tokenAddress, provider, senderAddress) + + setBalance(balance) setFrom(senderAddress.address) + },[provider]) useEffect(()=>{ @@ -128,134 +146,119 @@ function App() { } }; - },[provider,walletProvider]) + },[provider, walletProvider]) + + // 체크박스 토글 + const toggleChunkSelection = (idx) => { + if (chunkArray[idx].status === 0) return; + + setSelectedChunks(prev => { + if (prev.includes(idx)) { + return prev.filter(i => i !== idx); + } else { + return [...prev, idx]; + } + }); + }; const sendToken = async () => { const tokenAddress = tokenRef.current?.value; + let failedIdxs = []; - let totalAmount = 0; - amountArray.map((row)=> totalAmount += parseFloat(formatEther(row))); + for (const idx of selectedChunks) { + const chunk = chunkArray[idx]; - const signer = await provider.getSigner(); - const contract = new ethers.Contract(contractAddress, abi, signer); - - const batchSize = 200; - const loopCount = Math.ceil(toArray.length / batchSize); - - let status; - - if(toArray.length !== 0 && amountArray.length !== 0 && toArray.length === amountArray.length) - { try { - const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, signer); - - let balance = isToken ? await tokenContract.balanceOf(from) : await provider.getBalance(from); - console.log(balance, parseEther(totalAmount.toString())) - if(balance >= parseEther(totalAmount.toString())){ - if(isToken) - { - //token approve - const tx = await tokenContract.approve( - contractAddress, - parseEther(totalAmount.toString()) - ) - await provider.waitForTransaction(tx.hash); - } - } else { - throw new Error("잔액부족") - } - } catch (error) { - alert(error) - } - } else { - alert("양식이 맞지 않습니다.") - } - - for (let i = 0; i < loopCount; i++) - { - const start = i * batchSize; - const end = start + batchSize; - - const chunkAddresses = toArray.slice(start, end); - const chunkAmounts = amountArray.slice(start, end); - const chunkTotal = chunkAmounts.reduce((acc, amt) => acc + parseFloat(formatEther(amt)), 0); - console.log(tokenAddress, chunkAddresses, chunkAmounts, isToken,chunkTotal) - - //multisender - try { - const tx = await contract.batchTransferFrom( + const status = await multisend( tokenAddress, - chunkAddresses, - chunkAmounts, - isToken, - {value : isToken ? 0 : parseEther(chunkTotal.toString())} + provider, + from, + chunk.chunkAddresses, + chunk.chunkAmounts ); - status = await provider.waitForTransaction(tx.hash); + if (status) { + setChunkArray(prev => prev.map((c, i) => + i === idx ? {...c, status: 0} : c + )); + } else { + setChunkArray(prev => prev.map((c, i) => + i === idx ? {...c, status: 1} : c + )); + failedIdxs.push(idx) + + } } catch (error) { - alert(error) + console.error(`Chunk ${idx + 1} 전송 실패:`, error); + alert(`Chunk ${idx + 1} 전송 중 오류가 발생했습니다.`); + break; } - } - - if(status){ - setText("파일 업로드") - setToArray([]); - setAmountArray([]); - } - + // 전송 완료 + setSelectedChunks(failedIdxs); }; return (
-

Admin MultiSender

-
- {/*
- 토큰 전송 - -
*/} -
- 토큰 컨트랙트 : - -
-
- 보내는 계좌 : - -
-
- - - +
+
+ UPLOAD - +
    +
  • + + +
  • + +
  • + + +
  • + +
  • +
    + + + +
    +
  • +
+

balance : {setComma(formatEther(balance))}

+

totalAmount : {setComma(totalAmount)}

+ + + + + +
+ +
+ DETAIL + +
+ {selectedChunks.length > 0 ? ( + chunkArray[idx].chunkAddresses)} + amounts={selectedChunks.flatMap(idx => chunkArray[idx].chunkAmounts)} + /> + ) : ''} + + + ); } export default App; + diff --git a/src/component/list.jsx b/src/component/list.jsx new file mode 100644 index 0000000..455b719 --- /dev/null +++ b/src/component/list.jsx @@ -0,0 +1,59 @@ +import { setComma } from "../utile/setComma" + +export const List = ({ chunkArray,selectedChunks,toggleChunkSelection }) => { + return ( + <> + {chunkArray.length > 0 && ( +
+

Chunks ({chunkArray.length})

+ {chunkArray.map((chunk, idx) => ( +
+
+ toggleChunkSelection(idx)} + disabled={chunk.status === 0} + style={{ + marginRight: "10px", + cursor: chunk.status === 0 ? "not-allowed" : "pointer" + }} + /> + + Chunk {idx + 1}: {chunk.chunkAddresses.length} addresses + - Total: {setComma(chunk.chunkTotal.toFixed(2))} tokens + +
+ {chunk.status === 0 ? ( + + ✓ 전송완료 + + ) : + chunk.status === 1 ? + ( + + X 전송실패 + + ) : '' + } +
+ ))} +
+ )} + + ) +} + + diff --git a/src/component/table.jsx b/src/component/table.jsx new file mode 100644 index 0000000..1b83e6c --- /dev/null +++ b/src/component/table.jsx @@ -0,0 +1,26 @@ +import { formatEther } from "ethers"; + +export const Table = ({ addresses, amounts }) => { + return ( +
+ + + + + + + + { + addresses?.map((address, index) => ( + + + + + )) + } + +
AddressAmount
{address}{formatEther(amounts[index])}
+ ) +} + + diff --git a/src/index.css b/src/index.css index ec2585e..d8b9784 100644 --- a/src/index.css +++ b/src/index.css @@ -11,3 +11,220 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +:root { + --gap: 20px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + margin: 0; + font-family: sans-serif; + font-size: 12px; + scroll-snap-type: y mandatory; + scroll-behavior: smooth; + scroll-padding-top: 0; + overscroll-behavior: contain; +} + +html, body { + margin: 0; + font-family: sans-serif; +} + +.wrap { + display: flex; + flex-direction: row; + justify-content: center; + align-items: start; + padding: var(--gap); + gap: var(--gap); +} + +.wrap fieldset { + flex-grow: 1; + padding: 1.5rem; + border: 1px solid #CCC; + border-radius: 0.5rem; + height: calc(100vh - 80px); + overflow: auto; +} + +.wrap fieldset:first-child { + flex-grow: 0; + flex-basis: 500px; +} + +.wrap fieldset legend { + font-size: 1.5rem; + font-weight: bold; + padding: 0 2rem 0 .75rem; +} + + +fieldset ul { + list-style: none; + padding: 0; +} + +fieldset ul > li { + display: flex; + flex-direction: column; + margin-bottom: 2rem; +} + +fieldset label { + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 0.5rem; + padding-left: 0.375rem; +} + +fieldset div label { + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 0.5rem; + padding-left: 0.375rem; +} + +fieldset label+input { + display: block; + width: 100%; + height: 3rem; + padding: .375rem .75rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #495057; + background-color: #fff; + background-clip: padding-box; + border: 1px solid #ced4da; + border-radius: .5rem; + transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; +} + +fieldset div label+input { + display: block; + width: 100%; + height: 3rem; + padding: .375rem .75rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #495057; + background-color: #fff; + background-clip: padding-box; + border: 1px solid #ced4da; + border-radius: .5rem; + transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; +} + +fieldset label+input:focus { + color: #495057; + background-color: #fff; + border-color: #808080; + outline: 0; + box-shadow: 0 0 0 .2rem rgba(100, 100, 100, .25); +} + +fieldset label+input[type="file"] { + display: none; +} + +fieldset input[type="file"]+label { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 10rem; + border: 2px dotted #CCC; + border-radius: .5rem; +} + +fieldset input[type="file"]+label::before { + content: '업로드 할 파일을 선택하거나 드레그&드롭하세요.'; + color: rgba(0,0,0,.5); + font-weight: 400; + text-shadow: 0 0 2px rgba(0,0,0,.7); +} + +fieldset table { + display: table; + width: 100%; + caption-side: bottom; + border-collapse: separate; + box-sizing: border-box; + border-spacing: 1px; + border-color: gray; + background-color: #aaa; + font-size: 1.2rem; + margin-bottom: 30px; +} + +fieldset thead { + background-color: rgba(0,0,0,.7); + border-bottom: 2px solid rgba(0,0,0,1); + color: white; +} +fieldset th { + text-align: center; + padding: .375rem .725rem; +} +fieldset td { + background-color: rgba(255,255,255,.9); + padding: .2rem .5rem; + font-size: 1rem; +} + +fieldset tr[row-type="sum"] { + border-top: 2px solid #aaa; +} + +[data-type="number"] { + text-align: right; + padding: .2rem 1rem; +} + +/* row 처리 */ +.flex-row { + position: relative; + display: flex; + flex-direction: row; + gap: 20px; +} + +.flex-row > table { + flex-grow: 1; +} + + +@media screen and (max-width: 1200px) { + + .wrap { + flex-direction: column; + } + + .wrap fieldset { + flex-grow: 1 !important; + flex-basis: unset; + width: 100%; + height: unset; + } + + .flex-row { + flex-direction: column; + } +} + +.transferButton { + margin-top: 20px; + padding: 8px; + width: 100px; + font-size: 20px; +} + diff --git a/src/utile/blockchain.js b/src/utile/blockchain.js new file mode 100644 index 0000000..c5c065c --- /dev/null +++ b/src/utile/blockchain.js @@ -0,0 +1,72 @@ +import { tokenAbi } from "../tokenAbi" +import { ethers,formatEther,parseEther } from "ethers"; + +const contractAddress = "0x1ebA64fDe3BF54545c86B9e3bB40c72f50f8D012"; +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"}] + + +export const multisend = async (tokenAddress, provider, from, toArray, amountArray) => { + let totalAmount = 0; + let isToken = true; + let status; + + //console.log(tokenAddress, provider, from) + + amountArray.map((row)=> totalAmount += parseFloat(formatEther(row))); + + const signer = await provider.getSigner(); + const contract = new ethers.Contract(contractAddress, abi, signer); + + if(toArray.length !== 0 && amountArray.length !== 0 && toArray.length === amountArray.length) + { + try { + const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, signer); + + let balance = isToken ? await tokenContract.balanceOf(from) : await provider.getBalance(from); + if(balance >= parseEther(totalAmount.toString())){ + if(isToken) + { + ////token approve + // const tx = await tokenContract.approve( + // contractAddress, + // parseEther(totalAmount.toString()) + // ) + // await provider.waitForTransaction(tx.hash); + } + } else { + throw new Error("잔액부족") + } + } catch (error) { + alert(error) + } + + try { + const tx = await contract.batchTransferFrom( + tokenAddress, + toArray, + amountArray, + isToken, + {value : isToken ? 0 : parseEther(totalAmount.toString())} + ); + + status = await provider.waitForTransaction(tx.hash); + } catch (error) { + console.log(error) + } + } else { + alert("양식이 맞지 않습니다.") + } + + + return status; +} + +export const getBalance = async (tokenAddress, provider, address) => { + const signer = await provider.getSigner(); + + const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, signer); + + let balance = await tokenContract.balanceOf(address) + + return balance; +} \ No newline at end of file diff --git a/src/utile/chunk.js b/src/utile/chunk.js new file mode 100644 index 0000000..237d8db --- /dev/null +++ b/src/utile/chunk.js @@ -0,0 +1,33 @@ +import { formatEther } from "ethers" + + +export const divide = (toArray, amountArray) => { + const bachArray = []; + + const batchSize = 200; + const loopCount = Math.ceil(toArray.length / batchSize); + + for (let i = 0; i < loopCount; i++) + { + const start = i * batchSize; + const end = start + batchSize; + + const chunkAddresses = toArray.slice(start, end) + .map(addr => addr.trim()) + const chunkAmounts = amountArray.slice(start, end); + const chunkTotal = chunkAmounts.reduce((acc, amt) => acc + parseFloat(formatEther(amt)), 0); + + let chunkData = + { + chunkAddresses, + chunkAmounts, + chunkTotal, + status: 2 // 전송 완료 여부 + } + + bachArray.push(chunkData) + } + + return bachArray + +} \ No newline at end of file diff --git a/src/utile/setComma.js b/src/utile/setComma.js new file mode 100644 index 0000000..93d45de --- /dev/null +++ b/src/utile/setComma.js @@ -0,0 +1,24 @@ +export const setComma = (value) => { + if (value == null) return "0"; + + const num = typeof value === "string" ? parseFloat(value) : value; + + if (isNaN(num)) return value; + + return num.toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }); +}; + +export const setCommaRegex = (value) => { + if (value == null) return "0"; + + const str = String(value); + const parts = str.split("."); + + // 정수 부분에 콤마 추가 + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + + return parts.join("."); +}; \ No newline at end of file