Web3 블록체인 시스템
MetaMask 지갑을 통한 탈중앙화 인증과
머클 트리 기반의 효율적인 NFT 에어드랍 시스템
Web3 지갑 인증
디지털 서명 기반 로그인
코인 에어드랍
자동화된 토큰 분배
NFT 에어드랍
머클 트리 기반 ERC1155
스마트 컨트랙트
Solidity 기반 자동 실행
가스 최적화
머클 증명으로 비용 절감
실시간 알림
Socket.io 기반 푸시
Web3 지갑 로그인 시스템
구현 내용
지갑 연결
- • MetaMask 지갑 연결
- • 계정 정보 조회
- • 네트워크 상태 확인
- • 잔액 조회
네트워크 관리
- • 로컬 Ganache CLI 네트워크 자동 전환
- • 체인 ID 확인 (0x539)
- • 네트워크 추가 및 전환
- • 연결 상태 모니터링
보안 기능
- • 지갑 주소 변경 감지
- • 네트워크 변경 감지
- • 자동 연결 해제
- • 에러 처리 및 사용자 알림
API 테스트
테스트 방법
- • MetaMask가 설치되어 있어야 합니다
- • “지갑 연결” 버튼을 클릭하여 연결
- • 로컬 네트워크로 자동 전환됩니다
- • 연결 상태와 계정 정보를 확인하세요
연결 상태
@ApiTags('auth')
@Controller('api/auth')
export class AuthController {
/**
* 지갑 로그인 요청
* */
@Post('web3-request')
@ApiResponseEntity({
type: AuthCramOutDto,
summary: '회원가입 및 로그인',
})
async web3Request(
@Body() web3RequestInDto: Web3RequestInDto,
): Promise<ResponseEntity<AuthCramOutDto>> {
const challenge = this.authService.signChallenge(web3RequestInDto.address);
return ResponseEntity.ok().body(
AuthCramOutDto.of().setChallenge(challenge),
);
}
/**
* 지갑 로그인 검증
* */
@Post('web3-verify')
@ApiResponseEntity({
type: AuthSignUserOutDto,
summary: 'wallet 로그인',
})
async web3Verify(
@Body() web3VerifyInDto: Web3VerifyInDto,
): Promise<ResponseEntity<AuthSignUserOutDto>> {
const { challenge, address, signature } = web3VerifyInDto;
// 1. challenge 확인
this.authService.verifyChallenge(address, challenge);
// 2. wallet 서명 확인
await this.web3Service.web3Service.verifySignature(
address,
signature,
challenge,
);
// 3.계정 생성 및 로그인
const authSignUserOutDto = await this.authService.web3Register(address);
return ResponseEntity.ok().body(authSignUserOutDto);
}코인 에어드랍 시스템
코인 에어드랍 프로세스 플로우

코인 에어드랍 프로세스
에어드랍 단계
- 큐 등록: 에어드랍 요청을 큐에 등록
- 블록체인 동기화: Cron Job으로 주기적 동기화
- 대기열 확인: Job Server가 대기열 상태 모니터링
- 배치 처리: BatchServer가 대량 에어드랍 실행
- 실시간 알림: Socket으로 진행 상황 공유
주요 특징
- • 큐 시스템: 대량 요청 처리 가능 (Bull Queue)
- • 비동기 처리: 백그라운드 작업으로 성능 최적화
- • 자동화: Cron Job으로 주기적 블록체인 동기화
- • 실시간 추적: Socket.io로 에어드랍 상태 실시간 확인
- • 배치 처리: BatchServer로 효율적인 대량 전송
- • 안전성: 중복 방지 및 에러 핸들링
- • 확장성: 마이크로서비스 아키텍처
API 테스트
테스트 방법
- • “POST airdrop/coin/queue 호출” 버튼을 클릭하여 에어드랍 신청
- • 큐 등록이 완료되면 결과를 확인하세요
- • 백그라운드에서 자동으로 처리됩니다
- • 에러 발생 시 상세한 에러 메시지를 확인하세요
테스트
pragma solidity ^0.8.19;
import "../../utils/PortfolioSuperOperatorsUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract AdminAirdropCoin is Initializable, ReentrancyGuardUpgradeable, PortfolioSuperOperatorsUpgradeable, UUPSUpgradeable {
event AirdropCoin(address indexed receiver, uint256 amount);
event Withdraw(address indexed to, uint256 amount);
event Deposit(address indexed from, uint256 amount);
constructor() {
_disableInitializers();
}
/// @notice 컨트랙트 초기화
/// @param operator 오퍼레이터 이름
/// @param portfolioAdmin 관리자 주소
function initialize(string memory operator, address portfolioAdmin) public initializer {
__ReentrancyGuard_init();
__UUPSUpgradeable_init();
__PortfolioSuperOperators_init(operator, portfolioAdmin);
}
/// @notice 에어드랍 정보 구조체
struct AirdropInfo {
address receiver;
uint256 amount; // ETH 단위 (Wei로 자동 변환됨)
}
/// @notice 다중 에어드랍 실행
/// @param airdropInfos 에어드랍 정보 배열
function airdrop(AirdropInfo[] memory airdropInfos) external onlySuperOperator nonReentrant {
uint256 length = airdropInfos.length;
require(length > 0, "Empty array");
require(length <= 500, "Too many recipients");
uint256 totalAmount = 0;
// 1단계: 유효성 검증 및 총액 계산
for (uint256 i = 0; i < length; ) {
require(airdropInfos[i].receiver != address(0), "Invalid address");
require(airdropInfos[i].amount > 0, "Invalid amount");
uint256 amountInWei = airdropInfos[i].amount * 10 ** 18;
totalAmount += amountInWei;
unchecked { ++i; }
}
require(address(this).balance >= totalAmount, "Insufficient balance");
// 2단계: 전송 실행
for (uint256 i = 0; i < length; ) {
address payable recipient = payable(airdropInfos[i].receiver);
uint256 amountInWei = airdropInfos[i].amount * 10 ** 18;
(bool success, ) = recipient.call{value: amountInWei}("");
require(success, "Transfer failed");
emit AirdropCoin(recipient, amountInWei);
unchecked { ++i; }
}
}
/// @notice 컨트랙트에 코인 충전
function charging() external payable onlySuperOperator {
require(msg.value > 0, "Invalid amount");
emit Deposit(msg.sender, msg.value);
}
/// @notice 컨트랙트 잔액 전액 출금
function withdraw() external onlySuperOperator nonReentrant {
uint256 balance = address(this).balance;
require(balance > 0, "No balance");
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
emit Withdraw(msg.sender, balance);
}
/// @notice 특정 금액만 출금
/// @param amount 출금할 금액 (Wei)
function withdrawAmount(uint256 amount) external onlySuperOperator nonReentrant {
require(amount > 0, "Invalid amount");
require(address(this).balance >= amount, "Insufficient balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
emit Withdraw(msg.sender, amount);
}
/// @notice 현재 컨트랙트 잔액 조회
/// @return 잔액 (Wei)
function getBalance() external view returns (uint256) {
return address(this).balance;
}
/// @notice 일반 전송 수신
receive() external payable {
emit Deposit(msg.sender, msg.value);
}
/// @dev UUPS 업그레이드 권한 검증
function _authorizeUpgrade(address newImplementation) internal override onlySuperOperator {}
/// @dev 스토리지 갭
uint256[30] private __AdminAirdropCoinGap;
}ERC1155 머클트리 에어드랍 시스템
ERC1155 에어드랍 프로세스 플로우

시스템 아키텍처
주요 구성 요소
- • Web: 사용자 인터페이스 및 클레임 요청
- • API Server: 요청 처리 및 응답 관리
- • Job Server: 백그라운드 작업 및 스케줄링
- • Batch Server: 대량 처리 및 블록체인 상호작용
- • MainNet: 이더리움 메인넷 블록체인
데이터 흐름
- 클레임 등록: Web → API Server로 ERC1155 클레임 대기열 등록
- 머클 트리 생성: Job Server가 주기적으로 대기열을 처리하여 머클 트리 생성
→ 모든 클레임 데이터를 해시화하여 이진 트리 구조로 구성
- DB 저장: Batch Server가 머클 트리 및 머클 증명(Merkle Proof) 데이터를 DB에 저장
→ 각 클레임에 대한 머클 증명 생성 및 저장
- 블록체인 주입: 머클 루트(Merkle Root)를 MainNet 스마트 컨트랙트에 주입
→ 32 bytes 해시 하나만 저장하여 가스 비용 최소화
- 상태 공유: Socket을 통해 실시간 현황 공유
- 클레임 요청: 사용자가 머클 증명으로 최종 클레임
→ 스마트 컨트랙트가 제출된 머클 증명을 검증하여 NFT 발행
기술적 특징
머클 트리 시스템 (Merkle Tree)
가스 효율성
대량 에어드랍 시 각 수신자에게 개별 트랜잭션을 보내는 대신, 하나의 머클 루트만 블록체인에 저장하여 가스 비용을 극적으로 절감합니다.
무결성 보장
암호학적 해시 함수를 사용하여 데이터의 무결성을 보장합니다. 머클 증명(Merkle Proof)으로 특정 데이터가 트리에 포함되어 있음을 수학적으로 증명할 수 있습니다.
중복 방지
스마트 컨트랙트에서 이미 클레임된 항목을 추적하여 동일한 에어드랍을 중복으로 수령하는 것을 방지합니다.
확장성
수천, 수만 개의 클레임을 동시에 처리할 수 있으며, 트리 구조 덕분에 검증 시간이 O(log n)으로 매우 효율적입니다.
비동기 처리
- • Cron Job: 주기적 대기열 처리 및 머클 트리 생성
- • Queue System: Bull Queue를 통한 안정적인 작업 관리
- • Socket 통신: 실시간 상태 업데이트 및 알림
- • 배치 처리: 대량 트랜잭션 효율적 처리
보안 메커니즘
- • 검증 시스템: 머클 증명 기반 클레임 검증
- 스마트 컨트랙트가 제출된 머클 증명을 검증하여 위조 불가능한 에어드랍 실행
- • 블록체인 연동: MainNet과의 안전한 상호작용
- • 상태 추적: 클레임 상태 실시간 모니터링
- • 에러 핸들링: 실패한 트랜잭션 복구 및 재시도
pragma solidity ^0.8.19;
import "../../utils/PortfolioSuperOperatorsUpgradeable.sol";
import "./Erc1155ClaimStruct.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract Erc1155ClaimProof is Initializable, PortfolioSuperOperatorsUpgradeable, UUPSUpgradeable {
event SetRoot(uint256 indexed round, bytes32 indexed root);
event ClaimProof(address indexed userAddress, uint256 indexed id, uint256 indexed tokenId, ProofInfo proofInfo);
// round => root
mapping(uint256 => bytes32) public rootList;
mapping(address => mapping(uint256 => bool)) public useClaimStatus;
constructor() {
_disableInitializers();
}
function initialize(string memory operator, address portfolioAdmin) public initializer {
__UUPSUpgradeable_init();
__PortfolioSuperOperators_init(operator, portfolioAdmin);
}
function _authorizeUpgrade(address newImplementation) internal override onlySuperOperator {}
function getRoot(uint256 _round) public view returns (bytes32) {
return rootList[_round];
}
function setRoot(SetRootInfo memory _setRootInfo) external onlySuperOperator {
rootList[_setRootInfo.round] = _setRootInfo.root;
emit SetRoot(_setRootInfo.round, _setRootInfo.root);
}
function setRootList(SetRootInfo[] memory _setRootInfos) external onlySuperOperator {
for (uint8 i = 0; i < _setRootInfos.length;) {
rootList[_setRootInfos[i].round] = _setRootInfos[i].root;
emit SetRoot(_setRootInfos[i].round, _setRootInfos[i].root);
unchecked { ++i; }
}
}
function claimProof(ProofInfo memory _proofInfo) external onlySuperOperator {
require(verifyClaim(_proofInfo), "invalid proof");
require(!useClaimStatus[_proofInfo.userAddress][_proofInfo.id], "round : already claimed");
useClaimStatus[_proofInfo.userAddress][_proofInfo.id] = true;
emit ClaimProof(_proofInfo.userAddress, _proofInfo.id, _proofInfo.tokenId, _proofInfo);
}
function verifyClaim(
ProofInfo memory _proofInfo
) private view returns (bool) {
bytes32 leaf = keccak256(abi.encodePacked(_proofInfo.id, _proofInfo.userAddress, _proofInfo.claimType, _proofInfo.tokenId, _proofInfo.amount));
return verifyProof(_proofInfo.proof, rootList[_proofInfo.round], leaf);
}
function verifyProof(
bytes32[] memory proof,
bytes32 root,
bytes32 leaf
) internal pure returns (bool) {
return processProof(proof, leaf) == root;
}
function processProof(bytes32[] memory proof, bytes32 leaf)
internal
pure
returns (bytes32)
{
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
bytes32 proofElement = proof[i];
if (computedHash <= proofElement) {
computedHash = keccak256(
abi.encodePacked(computedHash, proofElement)
);
} else {
computedHash = keccak256(
abi.encodePacked(proofElement, computedHash)
);
}
}
return computedHash;
}
uint256[30] private __Erc1155ClaimProofGap;
}블록체인 가챠 시스템 아키텍처
가챠 토큰 민팅 시퀀스 다이어그램

