블록체인 기반 토큰 생태계는 단순한 디지털 자산 보유 단계를 넘어, 특정 서비스나 커뮤니티에 접근하기 위한 ‘열쇠’ 역할을 수행하는 방향으로 진화하고 있다. 특히 이더리움 네트워크 위에서 발행되는 ERC-20 토큰은 스마트 컨트랙트와 결합하여 사용자의 권한을 확인하고, 일정 기준을 충족했을 때만 콘텐츠나 플랫폼에 접근할 수 있도록 하는 구조를 구현하기에 적합하다.
대표적인 방식은 **토큰 게이팅(Token Gating)**이다. 사용자가 메타마스크 같은 지갑을 통해 사이트에 접속하면, 시스템은 사용자의 계정을 읽어와 특정 토큰의 보유량을 확인한다. 이를 위해 eth_call을 활용해 balanceOf(address) 함수와 decimals() 함수를 호출한다. 이렇게 가져온 원시 잔액은 18자리 소수점 단위를 가진 정수 값(BigInt)으로 표시되며, 실제 사용자가 이해하기 쉽도록 decimals를 반영해 사람이 읽을 수 있는 수치로 변환해야 한다.
이 과정에서 중요한 점은 정밀도 보존이다. 자주 발생하는 실수는 단순히 parseInt 같은 함수를 적용하여 큰 수를 잘못 처리하는 경우다. 이더리움 토큰의 원시 잔액은 보통 10^18 단위까지 표현되므로, 안전하게 다루기 위해서는 자바스크립트에서 BigInt를 사용해 연산하는 것이 바람직하다. 실제 비교도 소수점으로 변환한 실수(float) 값보다는, 기준치를 동일하게 10^decimals 만큼 확장한 뒤 BigInt끼리 직접 비교하는 편이 정확하다.
예를 들어, 어떤 서비스에서 “100만 개 이상의 PEPE 토큰 또는 1개의 HJH 토큰”을 요구한다고 가정해보자. 단순히 소수점 변환 후 부동소수 연산을 하면 미세한 오차 때문에 기준 충족 여부가 잘못 판단될 수 있다. 하지만 BigInt 연산을 사용하면 기준치 역시 1000000 * 10^18 같은 형태로 확장해 비교하기 때문에 오차가 발생하지 않는다. 결과적으로 사용자는 잔액 검증이 일관되게 이뤄지고, 서비스 제공자는 안정적으로 접근 권한을 관리할 수 있다.
이러한 구조는 단순히 권한 제어를 넘어, 커뮤니티 구축과 가치 보호라는 관점에서 큰 의미를 갖는다. 특정 수량의 토큰을 보유한 사람만이 참여할 수 있는 온라인 갤러리, 포럼, 혹은 이벤트는 그 자체로 하나의 인센티브 구조를 만든다. 이는 커뮤니티 구성원에게는 일종의 신뢰 자격 증명으로 작용하고, 외부인에게는 해당 토큰의 가치를 높이는 역할을 한다. 특히 예술, 엔터테인먼트, 메타버스와 같이 참여자들의 ‘소속감’과 ‘희소성’이 중요한 분야에서는 이러한 접근 방식이 점점 더 일반화될 것으로 보인다.
또한 웹 애플리케이션의 사용자 경험 측면에서도 세심한 고려가 필요하다. 단순히 권한 여부를 확인하는 것에서 그치지 않고, 결과를 직관적이고 미려한 UI로 보여주는 것이 중요하다. 예컨대 토큰 보유량을 단순 숫자 대신 시각적으로 구분되는 카드 UI에 표시하고, 기준 충족 여부를 색상과 배지를 통해 전달하면 사용자 경험이 크게 향상된다. 더 나아가 실패했을 경우에는 구매 경로를 바로 제공하는 식으로 자연스러운 행동 전환을 유도할 수도 있다.
이러한 토큰 게이팅 기술은 블록체인 네이티브 서비스뿐만 아니라, 전통적인 웹 플랫폼에도 확장 가능하다. 온라인 교육 플랫폼이 특정 수업을 토큰 보유자만 수강할 수 있도록 제한하거나, 미디어 기업이 구독자 대신 토큰 소유자를 멤버십으로 대체하는 방식 등이 대표적이다. 결국 이는 디지털 자산을 단순히 투자 대상이 아니라 실질적 사용권과 접근권을 부여하는 매개체로 진화시키는 흐름의 일부라 할 수 있다.
앞으로 토큰 보유량 확인과 권한 제어는 점점 더 다양한 맥락에서 활용될 것이다. 기술적으로는 더 정밀하고 효율적인 검증 메커니즘이 필요하며, 사회적으로는 이를 어떻게 설계하느냐가 커뮤니티의 건강성과 지속 가능성을 좌우하게 된다. 블록체인 기술은 단순한 소유 증명에서 벗어나, 새로운 형태의 사회적 신뢰와 가치를 창출하는 수단으로 자리잡아가고 있다.
runCheck()를 BigInt 기반 정확 비교로 반영하고, getTokenBalance()를 실제 RPC(eth_call) 조회 버전으로 교체한 완성된 next.html 전체 파일을 아래에 드립니다.
(모의값 공지는 제거했으며, 표시는 정확 문자열(actualText)을 사용하고, 비교는 rawBalance(BigInt)로 수행합니다.)
동작 요약
- URL의
?qr=값에서0x…이더리움 주소를 추출eth_call로balanceOf+decimals조회(메타마스크 RPC 사용)- 기준치(100만 PEPE 또는 1 HJH)를 BigInt 로 변환해 정확 비교
- 통과/미통과 배지 및 상세 값을 표시
- 메인넷(
0x1)이 아니면 안내
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>HJH Token Checker</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
:root { --green:#2ecc40; --green-dark:#116611; --danger:#e74c3c; --gray:#6c757d; }
* { box-sizing: border-box; }
body { margin:0; font-family: Arial, sans-serif; background:#f7fff7; color:var(--green-dark); }
header { padding:18px 16px; text-align:center; border-bottom:2px solid #e6ffe6; }
header h1 { margin:0; font-size:20px; }
main { max-width:720px; margin:20px auto; padding:0 14px 28px; }
.card { background:#fff; border:3px solid var(--green); border-radius:14px; padding:18px; box-shadow:0 6px 18px rgba(0,0,0,.06); }
.row { margin:10px 0; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }
.badge { display:inline-block; padding:4px 10px; border-radius:999px; font-size:12px; font-weight:700; }
.badge.ok { background:#e9f9ee; color:#0b7a2a; border:1px solid #bfe7ca; }
.badge.ng { background:#fdeaea; color:#7a1b1b; border:1px solid #f3c2c2; }
.result { margin-top:14px; padding:12px; border-radius:10px; border:2px solid #bfe7ca; background:#effdf3; }
.result.error { border-color:#f3c2c2; background:#fff3f3; }
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
.tile { padding:12px; border-radius:12px; border:2px solid #eafaea; background:#f9fffa; }
.tile h4 { margin:0 0 6px; font-size:14px; color:#0b7a2a; }
.tile .val { font-weight:700; }
.btns { margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; }
.btn { padding:10px 16px; border-radius:999px; border:0; cursor:pointer; font-weight:700; color:#fff; background:var(--green); }
.btn.gray { background:var(--gray); }
.muted { color:#577; font-size:12px; }
.loading { display:inline-block; width:1em; height:1em; border:2px solid #cfe; border-top-color:#2ecc40; border-radius:50%; animation:spin 1s linear infinite; vertical-align:-2px; margin-right:6px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width:640px){ .grid{ grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>HJH 토큰 보유 기준 확인</h1>
</header>
<main>
<div class="card">
<div class="row"><strong>입력 주소</strong></div>
<div id="addr" class="row mono">-</div>
<div class="row">
<span id="status" class="badge">준비 중…</span>
</div>
<div class="grid">
<div class="tile">
<h4>PEPE 잔액</h4>
<div class="val" id="pepe-val">-</div>
<div class="muted">최소 기준: <span id="pepe-min">-</span></div>
</div>
<div class="tile">
<h4>HJH 잔액</h4>
<div class="val" id="hjh-val">-</div>
<div class="muted">최소 기준: <span id="hjh-min">-</span></div>
</div>
</div>
<div id="result" class="result" style="display:none;"></div>
<div class="btns">
<a class="btn" href="index.html">← 다시 스캔</a>
<button id="retry" class="btn gray" style="display:none;">다시 조회</button>
</div>
<div class="row muted" style="margin-top:10px;">
* 메타마스크 RPC를 사용하여 온체인 잔액을 조회합니다. (Ethereum Mainnet)
</div>
</div>
</main>
<script>
// ===== Constants =====
const PEPE_CONTRACT_ADDRESS = '0x6982508145454Ce325dDbE47a25d4ec3d2311933';
const HJH_CONTRACT_ADDRESS = '0xfa7a06beb4a8028ed6745777b04b04b41be5f478';
// 기준 (정수 토큰 수, 소수 고려 전)
const MINIMUM_PEPE_BALANCE = 1_000_000; // 100만
const MINIMUM_HJH_BALANCE = 1; // 1
// ===== DOM refs =====
const addrEl = document.getElementById('addr');
const statusEl = document.getElementById('status');
const pepeVal = document.getElementById('pepe-val');
const hjhVal = document.getElementById('hjh-val');
const pepeMin = document.getElementById('pepe-min');
const hjhMin = document.getElementById('hjh-min');
const resultEl = document.getElementById('result');
const retryBtn = document.getElementById('retry');
pepeMin.textContent = MINIMUM_PEPE_BALANCE.toLocaleString();
hjhMin.textContent = MINIMUM_HJH_BALANCE.toLocaleString();
// ===== Helpers (BigInt-safe) =====
function hexToBigInt(hex) {
if (!hex) return 0n;
const h = hex.toLowerCase();
if (h === '0x' || h === '0x0') return 0n;
return BigInt(h);
}
function formatUnits(rawBig, decimals) {
const neg = rawBig < 0n;
let raw = neg ? -rawBig : rawBig;
const base = 10n ** BigInt(decimals);
const i = raw / base;
const f = raw % base;
const fStr = f.toString().padStart(decimals, '0').replace(/0+$/, '');
return (neg ? '-' : '') + i.toString() + (fStr ? '.' + fStr : '');
}
function setStatus(text, ok=null) {
statusEl.textContent = text;
statusEl.className = 'badge' + (ok===true ? ' ok' : ok===false ? ' ng' : '');
}
function extractEthAddress(text) {
if (!text) return null;
const m = text.match(/0x[a-fA-F0-9]{40}/);
return m ? m[0] : (/^0x[a-fA-F0-9]{40}$/.test(text) ? text : null);
}
function getQueryParam() {
const usp = new URLSearchParams(location.search);
const paramName = usp.get('param') || 'qr';
return usp.get(paramName);
}
// ===== Network info (메인넷 체크) =====
async function getNetworkInfo() {
try {
const chainId = await window.ethereum.request({ method: 'eth_chainId' });
const names = { '0x1': 'Ethereum Mainnet' };
return { chainId, networkName: names[chainId] || `Unknown (${chainId})` };
} catch {
return { chainId: '0x0', networkName: 'Unknown' };
}
}
// ===== 실제 RPC 조회 버전 =====
async function getTokenBalance(address, contractAddress) {
try {
// balanceOf(address)
const balanceOfData = '0x70a08231' + address.slice(2).padStart(64, '0');
const balanceHex = await window.ethereum.request({
method: 'eth_call',
params: [{ to: contractAddress, data: balanceOfData }, 'latest']
});
// decimals()
const decimalsHex = await window.ethereum.request({
method: 'eth_call',
params: [{ to: contractAddress, data: '0x313ce567' }, 'latest']
});
const rawBig = hexToBigInt(balanceHex);
const decimals = Number(hexToBigInt(decimalsHex));
const actualStr = formatUnits(rawBig, decimals);
const actualNum = Number(actualStr); // 표시/간단 비교용
return { rawBalance: rawBig, decimals, actualBalance: actualNum, actualText: actualStr };
} catch (err) {
console.error('Token Balance Query Error:', err);
return { rawBalance: 0n, decimals: 18, actualBalance: 0, actualText: '0' };
}
}
function showResult(ok, address, pepe, hjh) {
resultEl.style.display = 'block';
resultEl.classList.toggle('error', !ok);
resultEl.innerHTML = `
<div><strong>${ok ? '통과' : '미통과'}</strong></div>
<div class="mono" style="margin-top:6px;">${address}</div>
<div style="margin-top:10px;">PEPE: ${pepe.actualText} (기준 ${MINIMUM_PEPE_BALANCE.toLocaleString()})</div>
<div>HJH: ${hjh.actualText} (기준 ${MINIMUM_HJH_BALANCE.toLocaleString()})</div>
`;
}
async function runCheck(address) {
try {
setStatus('조회 중…');
resultEl.style.display = 'none';
retryBtn.style.display = 'none';
// 네트워크 확인
const net = await getNetworkInfo();
if (net.chainId !== '0x1') {
setStatus('메인넷이 아닙니다. Ethereum Mainnet에 연결하세요.', false);
resultEl.style.display = 'block';
resultEl.classList.add('error');
resultEl.innerHTML = `현재 네트워크: <strong>${net.networkName}</strong><br/>메인넷에서 다시 시도해주세요.`;
retryBtn.style.display = 'inline-block';
return;
}
// 병렬 조회
const [pepe, hjh] = await Promise.all([
getTokenBalance(address, PEPE_CONTRACT_ADDRESS),
getTokenBalance(address, HJH_CONTRACT_ADDRESS)
]);
// 표시값(정확 문자열)
pepeVal.textContent = pepe.actualText;
hjhVal.textContent = hjh.actualText;
// BigInt 기준 비교 (threshold = min * 10^decimals)
const pepeThreshold = BigInt(MINIMUM_PEPE_BALANCE) * (10n ** BigInt(pepe.decimals));
const hjhThreshold = BigInt(MINIMUM_HJH_BALANCE) * (10n ** BigInt(hjh.decimals));
const pass = (pepe.rawBalance >= pepeThreshold) || (hjh.rawBalance >= hjhThreshold);
setStatus(pass ? '기준 충족' : '기준 미달', pass);
showResult(pass, address, pepe, hjh);
} catch (e) {
console.error(e);
setStatus('오류 발생', false);
resultEl.style.display = 'block';
resultEl.classList.add('error');
resultEl.textContent = '잔액 조회 중 오류가 발생했습니다. 다시 시도해 주세요.';
retryBtn.style.display = 'inline-block';
}
}
// ===== Init =====
(function init() {
const raw = getQueryParam();
if (!raw) {
setStatus('QR 파라미터가 없습니다.', false);
addrEl.textContent = '-';
retryBtn.style.display = 'inline-block';
return;
}
const address = extractEthAddress(raw);
addrEl.textContent = address || raw;
if (!address) {
setStatus('유효한 이더리움 주소가 아닙니다.', false);
resultEl.style.display = 'block';
resultEl.classList.add('error');
resultEl.innerHTML = 'QR 내용: <span class="mono">' + raw + '</span><br />' +
'0x로 시작하는 42자 이더리움 주소가 필요합니다.';
retryBtn.style.display = 'inline-block';
return;
}
if (!window.ethereum) {
setStatus('MetaMask(또는 EIP-1193 지갑)가 필요합니다.', false);
resultEl.style.display = 'block';
resultEl.classList.add('error');
resultEl.textContent = '지갑 확장 프로그램을 설치/활성화한 뒤 다시 시도하세요.';
return;
}
runCheck(address);
})();
retryBtn.addEventListener('click', () => {
const raw = getQueryParam();
const address = extractEthAddress(raw);
if (address) runCheck(address);
else location.href = 'index.html';
});
</script>
</body>
</html>
지갑 연결 버튼 없이(완전 비대면 조회) 외부 공개 RPC(Infura/Alchemy)로 바꾸는 버전 경우 CORS/RPC Key, rate limit 고려가 필요합니다.)