1 Commits

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

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ package-lock.json
# testing # testing
/coverage /coverage
# production
/build
# misc # misc
.DS_Store .DS_Store
.env.local .env.local

View File

@@ -1,15 +0,0 @@
{
"files": {
"main.css": "/static/css/main.6d74c8c6.css",
"main.js": "/static/js/main.040a5bac.js",
"static/js/453.41fba699.chunk.js": "/static/js/453.41fba699.chunk.js",
"index.html": "/index.html",
"main.6d74c8c6.css.map": "/static/css/main.6d74c8c6.css.map",
"main.040a5bac.js.map": "/static/js/main.040a5bac.js.map",
"453.41fba699.chunk.js.map": "/static/js/453.41fba699.chunk.js.map"
},
"entrypoints": [
"static/css/main.6d74c8c6.css",
"static/js/main.040a5bac.js"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Admin MultiSender</title><script defer="defer" src="/static/js/main.040a5bac.js"></script><link href="/static/css/main.6d74c8c6.css" rel="stylesheet"></head><body><div id="root"></div></body></html>

View File

@@ -1,8 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -1,2 +0,0 @@
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}:root{--gap:20px}*{box-sizing:border-box;margin:0;padding:0}html{font-size:12px;overscroll-behavior:contain;scroll-behavior:smooth;scroll-padding-top:0;scroll-snap-type:y mandatory}body,html{font-family:sans-serif;margin:0}.wrap{align-items:start;display:flex;flex-direction:row;gap:20px;gap:var(--gap);justify-content:center;padding:20px;padding:var(--gap)}.wrap fieldset{border:1px solid #ccc;border-radius:.5rem;flex-grow:1;height:calc(100vh - 80px);overflow:auto;padding:1.5rem}.wrap fieldset:first-child{flex-basis:500px;flex-grow:0}.wrap fieldset legend{font-size:1.5rem;font-weight:700;padding:0 2rem 0 .75rem}fieldset ul{list-style:none;padding:0}fieldset ul>li{display:flex;flex-direction:column;margin-bottom:2rem}fieldset div label,fieldset label{font-size:1.2rem;font-weight:700;margin-bottom:.5rem;padding-left:.375rem}fieldset div label+input,fieldset label+input{background-clip:padding-box;background-color:#fff;border:1px solid #ced4da;border-radius:.5rem;color:#495057;display:block;font-size:1rem;font-weight:400;height:3rem;line-height:1.5;padding:.375rem .75rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;width:100%}fieldset label+input:focus{background-color:#fff;border-color:grey;box-shadow:0 0 0 .2rem #64646440;color:#495057;outline:0}fieldset label+input[type=file]{display:none}fieldset input[type=file]+label{align-items:center;border:2px dotted #ccc;border-radius:.5rem;display:flex;flex-direction:column;height:10rem;justify-content:center}fieldset input[type=file]+label:before{color:#00000080;content:"업로드 할 파일을 선택하거나 드레그&드롭하세요.";font-weight:400;text-shadow:0 0 2px #000000b3}fieldset table{background-color:#aaa;border-collapse:initial;border-color:gray;border-spacing:1px;box-sizing:border-box;caption-side:bottom;display:table;font-size:1.2rem;margin-bottom:30px;width:100%}fieldset thead{background-color:#000000b3;border-bottom:2px solid #000;color:#fff}fieldset th{padding:.375rem .725rem;text-align:center}fieldset td{background-color:#ffffffe6;font-size:1rem;padding:.2rem .5rem}fieldset tr[row-type=sum]{border-top:2px solid #aaa}[data-type=number]{padding:.2rem 1rem;text-align:right}.flex-row{display:flex;flex-direction:row;gap:20px;position:relative}.flex-row>table{flex-grow:1}@media screen and (max-width:1200px){.wrap{flex-direction:column}.wrap fieldset{flex-basis:auto;flex-grow:1!important;height:auto;width:100%}.flex-row{flex-direction:column}}.transferButton{font-size:20px;margin-top:20px;padding:8px;width:100px}.App{text-align:center}.App-logo{height:40vmin;pointer-events:none}@media (prefers-reduced-motion:no-preference){.App-logo{animation:App-logo-spin 20s linear infinite}}.App-header{align-items:center;background-color:#282c34;color:#fff;display:flex;flex-direction:column;font-size:calc(10px + 2vmin);justify-content:center;min-height:100vh}.App-link{color:#61dafb}@keyframes App-logo-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}
/*# sourceMappingURL=main.6d74c8c6.css.map*/

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
"use strict";(self.webpackChunkadminsite=self.webpackChunkadminsite||[]).push([[453],{453:(e,t,n)=>{n.r(t),n.d(t,{getCLS:()=>y,getFCP:()=>g,getFID:()=>C,getLCP:()=>P,getTTFB:()=>D});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver(function(e){return e.getEntries().map(t)});return n.observe({type:e,buffered:!0}),n}}catch(e){}},s=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},f=function(e){addEventListener("pageshow",function(t){t.persisted&&e(t)},!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,d=function(){return"hidden"===document.visibilityState?0:1/0},p=function(){s(function(e){var t=e.timeStamp;v=t},!0)},l=function(){return v<0&&(v=d(),p(),f(function(){setTimeout(function(){v=d(),p()},0)})),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(s&&s.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],s=o?null:c("paint",a);(o||s)&&(n=m(e,r,t),o&&a(o),f(function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,n(!0)})})}))},h=!1,T=-1,y=function(e,t){h||(g(function(e){T=e.value}),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},d=c("layout-shift",v);d&&(n=m(i,r,t),s(function(){d.takeRecords().map(v),n(!0)}),f(function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)}))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach(function(t){t(e)}),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach(function(t){return e(t,b,E)})},C=function(e,t){var n,a=l(),v=u("FID"),d=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},p=c("first-input",d);n=m(e,v,t),p&&s(function(){p.takeRecords().map(d),p.disconnect()},!0),p&&f(function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=d,o.push(a),S()})},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach(function(e){addEventListener(e,v,{once:!0,capture:!0})}),s(v,!0),f(function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)})})})}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",function(){return setTimeout(t,0)})}}}]);
//# sourceMappingURL=453.41fba699.chunk.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,55 +0,0 @@
/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
/*! sheetjs (C) 2013-present SheetJS -- http://sheetjs.com */
/*! xlsx.js (C) 2013-present SheetJS -- http://sheetjs.com */
/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View File

