728x90
안녕하세요, 오늘은 제가 BNB 테스트넷에서 작동하는 DApp 프로젝트 BNB.Fun을 개발하며 겪은 과정을 공유하려고 합니다. 이 프로젝트는 토큰 발행부터 프리세일(구매/환불) 기능까지 구현하는 것을 목표로 했고, 여러 시행착오 끝에 성공적으로 완성했습니다. 그 여정을 정리해 보겠습니다.
 
지난번까지 개발한 내용은 React(프론트앤드) <ㅡ> 백앤드(컨트랙트) 연동하여
웹 사이트(localhost)에서 토큰을 발행하고 웹사이트에 발행한 토큰들이 웹에 표시되는것까지
구현 완료했습니다.
 

Step 1: 컨트랙트 코드(Solidity)

먼저, TokenFactory.sol, CustomToken.sol, Presale.sol 세 개의 컨트랙트를 작성하고 배포했습니다.
  • TokenFactory:
    • createToken(name, symbol): 0.01 BNB를 받고 CustomTokenPresale 인스턴스를 생성.
    • withdraw(): 컨트랙트에 쌓인 BNB를 소유자에게 전송가능하도록 구현 
    • 배포 주소: 0x7E5b012c635d9bB9c19aAD3E37F059C4410E3AA1. (bsc testnet)
     
  • CustomToken:
    • ERC-20 기반 토큰, 초기 공급량은 Presale에 할당.
    • 예: 0x173ACc12fd867f2bee4bECB1e05a2e2fB106f1Ee.
  • Presale:
    • buyTokens(): BNB로 토큰 구매.
    • refund(): 투자한 BNB 환불 및 토큰 반환.
    • 예: 0x790fc6bB52B34488D9b6a9630e7DcC516d47c8a6.

 

Step 2: 프론트엔드 구현

 
React 프로젝트를 생성하고, 주요 컴포넌트를 구현했습니다.
  • App.js:
    • MetaMask 연결 및 tokens 상태 관리.
    • 로컬 스토리지로 tokens 유지 (새로고침 시 초기화 방지).
  • CreateTokenModal.js:
    • 토큰 이름, 심볼 입력 후 TokenFactory.createToken 호출.
    • 발행된 tokenAddresspresaleAddresstokens에 추가.
  • PresalePage.js:
    • URL 파라미터(tokenAddress)로 토큰 식별.
    • tokens에서 presaleAddress 찾아 프리세일 데이터 로드.
    • "Buy Now"와 "Refund" 버튼으로 구매/환불 기능 제공.

 

 


Step 3: 디버깅과 해결 과정

개발 중 여러 문제를 만났고, 하나씩 해결해 나갔습니다.
  1. "Transaction execution reverted":
    • 원인1 : 가스 한도 부족 또는 잔액 문제 / 
    • 해결: gasLimit: 3000000으로 증가, 계정에 0.1 BNB 충전.
    • 원인2. :  CustomTokenPresale 주소를 필요로 하는데, PresaleCustomToken 주소를 필요로 함 → 순서 문제로 인해 CustomToken의 초기 공급량이 잘못된 주소(address(0))로 민팅되거나, 이후 Presale에서 토큰을 다룰 때 오류 발생.
    • 해결: Presale을 먼저 만들어 presaleAddress를 확보한 뒤, 이를 CustomToken에 전달 → CustomToken이 올바른 Presale 주소에 민팅하고, Presale은 이후 업데이트로 토큰을 인식.
      *이 문제로 진짜 몇일동안 뭐가 문제인지 고민함...(백업하고 구현하고 문제발생되고 다시 백업하고 구현하고 문제 생기고.. 반복)
  2. "Presale address not found in local token list":
    • 원인: PresalePagetokens에서 데이터를 찾지 못함.
    • 해결: URL을 최신 토큰 주소로 수정, tokens 상태를 로컬 스토리지로 유지.
  3. "limit exceeded" (eth_getLogs):
    • 원인: TokenFactory 이벤트 조회 시 블록 범위 초과.
    • 해결: eth_getLogs 대신 tokens 상태만 사용.

