1 Commits

11 changed files with 846 additions and 468 deletions

237
multisender_sample.html Normal file
View File

@@ -0,0 +1,237 @@
<html>
<head>
<title>MULTI SENDER</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no">
<style>
: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);
}
.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 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;
}
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;
}
@media screen and (max-width: 1200px) {
.wrap {
flex-direction: column;
}
.wrap fieldset {
flex-grow: 1 !important;
flex-basis: unset;
width: 100%;
height: unset;
}
}
</style>
</head>
<body>
<div class="wrap">
<fieldset>
<legend>UPLOAD</legend>
<ul>
<li>
<label for="contract">토큰 컨트랙트</label>
<input type="text" id="contract" value="0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18" />
</li>
<li>
<label for="file">보내는 계좌</label>
<input type="file" id="file" accept=".xls,.xlsx,.csv" />
<label for="file"></label>
</li>
</ul>
</fieldset>
<fieldset>
<legend>DETAIL</legend>
<table>
<thead>
<tr>
<th>항목1</th>
<th>항목2</th>
<th>항목3</th>
<th>항목4</th>
<th>항목5</th>
</tr>
</thead>
<tbody>
<tr>
<td>값1</td>
<td>값2</td>
<td>값3</td>
<td>값4</td>
<td data-type="number">1.25</td>
</tr>
<tr row-type="sum">
<td colspan="4">항목5 합계</td>
<td data-type="number">1.25</td>
</tr>
</tbody>
</table>
</fieldset>
</div>
<script>
</script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
[ZoneTransfer]
ZoneId=3
HostUrl=https://files.slack.com/files-pri/T08K8CRTBD0-F09RSV6BGPK/download/multisender_sample.html?origin_team=T08K8CRTBD0

View File

@@ -36,155 +36,3 @@
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,40 +1,25 @@
import './App.css';
import { read, utils } from 'xlsx';
import { ethers, formatEther, parseEther } from 'ethers';
import { read, utils } from "xlsx";
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 [transferStatuses, setTransferStatuses] = useState([]);
const [errorMessage, setErrorMessage] = useState('');
const isToken = true;
const [from, setFrom] = useState("");
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) => {
@@ -44,92 +29,94 @@ 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')
);
const filteredData = jsonData.filter(row => {
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.trim());
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));
});
// Initialize transfer statuses for each address
const statuses = addresses.map(() => ({
status: 'pending',
message: '',
}));
setChunkArray(prev => [...prev, ...divide(addresses, amount)]);
console.log(total)
setTotalAmount(total)
setToArray(addresses);
setAmountArray(amount);
setTransferStatuses(statuses);
setErrorMessage(''); // Clear any previous errors
} catch (error) {
setErrorMessage(`파일 처리 오류: ${error.message}`);
console.error("Error:", error);
alert(error)
}
};
reader.readAsArrayBuffer(file);
};
const handleFileChange = (e) => {
const file = e.target.files[0];
handleFile(file);
};
const handleDragOver = (e) => {
e.preventDefault();
};
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();
};
useEffect(()=>{
if (!window.ethereum)
return setErrorMessage('MetaMask가 설치되지 않았습니다.');
setProvider(new ethers.BrowserProvider(window.ethereum));
}, []);
if (!window.ethereum) return alert("MetaMask not installed");
setProvider(new ethers.BrowserProvider(window.ethereum))
},[])
const walletProvider = useCallback(async ()=>{
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x38' }], //0x38 56
params: [{ chainId: '0x38' }] //0x38
});
} catch (err) {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [
{
params: [{
chainId: '0x38', //0x38
chainName: 'BNB Smart Chain Mainnet',
rpcUrls: ['https://binance.llamarpc.com'],
nativeCurrency: {
name: '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) {
setFrom(accounts[0]);
} else {
@@ -138,268 +125,140 @@ function App() {
});
const signer = provider.getSigner();
const senderAddress = await signer;
setFrom(senderAddress.address);
}, [provider]);
const tokenAddress = tokenRef.current?.value;
const balance = await getBalance(tokenAddress, provider, senderAddress)
setBalance(balance)
setFrom(senderAddress.address)
},[provider])
useEffect(()=>{
if(provider){
walletProvider();
walletProvider()
}
return () => {
if (window.ethereum?.removeListener) {
window.ethereum.removeListener('accountsChanged', () => {});
window.ethereum.removeListener('chainChanged', () => {});
window.ethereum.removeListener("accountsChanged", () => {});
window.ethereum.removeListener("chainChanged", () => {});
}
};
}, [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 = 100;
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(
const status = await multisend(
tokenAddress,
tokenAbi,
signer
provider,
from,
chunk.chunkAddresses,
chunk.chunkAmounts
);
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) {
console.error('failed to send');
setErrorMessage(`전송 준비 중 오류: ${error.message}`);
return;
}
} else {
setErrorMessage('파일 양식이 맞지 않습니다. 주소와 금액을 확인해주세요.');
return;
}
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(
tokenAddress,
chunkAddresses,
chunkAmounts,
isToken,
{ value: isToken ? 0 : parseEther(chunkTotal.toString()) }
);
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) {
// 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('파일 업로드');
setToArray([]);
setAmountArray([]);
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) {
console.error(`Chunk ${idx + 1} 전송 실패:`, error);
alert(`Chunk ${idx + 1} 전송 중 오류가 발생했습니다.`);
break;
}
}
// 전송 완료
setSelectedChunks(failedIdxs);
};
return (
<div className="App">
<h1>Admin MultiSender</h1>
<div
<div className="wrap">
<fieldset>
<legend>UPLOAD</legend>
<ul>
<li>
<label htmlFor="tokenAddress">토큰 컨트랙트</label>
<input type="text" id="tokenAddress" defaultValue="0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18" ref={tokenRef}/>
</li>
<li>
<label htmlFor="contract">발송 주소</label>
<input type="text" id="contract" value={from} disabled/>
</li>
<li>
<div onDragOver={handleDragOver} onDrop={handleDrop}>
<label htmlFor="file">보내는 계좌</label>
<input type="file" id="file" accept=".xls,.xlsx,.csv" onChange={handleFileChange} onDrop={handleDrop} onDragOver={handleDragOver}/>
<label htmlFor="file"></label>
</div>
</li>
</ul>
<p>balance : {setComma(formatEther(balance))}</p>
<p>totalAmount : {setComma(totalAmount)}</p>
<List chunkArray={chunkArray} selectedChunks={selectedChunks} toggleChunkSelection={toggleChunkSelection}></List>
<button
className='transferButton'
type='button'
onClick={sendToken}
disabled={selectedChunks.length === 0}
style={{
width: '600px',
height: '100px',
margin: '0 Auto',
display: 'flex',
flexDirection: 'column',
opacity: selectedChunks.length === 0 ? 0.5 : 1,
cursor: selectedChunks.length === 0 ? "not-allowed" : "pointer"
}}
>
{/* <div>
<span style={{fontSize: "18px"}}>토큰 전송 </span>
<input type="checkbox" onChange={handleCheckboxChange} />
</div> */}
<div>
<span style={{ fontSize: '18px' }}>토큰 컨트랙트 : </span>
<input
type="text"
ref={tokenRef}
placeholder=""
style={{ fontSize: '18px', marginTop: '10px', width: '450px' }}
defaultValue={'0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18'}
/>
</div>
<div>
<span style={{ fontSize: '18px' }}>보내는 계좌 : </span>
<input
type="text"
placeholder={from}
style={{
fontSize: '18px',
marginTop: '10px',
width: '450px',
border: 'none',
}}
disabled
/>
</div>
</div>
<input
type="file"
id="fileInput"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<label htmlFor="fileInput">
<div
id="upload"
className="upload-area"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{text}
</div>
</label>
<button type="button" className="action-button" onClick={sendToken}>
전송
전송 ({selectedChunks.length})
</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>
)}
</fieldset>
{/* Error message display */}
{errorMessage && (
<div className="error-container">
<span className="error-title">오류:</span>
{errorMessage}
<fieldset>
<legend>DETAIL</legend>
<div className="flex-row">
{selectedChunks.length > 0 ? (
<Table
addresses={selectedChunks.flatMap(idx => chunkArray[idx].chunkAddresses)}
amounts={selectedChunks.flatMap(idx => chunkArray[idx].chunkAmounts)}
/>
) : ''}
</div>
</fieldset>
</div>
)}
</div>
);
}
export default App;