시스템 아키텍처
주요 구성 요소
- • Frontend: 사용자 인터페이스 및 가챠 요청
- • API Server: 가챠 요청 처리 및 확률 계산
- • Job Server: 백그라운드 작업 및 스케줄링
- • Batch Server: 대량 처리 및 블록체인 상호작용
- • MainNet: 이더리움 메인넷 블록체인
데이터 흐름
- 가챠 요청: 사용자가 가챠 토큰으로 가챠 실행 요청
- 확률 계산: API Server가 등급별 확률에 따라 NFT 타입 결정
→ N(70%), R(20%), SR(8%), SSR(2%) 확률 시스템
- NFT 민팅: Job Server가 실시간으로 Number NFT 또는 Shape NFT 민팅
→ 가챠 토큰 소각 후 새로운 NFT 즉시 발행
- 메타데이터 생성: 자동으로 NFT 속성 및 메타데이터 생성/저장
→ 등급, 속성, 이미지 등 모든 정보 블록체인에 기록
- 실시간 알림: Socket을 통해 가챠 결과 실시간 전달
- NFT 전송: 민팅된 NFT를 사용자 지갑으로 자동 전송
→ 투명하고 즉시적인 NFT 소유권 이전
기술적 특징
가챠 시스템 (Gacha System)
투명한 확률 시스템
모든 확률 정보가 블록체인에 공개되어 있어 조작이 불가능하며, 사용자가 언제든지 확률을 확인할 수 있습니다.
즉시 민팅
가챠 결과가 결정되면 실시간으로 NFT를 민팅하여 사용자에게 즉시 전달됩니다. 대기 시간 없이 바로 소유할 수 있습니다.
등급 시스템
N, R, SR, SSR 4단계 등급으로 구분되며, 등급에 따라 희귀도와 가치가 달라집니다.
다양한 NFT 타입
Number NFT(고유 숫자)와 Shape NFT(다양한 형태)를 제공하여 컬렉션의 다양성을 보장합니다.
비동기 처리
- • 실시간 처리: Socket 연결을 통한 즉시 가챠 결과 전달
- • Queue System: Bull Queue를 통한 안정적인 민팅 작업 관리
- • Socket 통신: 실시간 상태 업데이트 및 알림
- • 배치 처리: 대량 가챠 요청 효율적 처리
보안 메커니즘
- • 토큰 소각: 가챠 토큰 사용 시 자동 소각으로 중복 사용 방지
- 블록체인에서 토큰 소각을 먼저 처리한 후 새로운 NFT 민팅
- • 블록체인 연동: MainNet과의 안전한 상호작용
- • 확률 검증: 가챠 결과 실시간 모니터링 및 검증
- • 에러 핸들링: 실패한 트랜잭션 복구 및 재시도
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract GachaMachine is ERC1155, Ownable, ReentrancyGuard {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
// 가챠 토큰 ID (소각용)
uint256 public constant GACHA_TOKEN_ID = 3000; // 도형 가챠팩
uint256 public constant NUMBER_GACHA_TOKEN_ID = 3001; // 숫자 가챠팩
// NFT 토큰 ID 범위
uint256 public constant SHAPE_TOKEN_START = 1000;
uint256 public constant NUMBER_TOKEN_START = 2001;
// 가챠 결과 이벤트
event GachaResult(
address indexed player,
uint256 indexed gachaType, // 0: Shape, 1: Number
uint256 indexed tokenId,
uint256 tier // 0: N, 1: R, 2: SR, 3: SSR
);
// 티어 확률 (basis points: 10000 = 100%)
struct TierProbability {
uint256 N; // 50%
uint256 R; // 30%
uint256 SR; // 15%
uint256 SSR; // 5%
}
TierProbability public tierProbability = TierProbability(5000, 3000, 1500, 500);
// 가챠 타입별 최대 토큰 수
mapping(uint256 => uint256) public maxTokenCount;
// 이미 민팅된 토큰 ID 추적
mapping(uint256 => bool) public isTokenMinted;
constructor(string memory uri) ERC1155(uri) {
maxTokenCount[0] = 1000; // Shape 가챠 최대 1000개
maxTokenCount[1] = 1000; // Number 가챠 최대 1000개
}
/**
* @dev 가챠 실행 함수
* @param gachaType 0: Shape 가챠, 1: Number 가챠
* @param seed 랜덤 시드 (프론트엔드에서 생성)
*/
function executeGacha(uint256 gachaType, uint256 seed)
external
nonReentrant
{
require(gachaType == 0 || gachaType == 1, "Invalid gacha type");
// 가챠 토큰 소각
uint256 gachaTokenId = gachaType == 0 ? GACHA_TOKEN_ID : NUMBER_GACHA_TOKEN_ID;
require(balanceOf(msg.sender, gachaTokenId) > 0, "No gacha token");
// 가챠 토큰 1개 소각
_burn(msg.sender, gachaTokenId, 1);
// 티어 결정
uint256 tier = _determineTier(seed);
// 토큰 ID 생성
uint256 tokenId = _generateTokenId(gachaType, tier);
// NFT 민팅
_mint(msg.sender, tokenId, 1, "");
// 이벤트 발생
emit GachaResult(msg.sender, gachaType, tokenId, tier);
}
/**
* @dev 티어 결정 함수
* @param seed 랜덤 시드
* @return tier 결정된 티어 (0: N, 1: R, 2: SR, 3: SSR)
*/
function _determineTier(uint256 seed) internal view returns (uint256) {
uint256 random = uint256(keccak256(abi.encodePacked(seed, block.timestamp, msg.sender))) % 10000;
uint256 cumulative = 0;
// SSR (5%)
cumulative += tierProbability.SSR;
if (random < cumulative) return 3;
// SR (15%)
cumulative += tierProbability.SR;
if (random < cumulative) return 2;
// R (30%)
cumulative += tierProbability.R;
if (random < cumulative) return 1;
// N (50%)
return 0;
}
/**
* @dev 토큰 ID 생성 함수
* @param gachaType 가챠 타입
* @param tier 티어
* @return tokenId 생성된 토큰 ID
*/
function _generateTokenId(uint256 gachaType, uint256 tier) internal returns (uint256) {
uint256 startId = gachaType == 0 ? SHAPE_TOKEN_START : NUMBER_TOKEN_START;
uint256 tokenId;
// 티어별 범위 내에서 랜덤 토큰 ID 생성
uint256 tierStart = startId + (tier * 250); // 각 티어당 250개씩
uint256 tierEnd = tierStart + 249;
// 이미 민팅된 토큰이 아닐 때까지 반복
do {
tokenId = tierStart + (uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender, _tokenIds.current()))) % 250);
} while (isTokenMinted[tokenId] && tokenId <= tierEnd);
require(tokenId <= tierEnd, "No available tokens in this tier");
isTokenMinted[tokenId] = true;
_tokenIds.increment();
return tokenId;
}
/**
* @dev 티어 확률 업데이트 (관리자만)
*/
function updateTierProbability(
uint256 _N,
uint256 _R,
uint256 _SR,
uint256 _SSR
) external onlyOwner {
require(_N + _R + _SR + _SSR == 10000, "Total probability must be 100%");
tierProbability = TierProbability(_N, _R, _SR, _SSR);
}
/**
* @dev 가챠 토큰 배치 민팅 (관리자만)
*/
function batchMintGachaTokens(
address[] calldata to,
uint256[] calldata amounts,
uint256 gachaType
) external onlyOwner {
require(to.length == amounts.length, "Arrays length mismatch");
uint256 gachaTokenId = gachaType == 0 ? GACHA_TOKEN_ID : NUMBER_GACHA_TOKEN_ID;
for (uint256 i = 0; i < to.length; i++) {
_mint(to[i], gachaTokenId, amounts[i], "");
}
}
/**
* @dev 토큰 URI 설정
*/
function setURI(string memory newuri) public onlyOwner {
_setURI(newuri);
}
}