Step 4: 최종 테스트

최신 토큰 발행 후 프리세일 페이지 접속
  • URL: http://localhost:3000/presale/0x173ACc12fd867f2bee4bECB1e05a2e2fB106f1Ee.
  • 결과:
    • "Presale Details"에 현재 비율, 모금액 표시.
    • "Buy Now"로 0.01 BNB 구매 성공.
    • "Refund"로 환불 성공.
    •  
 
 
 

주요코드(Solidity)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./customToken.sol";
import "./Presale.sol";

contract TokenFactory is Ownable {
    uint256 public constant CREATE_FEE = 0.01 ether;

    event TokenCreated(address tokenAddress, address presaleAddress);

    constructor() Ownable(msg.sender) {}

    function createToken(string memory name, string memory symbol) external payable returns (address, address) {
        require(msg.value == CREATE_FEE, "Must send exactly 0.01 BNB");

        // Presale 먼저 생성
        Presale newPresale = new Presale(IERC20(address(0)));
        address presaleAddress = address(newPresale);

        // CustomToken 생성 및 Presale로 토큰 전송
        CustomToken newToken = new CustomToken(name, symbol, presaleAddress);
        address tokenAddress = address(newToken);

        // Presale에 토큰 설정 및 소유권 이전
        newPresale.updateToken(IERC20(tokenAddress));
        newPresale.transferOwnership(msg.sender);

        emit TokenCreated(tokenAddress, presaleAddress);
        return (tokenAddress, presaleAddress);
    }

    function withdraw() external onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }
}

 

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

interface IPancakeSwapRouter {
    function addLiquidityETH(
        address token,
        uint256 amountTokenDesired,
        uint256 amountTokenMin,
        uint256 amountETHMin,
        address to,
        uint256 deadline
    ) external payable returns (uint256 amountToken, uint256 amountETH, uint256 liquidity);
}