@@ -1,237 +0,0 @@
<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

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

View File

@@ -8,7 +8,6 @@
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"ethers": "^6.15.0", "ethers": "^6.15.0",
"express": "^5.2.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",

View File

@@ -1,12 +0,0 @@
const express = require('express');
const path = require('path');
const app = express();
app.use(express.static(path.join(__dirname, 'build')));
app.use((req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
app.listen(3000, () => {
console.log('React build serving on 3000');
});

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,25 +1,40 @@
import './App.css'; import './App.css';
import { read, utils } from "xlsx"; import { read, utils } from 'xlsx';
import { ethers, parseEther,formatEther } from "ethers"; import { ethers, formatEther, parseEther } from 'ethers';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Table } from './component/table'; import { tokenAbi } from './tokenAbi';
import { getBalance, multisend } from './utile/blockchain';
import { divide } from './utile/chunk'; const contractAddress = '0x1ebA64fDe3BF54545c86B9e3bB40c72f50f8D012';
import { setComma } from './utile/setComma';
import { List } from './component/list';
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 [from, setFrom] = useState(""); const [from, setFrom] = useState('');
const [toArray, setToArray] = useState([]);
const [amountArray, setAmountArray] = useState([]);
const [transferStatuses, setTransferStatuses] = useState([]);
const [errorMessage, setErrorMessage] = useState('');
const isToken = true;
const [provider, setProvider] = useState(); const [provider, setProvider] = useState();
const [text, setText] = useState('파일 업로드');
const tokenRef = useRef(null); 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) => { const handleFile = (file) => {
if (!file) return; if (!file) return;
setText(file.name);
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (evt) => { reader.onload = (evt) => {
@@ -28,252 +43,363 @@ 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 });
if (jsonData.length === 0) { const filteredData = jsonData.filter((row) => {
alert("파일에 데이터가 없습니다."); return (
return; typeof row.address === 'string' &&
} !row.address.toUpperCase().includes('EX') &&
!String(row.amount).toUpperCase().includes('EX')
const columns = Object.keys(jsonData[0]); );
const requiredColumns = ['address', 'amount'];
if (columns.length !== 2 ||
!requiredColumns.every(col => columns.includes(col))) {
alert("양식이 맞지 않습니다.");
return;
}
let total = totalAmount;
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) => { const addresses = filteredData.map((row) => row.address.trim());
const a = row.address;
return a;
});
const amount = filteredData.map((row) => { const amount = filteredData.map((row) => {
// 소수점 2자리까지 반올림 처리
const rounded = Math.round(Number(row.amount) * 100) / 100; const rounded = Math.round(Number(row.amount) * 100) / 100;
total += rounded
return parseEther(rounded.toFixed(2)); return parseEther(rounded.toFixed(2));
}); });
setChunkArray(prev => [...prev, ...divide(addresses, amount)]);
console.log(total)
setTotalAmount(total)
// Initialize transfer statuses for each address
const statuses = addresses.map(() => ({
status: 'pending',
message: '',
}));
setToArray(addresses);
setAmountArray(amount);
setTransferStatuses(statuses);
setErrorMessage(''); // Clear any previous errors
} catch (error) { } catch (error) {
console.error("Error:", error); setErrorMessage(`파일 처리 오류: ${error.message}`);
alert(error)
} }
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}; };
const handleFileChange = (e) => {
const file = e.target.files[0];
handleFile(file);
};
const handleDragOver = (e) => {
e.preventDefault();
};
const handleDrop = (e) => { const handleDrop = (e) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
handleFile(file); handleFile(file);
}; };
useEffect(()=>{ const handleFileChange = (e) => {
if (!window.ethereum) return alert("MetaMask not installed"); const file = e.target.files[0];
setProvider(new ethers.BrowserProvider(window.ethereum)) handleFile(file);
},[]) };
// const handleCheckboxChange = (e) => {
const walletProvider = useCallback(async ()=>{ // setIsToken(e.target.checked);
// };
const handleDragOver = (e) => {
e.preventDefault();
};
useEffect(() => {
if (!window.ethereum)
return setErrorMessage('MetaMask가 설치되지 않았습니다.');
setProvider(new ethers.BrowserProvider(window.ethereum));
}, []);
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 {
chainName: 'BNB Smart Chain Mainnet', chainId: '0x38', //0x38
rpcUrls: ['https://binance.llamarpc.com'], chainName: 'BNB Smart Chain Mainnet',
nativeCurrency: { rpcUrls: ['https://binance.llamarpc.com'],
name: 'BNB', nativeCurrency: {
symbol: 'BNB', name: 'BNB',
decimals: 18 symbol: 'BNB',
decimals: 18,
},
blockExplorerUrls: ['https://bscscan.com/'],
}, },
blockExplorerUrls: ['https://bscscan.com/'] ],
}]
}); });
} }
await provider.send("eth_requestAccounts", []);
window.ethereum.on("accountsChanged", (accounts) => { await provider.send('eth_requestAccounts', []);
window.ethereum.on('accountsChanged', (accounts) => {
if (accounts.length > 0) { if (accounts.length > 0) {
setFrom(accounts[0]); setFrom(accounts[0]);
} else { } else {
setFrom(null); setFrom(null);
} }
}); });
const signer = provider.getSigner(); const signer = provider.getSigner();
const senderAddress = await signer; const senderAddress = await signer;
setFrom(senderAddress.address);
}, [provider]);
const tokenAddress = tokenRef.current?.value; useEffect(() => {
const balance = await getBalance(tokenAddress, provider, senderAddress) if (provider) {
walletProvider();
setBalance(balance)
setFrom(senderAddress.address)
},[provider])
useEffect(()=>{
if(provider){
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 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 sendToken = async () => {
const tokenAddress = tokenRef.current?.value; const tokenAddress = tokenRef.current?.value;
let failedIdxs = [];
for (const idx of selectedChunks) { let totalAmount = 0;
const chunk = chunkArray[idx]; amountArray.map((row) => (totalAmount += parseFloat(formatEther(row))));
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 { try {
const status = await multisend( const tokenContract = new ethers.Contract(
tokenAddress, tokenAddress,
provider, tokenAbi,
from, signer
chunk.chunkAddresses,
chunk.chunkAmounts
); );
if (status) { let balance = isToken
setChunkArray(prev => prev.map((c, i) => ? await tokenContract.balanceOf(from)
i === idx ? {...c, status: 0} : c : 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 { } else {
setChunkArray(prev => prev.map((c, i) => throw new Error('잔액부족');
i === idx ? {...c, status: 1} : c
));
failedIdxs.push(idx)
} }
} catch (error) { } catch (error) {
console.error(`Chunk ${idx + 1} 전송 실패:`, error); console.error('failed to send');
alert(`Chunk ${idx + 1} 전송 중 오류가 발생했습니다.`); setErrorMessage(`전송 준비 중 오류: ${error.message}`);
break; 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 || '알 수 없는 오류'}`
);
} }
} }
// 전송 완료
setSelectedChunks(failedIdxs); if (status) {
setText('파일 업로드');
setToArray([]);
setAmountArray([]);
}
}; };
return ( return (
<div className="App"> <div className="App">
<div className="wrap"> <h1>Admin MultiSender</h1>
<fieldset> <div
<legend>UPLOAD</legend> style={{
width: '600px',
<ul> height: '100px',
<li> margin: '0 Auto',
<label htmlFor="tokenAddress">토큰 컨트랙트</label> display: 'flex',
<input type="text" id="tokenAddress" defaultValue="0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18" ref={tokenRef}/> flexDirection: 'column',
</li> }}
>
<li> {/* <div>
<label htmlFor="contract">발송 주소</label> <span style={{fontSize: "18px"}}>토큰 전송 </span>
<input type="text" id="contract" value={from} disabled/> <input type="checkbox" onChange={handleCheckboxChange} />
</li> </div> */}
<div>
<li> <span style={{ fontSize: '18px' }}>토큰 컨트랙트 : </span>
<div onDragOver={handleDragOver} onDrop={handleDrop}> <input
<label htmlFor="file">보내는 계좌</label> type="text"
<input type="file" id="file" accept=".xls,.xlsx,.csv" onChange={handleFileChange} onDrop={handleDrop} onDragOver={handleDragOver}/> ref={tokenRef}
<label htmlFor="file"></label> placeholder=""
</div> style={{ fontSize: '18px', marginTop: '10px', width: '450px' }}
</li> defaultValue={'0x36b8dE7c6B06B3f170003452114f0B8E6BcFEE18'}
</ul> />
<p>balance : {setComma(formatEther(balance))}</p> </div>
<p>totalAmount : {setComma(totalAmount)}</p> <div>
<span style={{ fontSize: '18px' }}>보내는 계좌 : </span>
<List chunkArray={chunkArray} selectedChunks={selectedChunks} toggleChunkSelection={toggleChunkSelection}></List> <input
type="text"
<button placeholder={from}
className='transferButton'
type='button'
onClick={sendToken}
disabled={selectedChunks.length === 0}
style={{ style={{
opacity: selectedChunks.length === 0 ? 0.5 : 1, fontSize: '18px',
cursor: selectedChunks.length === 0 ? "not-allowed" : "pointer" marginTop: '10px',
width: '450px',
border: 'none',
}} }}
> disabled
전송 ({selectedChunks.length}) />
</button> </div>
</fieldset>
<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>
<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}>
전송
</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>
); );
} }
export default App; export default App;

View File

@@ -1,59 +0,0 @@
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>
)}
</>
)
}

View File

@@ -1,26 +0,0 @@
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,220 +11,3 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace; 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 // 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);

View File

@@ -1,72 +0,0 @@
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 allowance = isToken ? await tokenContract.allowance(from, contractAddress) : 0;
let balance = isToken ? await tokenContract.balanceOf(from) : await provider.getBalance(from);
if(balance >= parseEther(totalAmount.toString())){
if( isToken && allowance < parseEther(totalAmount.toString()))
{
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;
}

View File

@@ -1,33 +0,0 @@
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
}

View File

@@ -1,24 +0,0 @@
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(".");
};