59
src/component/list.jsx Normal file
View File

@@ -0,0 +1,59 @@
import { setComma } from "../utile/setComma"
export const List = ({ chunkArray,selectedChunks,toggleChunkSelection }) => {
return (
<>
{chunkArray.length > 0 && (
<div style={{marginTop: "20px"}}>
<h3>Chunks ({chunkArray.length})</h3>
{chunkArray.map((chunk, idx) => (
<div
key={idx}
style={{
padding: "10px",
margin: "5px 0",
border: "1px solid #ddd",
backgroundColor: chunk.status === 0 ? "#f0f0f0" : "white",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
>
<div style={{display: "flex", alignItems: "center"}}>
<input
type="checkbox"
checked={selectedChunks.includes(idx)}
onChange={() => toggleChunkSelection(idx)}
disabled={chunk.status === 0}
style={{
marginRight: "10px",
cursor: chunk.status === 0 ? "not-allowed" : "pointer"
}}
/>
<span style={{color: chunk.status === 0 ? "#999" : "black"}}>
Chunk {idx + 1}: {chunk.chunkAddresses.length} addresses
- Total: {setComma(chunk.chunkTotal.toFixed(2))} tokens
</span>
</div>
{chunk.status === 0 ? (
<span style={{color: "green", fontWeight: "bold"}}>
전송완료
</span>
) :
chunk.status === 1 ?
(
<span style={{color: "red", fontWeight: "bold"}}>
X 전송실패
</span>
) : ''
}
</div>
))}
</div>
)}
</>
)
}

26
src/component/table.jsx Normal file
View File

@@ -0,0 +1,26 @@
import { formatEther } from "ethers";
export const Table = ({ addresses, amounts }) => {
return (
<table>
<thead>
<tr>
<th >Address</th>
<th >Amount</th>
</tr>
</thead>
<tbody>
{
addresses?.map((address, index) => (
<tr key={index}>
<td >{address}</td>
<td >{formatEther(amounts[index])}</td>
</tr>
))
}
</tbody>
</table>
)
}

View File

@@ -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;
}

View File

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

72
src/utile/blockchain.js Normal file
View File

@@ -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;
}

33
src/utile/chunk.js Normal file
View File

@@ -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
}

24
src/utile/setComma.js Normal file
View File

@@ -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(".");
};