contract Presale is Ownable, ReentrancyGuard {
    IERC20 public token;
    uint256 public constant INITIAL_RATE = 10_000_000 * 10**18; // 1 BNB = 1,000만 토큰
    uint256 public constant PRICE_INCREASE = 10; // 10% 증가
    uint256 public constant INCREASE_INTERVAL = 1 hours; // 1시간마다
    uint256 public constant TARGET_BNB = 5 ether; // 5 BNB 목표
    uint256 public constant LIQUIDITY_TOKENS = 50_000_000 * 10**18; // 5천만 토큰

    uint256 public startTime;
    uint256 public totalBNB;
    bool public liquidityAdded;

    mapping(address => uint256) public contributions; // 투자 BNB
    mapping(address => uint256) public tokenBalances; // 구매 토큰

    IPancakeSwapRouter public pancakeSwapRouter;
    address public constant PANCAKESWAP_ROUTER = 0xD99D1c33F9fC3444f8101754aBC46c52416550D1; // BSC Testnet 라우터

    event TokensPurchased(address buyer, uint256 bnbAmount, uint256 tokenAmount);
    event Refunded(address user, uint256 bnbAmount);
    event LiquidityAdded(uint256 tokenAmount, uint256 bnbAmount);

    constructor(IERC20 _token) Ownable(msg.sender) {
        token = _token;
        pancakeSwapRouter = IPancakeSwapRouter(PANCAKESWAP_ROUTER);
        startTime = block.timestamp;
    }

    function getCurrentRate() public view returns (uint256) {
        uint256 intervals = (block.timestamp - startTime) / INCREASE_INTERVAL;
        return INITIAL_RATE - (INITIAL_RATE * PRICE_INCREASE * intervals) / 100; // 토큰 수 감소
    }

    function buyTokens() external payable nonReentrant {
        require(!liquidityAdded, "Presale ended");
        require(msg.value > 0, "Must send BNB");

        uint256 rate = getCurrentRate();
        uint256 tokenAmount = (msg.value * rate) / 1 ether;
        require(token.balanceOf(address(this)) >= tokenAmount, "Not enough tokens");

        contributions[msg.sender] += msg.value;
        tokenBalances[msg.sender] += tokenAmount;
        totalBNB += msg.value;

        token.transfer(msg.sender, tokenAmount);
        emit TokensPurchased(msg.sender, msg.value, tokenAmount);

        if (totalBNB >= TARGET_BNB) {
            addLiquidity();
        }
    }

    function refund() external nonReentrant {
        require(!liquidityAdded, "Presale ended");
        uint256 bnbAmount = contributions[msg.sender];
        require(bnbAmount > 0, "No contribution");

        uint256 refundAmount = (bnbAmount * 90) / 100;
        uint256 tokenAmount = tokenBalances[msg.sender];

        contributions[msg.sender] = 0;
        tokenBalances[msg.sender] = 0;
        totalBNB -= bnbAmount;

        require(token.transferFrom(msg.sender, address(this), tokenAmount), "Token transfer failed");
        payable(msg.sender).transfer(refundAmount);
        emit Refunded(msg.sender, refundAmount);
    }

    function addLiquidity() internal {
        require(!liquidityAdded, "Liquidity already added");
        liquidityAdded = true;

        token.approve(address(pancakeSwapRouter), LIQUIDITY_TOKENS);
        pancakeSwapRouter.addLiquidityETH{value: TARGET_BNB}(
            address(token),
            LIQUIDITY_TOKENS,
            LIQUIDITY_TOKENS * 90 / 100, // 10% 슬리피지 허용
            TARGET_BNB * 90 / 100,
            address(this),
            block.timestamp + 15 minutes
        );

        emit LiquidityAdded(LIQUIDITY_TOKENS, TARGET_BNB);

        uint256 remainingTokens = token.balanceOf(address(this));
        if (remainingTokens > 0) {
            token.transfer(owner(), remainingTokens);
        }
    }

    function withdrawBNB() external onlyOwner {
        require(liquidityAdded, "Presale not ended");
        payable(owner()).transfer(address(this).balance);
    }

    function updateToken(IERC20 _token) external onlyOwner {
        require(address(token) == address(0), "Token already set");
        token = _token;
    }
}
 
 
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract CustomToken is ERC20, Ownable {
    constructor(
        string memory name,
        string memory symbol,
        address presaleAddress
    ) ERC20(name, symbol) Ownable(msg.sender) {
        uint256 initialSupply = 100_000_000 * 10**18; // 1억 토큰
        _mint(address(this), initialSupply);
        if (presaleAddress != address(0)) {
            _transfer(address(this), presaleAddress, initialSupply);
        }
    }
}
 
 

 
<마무리>
 
BSC 테스트넷의 JSON-RPC 제한 문제를 해결하면서 많은 걸 배웠습니다. 이 DApp은 토큰 발행과 프리세일을 간단히 경험할 수 있는 좋은 시작점이 될 거라 생각합니다.
다음 목표는 차트 데이터를 실시간으로 반영하고, 프리세일이 5BNB(테스트목적으로 0.5BNB)가 프리세일로 모금이 되면 팬케이크 스왑에 자동으로 유동성이 공급되는것을 구현 및 테스트 할 예정입니다. 
 
 
<잡담>
.env파일에 자신의 메타마스크 개인key를 입력해놓는데, 실수로 github에 .env도 같이올렷다가 1만원(bnb) 털렸습니다 ㅎㅎ 신기해서 
 
다른지갑에 5000원(bnb)입금하고 깃허브에 올렸더니 10초만에 털렸습니다. 아무튼 개인key는 절대 어디에 올리지 마세요

 

728x90

+ Recent posts