본문 바로가기
포트폴리오/Project_2

P2: Web2.0 Blockchain Community 제작하기

by StelthPark 2022. 1. 3.

1. 서론

  1. 팀장
  2. 데이터베이스 구현
  3. 서버 API 개발
  4. ERC-20 관련 구현
  5. ERC-721 관련 구현
  6. React 구조 구현
  7. CSS 보조

 

2. 개발 내용

  1. 회원가입 및 로그인
  2. ERC-20 배포
  3. ERC-721 배포
  4. 가스비 faucet
  5. ERC-20토큰으로 NFT 판매 및 구매
  6. ERC-20 컨트랙트 관련 트랙잭션 기록
  7. 트랜잭션을 한번에 몰아서 처리하기
  8. 게시글 작성 시 ERC-20 토큰 보상
  9. 잔고 확인 및 유저 간 토큰 전송

 

3. 세부 기능

  1. 회원가입을 통하여 서버 DB에 회원정보를 저장시킨후 로그인 할 수 있다.
  2. Server 계정만이 테스트 네트워크(Ganache)로 부터 가스비용 1ETH를 faucet 할 수 있다.
  3. Server 계정만이 테스트 네트워크(Ganache)에 ERC-20 컨트랙트를 배포할 수 있다.
  4. Server 계정만이 테스트 네트워크(Ganache)에 ERC-721 컨트랙트를 배포할 수 있다.
  5. DB에 저장된 사용자는 로그인하여 글을 작성 할 수있으며 해당 글은 메인페이지에 바로 등록되어 나타난다.
  6. 글을 작성하면 ERC-20의 Approve에 의해 Server주소의 Allowance에 발행한 토큰 1개가 저장되며 보상받는다.
  7. 토큰 보상은 서버가 지정한 시간을 주기로 트랜잭션을 모아서 처리한다.
  8. ERC-20 컨트랙트 관련 트랙잭션을 DB에 저장 시킨다.
  9. 보상받은 토큰으로 서버가 판매중인 NFT를 구매할 수 있으며 구매시 보유한 Allowance에 저장 된 ERC-20 토큰이 감소하고 NFT를 입금 받게 된다.
  10. 사용한 ERC-721 솔리디티 코드에 서버가 판매할 NFT가격은 1토큰으로 정해져있지만 setPrice를 통해 가격을 Server만이 변경 할 수있다.
  11. 마이페이지를 통해 현재 Allowance되어 있는 보상된 토큰 수를 확인 할 수 있으며 해당 토큰을 실제 MetaMask에서 잔고확인이 가능하도록 전송을 할 수 있다.
  12. 현재 Allowance되어 있는 보상된 토큰을 DB에 저장 된 다른회원에게 전송 할 수 있다.

 

4. 주 의

  • 로그인상태가 유지되도록 구현이 안된 상태이므로 새로고침시 로그아웃이 된다.
  • .env 파일내에 테스트네트워크의 faucet 줄 계정 주소와 테스트네트워크 주소가 입력되어야한다.
  • .env 파일내에 Server 계정의 address와 privateKey가 입력되어야한다.
  • .env 파일내에 ERC-20, ERC-721 컨트랙트 주소가 입력되어야한다.
  • .env 파일내에 demon을 위한 정상적인 경로와 MySQL 패스워드가 입력되어야한다.
  • 동일한 DB구조를 가지고 있어야한다.
  • Server가 ERC를 배포할려면 Faucet을 받아 가스비가 있어야한다.
  • 글 작성시 테이블내 text 컬럼이 데이터타입이 varchar(255)라 글작성수에 제한이 있다. 

 

5. 개발 방향 및 사용 기술

유저가 게시글 작성 시 토큰을 보상받게 되는데 해당 토큰을 유저간에 전송 할려면 유저간에 가스비가 있어야하므로 Ganache로 부터 가스비를 faucet 받야아한다고 생각했다. 하지만 실제 배포 운영에서는 가스비를 입금해주는 구조가 말이 안되기도 하고 토큰보상시 1차 가스비, 유저 간 전송시 2차 가스비가 생기므로 이를 처음부터 서버가 가스비 맡게 하기 위해 ERC-20의 Approve를 사용하여 Server에게 보상 토큰을 위임하게 된다. 문제는 실제 Metamask에 보상 받은 토큰이 들어오지 않고(소유주가 아니고 입금받은게 실제로 아니기에) 별도로 Allowance에 저장 된 본인의 토큰을 출금하는 과정을 통해 진짜 소유주가 되게 된다. 결국에는 진짜 소유주로 토큰을 입력받아도 소유주간 전송에는 가스비가 들게된다.

 

  • React
  • JavaScript
  • Node.js
  • pm2
  • MySQL
  • Express
  • Web3
  • Sequelize
  • dotenv

 

6. 데이터베이스 구조

 

7. 기능 별 로직

회원가입 및 지갑정보 발급 및 DB 저장

router.post('/user', async(req,res) => {
  // 포스트맨에서 userName, password를 넣으면
  let reqUserName, reqPassword;
  reqUserName = req.body.userName;
  reqPassword = req.body.password;

  // user에서 find로 userName을 찾고,
  db.users.findOrCreate({
    where: {
      userName: reqUserName
    },
    default: {
      password: reqPassword
    }
  })
  .then(([user, created]) => {
    if (!created) {
      // 있으면 있다고 응답
      res.status(201).send("User exists");
    // 없으면 DB에 저장
    } else {
      // 니모닉코드 생성  
      let mnemonic;
      mnemonic = lightwallet.keystore.generateRandomSeed();
      // 생성된 니모닉코드와 password로 keyStore, address 생성
      lightwallet.keystore.createVault({
        password: reqPassword, 
        seedPhrase: mnemonic,
        hdPathString: "m/0'/0'/0'"
      },
      function (err, ks) {
        ks.keyFromPassword(reqPassword, function (err, pwDerivedKey) {
          ks.generateNewAddress(pwDerivedKey, 1);
          
          let address = (ks.getAddresses()).toString();
          let prv_key = ks.exportPrivateKey(address,pwDerivedKey);
          let keyStore = ks.serialize();

          db.users.update({
            password: reqPassword,
            address: address,
            privateKey: prv_key,
            mnemonic: mnemonic,
            tokenbalance : 0
          }, {
            where: {
              userName: reqUserName
            }
          })
          .then(result => {
            // 주소를 보여준다
            
            res.json(address);
          })
          .catch(err => {
            console.error(err);
          })
        });
      });
    }
  })
});

Client에서 post로 userName과 password를 받아 DB에 저장된 유저인지 확인 한후 없다면 새로운 mnemonic, keystore, address를 만들어 DB를 업데이트한다.

 

 

서버계정만이 테스트 네트워크(Ganache)로 부터 1ETH Faucet

router.post("/", (req, res) => {

    let reqUserName, reqPassword;
    reqUserName = req.body.userName; //서버만받도록
    reqPassword = req.body.password;
    
    if(reqUserName === 'server'){
      db.users.findOne({
        where: {
            userName: reqUserName,
            password: reqPassword,
        },
    }).then((result) => {
       
        if (result == null) {
            res.status(502).send({ message: "Error Transaction Failed" });
        } else { 
            web3.eth.accounts.privateKeyToAccount(result.dataValues.privateKey) //검색한 사용자의 프라이빗키
            web3.eth.accounts.privateKeyToAccount(env.GANACHE_PRIVATEKEY) //가나슈의 프라이빗키
    
           //서명 후 전송처리
  
           web3.eth.accounts.signTransaction({
            to: result.dataValues.address,
            value: '1000000000000000000',
            gas: 2000000
        },env.GANACHE_PRIVATEKEY)
        .then((value)=>{
          return value.rawTransaction;
        })
        .then(async(tx)=>{
          
          web3.eth.sendSignedTransaction(tx, async function(err,hash){
            if(!err){
              const addressBalance = await web3.eth.getBalance(result.dataValues.address)
          
              res.status(200).send({
                message: "Faucet Successed",
                data:{
                  username: reqUserName,
                  address: result.dataValues.address,
                  balance: addressBalance,
                  txHash: hash
                }
              })
            }
            else{
              console.log('transfer failed!')
            }
          })
        })
        }
    });
    }
    else{
      res.status(501).send({message: 'You are not server'})
    }
  });

Client로 부터 받은 userName이 server인지 확인한 후 web3메소드를 통해 faucet받는다.

 

서버계정만이 ERC-20 배포

async function deployToken() {
    try {
        await web3.eth.accounts.wallet.add(env.SERVER_PRIVATEKEY);
        const contract = new web3.eth.Contract(erc20abi, env.SERVER_ADDRESS);
        const receipt = contract
            .deploy({ data: bytecode, arguments: ["testToken", "TOT"] })
            .send({ from: env.SERVER_ADDRESS, gas: 2000000, gasPrice: "10000000000" })
            .then("transactionHash", async function (hash) {
              
            });
        return receipt;
    } catch (e) {
        console.log(e);
    }
}

router.post("/", async (req, res) => {

  let reqUserName, reqPassword;
  reqUserName = req.body.userName;
  reqPassword = req.body.password;
  
 if(reqUserName==='server'){
  db.users.findOne({
    where: {
        userName: reqUserName,
        password: reqPassword,
    },
}).then((result)=>{
  if (result == null) {
    res.status(502).send({ message: "저장된 서버 아이디/패스워드가 없습니다." });
}else{
  deployToken().then((hash) => {
    console.log(hash._address);
    res.status(200).send({contractAddress : hash._address});
});
}
})
 }
 else{
  res.status(501).send({message: 'You are not server'})
}
});

ERC721배포와 차이점이 있다면 deploy메소드에서 arguments가 ERC20은 토큰이름,심볼로 입력 받으며 ERC721의 경우 솔리디티 내부에 constructor에 선언되어있다.

 

 

글 작성시 DB에 tokenbalance 입력시키기

outer.post("/", (req, res) => {
    let reqUserName, reqPassword;
    reqUserName = req.body.userName;
    reqPassword = req.body.password;

    db.users.findOne({
        where: {
            userName: reqUserName,
            password: reqPassword,
        },
    }).then(async(result) => {
        if (result == null) {
            res.status(502).send({
                message: "주소/패스워드 누락 또는 존재하지 않음"
            });
        } else {

            let updatebalance = parseInt(result.dataValues.tokenbalance) + 1000000000000000000;
                 await db.users.update({
                    tokenbalance : String(updatebalance)
                  }, {
                    where: {
                      userName: result.dataValues.userName,
                    }
                  })
                  return result
        }
    }).then((result)=>{
       
        db.users.findOne({
            where: {
                userName: result.dataValues.userName,
                password: result.dataValues.password,
            },
        }).then((result2)=>{
                            res.status(200).send({
                    message: "Serving Successed",
                    data: {
                        username: result2.dataValues.userName,
                        address: result2.dataValues.address,
                        tokenBalance: result2.dataValues.tokenbalance,
                    },
                });
        })
    })
});

pm2로 트랙잭션을 모아 처리하기 전 DB에 tokenbalance로 쌓아두게 된다. 해당 로직에서는 글작성시 토큰1개를 주도록 작성되었다.

 

 

pm2로 트랜잭션 모아서 처리하기

const updateData = async() => await users.findAll({
    where: {
        [Op.not]:[
            {
                tokenbalance:0
            }
        ]
    }
}).then(async(result)=>{
    await web3.eth.accounts.wallet.add(env.SERVER_PRIVATEKEY);
let contract = await new web3.eth.Contract(erc20abi, env.ERC20_CONTRACT_ADDRESS, {
  from: env.SERVER_ADDRESS,
});

    	for(let i=0 ; i<result.length ; i++){

            await contract.methods
              .allowance(env.SERVER_ADDRESS, result[i].dataValues.address)
              .call()
              .then((e) => {
                  return e;
              }).then(async (balance)=>{
                const changeBalance = parseInt(balance)+parseInt(result[0].dataValues.tokenbalance)
                await contract.methods.approve(result[i].dataValues.address, String(changeBalance))
                .send({ from: env.SERVER_ADDRESS, gas: 2000000, gasPrice: "100000000000"})
                .on("receive", (receive) => {
                    return receive;
                })
                .then((tx) => {
                    return tx.transactionHash;
                })
                .then((tx) => {
                    contract.methods
                        .allowance(env.SERVER_ADDRESS,result[i].dataValues.address)
                        .call()
                        .then((e) => {
                            return e;
                        })
                        .then(() => {
                            console.log('transfer success!')
                        });
                });
              })

              await users.update({
                tokenbalance : 0
              }, {
                where: {
                  userName: result[i].dataValues.userName,
                }
              }) 
        }
   
})

DB에 저장된 보상대기중인 tokenbalance가 0이상인 유저들을 모아 각 유저들을 서버의 Approve로 위임하게된다.

 

 

NFT 구매

router.post("/buynft", async (req, res) => {

  let reqUserName, reqPassword;
  reqUserName = req.body.userName;
  reqPassword = req.body.password;
  tokenURI = "https://www.futurekorea.co.kr/news/photo/202104/145945_150512_1130.jpg"

  db.users.findOne({
    where: {
      userName: reqUserName,
      password: reqPassword,
    },
  }).then(async (result) => {
    if (result == null) {
      res.status(502).send({
        message: "주소/패스워드 누락 또는 존재하지 않음"
      });
    } else {

      await web3.eth.accounts.wallet.add(env.SERVER_PRIVATEKEY);
      let contract = await new web3.eth.Contract(erc721abi, env.ERC721_CONTRACT_ADDRESS, {
        from: env.SERVER_ADDRESS,
      });
      let contract2 = await new web3.eth.Contract(erc20abi, env.ERC20_CONTRACT_ADDRESS, {
        from: env.SERVER_ADDRESS,
      });

      //NFT 구매 로직 시작
      await contract.methods
        .setToken(env.ERC20_CONTRACT_ADDRESS)
        .call()
        .then(async (token) => {
          try {
            await contract.methods
              .callPrice(token, result.dataValues.address)
              .call()
              .then(async (nftPrice) => {
                //allowance 마이너스
                await contract2.methods
                  .allowance(env.SERVER_ADDRESS, result.dataValues.address)
                  .call()
                  .then(async (balance) => {
                    const changeBalance = parseInt(balance) - parseInt(nftPrice)
                    const changeBalance2 = String(changeBalance)
                    await contract2.methods.approve(result.dataValues.address, changeBalance2)
                      .send({
                        from: env.SERVER_ADDRESS,
                        gas: 2000000,
                        gasPrice: "100000000000"
                      })
                      .then(async (x) => {
                        //nft 토큰 전송
                        await contract.methods
                          .mintNFT(result.dataValues.address, tokenURI)
                          .send({
                            from: env.SERVER_ADDRESS,
                            gas: 2000000,
                            gasPrice: "100000000000"
                          })
                          .then((receipt2) => {
                            res.status(201).send({
                              tx: receipt2.transactionHash,
                              message: 'NFT 구매 완료'
                            })
                          })
                      })

                  })
              })
          } catch (error) {
            res.status(201).send({
              message: "토큰이 부족합니다."
            })
          }
        })
    }
  })
});

ERC721 솔리디티 로직 내부에 있는 setToken을 사용하여 ERC20 컨트랙트를 불러온다. ERC721 로직에 있는 callPrice를 통해 내부에서 각 유저는 글작성이나 서로간의 토큰 전송으로 Approve하여 Allowance로 가진 양이 NFT의 가격보다 많은지 검사하게 된다. 이후 통과하면 callPrice는 NFT가격을 return하게 된다. NFT가격만큼 Allowance Balance를 차감하고 다시 Approve 한 다음 NFT 토큰을 전송하게된다.

 

 

새 함수가 추가된 ERC-721

더보기
더보기
contract NFTLootBox is ERC721URIStorage, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
IERC20 token;
uint256 nftPrice;

 

constructor() ERC721("MyNFTs", "MNFT") {
nftPrice = 1e18;
}

 

function mintNFT(address recipient, string memory tokenURI) public onlyOwner returns (uint256) {
_tokenIds.increment();
uint256 newItemId = _tokenIds.current();
_mint(recipient, newItemId);
_setTokenURI(newItemId, tokenURI);
return newItemId;
}
 
function setToken (address tokenAddress) public onlyOwner returns (IERC20) {
// 발행한 ERC-20을 불러온다.
require(tokenAddress != address(0x0));
token = IERC20(tokenAddress);
return token;
}
 
function callPrice(IERC20 token, address recipient) public view returns (uint256) {
// NFT 가격과 사용자 잔고를 비교하고 NFT가격을 리턴한다.
require(token.allowance(msg.sender, recipient) > nftPrice);
return nftPrice;
}
 
function price() public view returns (uint256){
// 단순히 NFT가격을 리턴한다.
return nftPrice;
}
 
function setPrice(uint256 price) public onlyOwner returns (bool){
// NFT가격을 입력받고 변경시킨다.
nftPrice = price;
return true;
}
}

 

8. 개발 회고

Keep: Client에서 Server로 api를 날릴때 마다 컴포넌트 이름을 정확하게 구분하여 중간에 로직수정이 들어가도 쉽게 수정하고 찾을 수 있도록 하였다. 실제 배포하여 서비스를 운영할 수 있는것에 중점을 두었기 때문에 앞서 말했던 유저간의 전송 가스비를 최대한 서버가 부담할 수 있도록 하였고 Sequelize를 사용하여 DB를 구성함에 있어 효율적으로 만들었고 이후 다른 환경에서 개발할때도 Sequelize를 통해 손쉽게 DB를 구성할 수있다. Dotenv를 사용하여 환경변수로 최대한 개발하는 과정에서 반복적으로 사용되는 함수들을 쉽게 관리 할 수 있도록 하였다.

 

Problem: ERC-721 솔리디티 작성에서 mintNFT함수 내부에서 사용자의 ERC-20 토큰 수량이 NFT가격보다 많음을 require로 검사하고 싶었으나 함수내에서 함수를 사용하면 msg.sender가 함수를 실행한 함수로 바뀐다는 문제 때문에 일단은 해당 로직을 제거하고 Server에서 비동기처리를 통해 순차적으로 검사한후 NFT가 발급되도록 하였다. 또한 서버에게 가스비를 부담하기 위해서 Approve를 사용하였는데 ERC-20을 사용하긴 했으나 실제 토큰 소유자가 되지 못한다는 점에서 결국 소유주로 해당 Allowance만큼 입금해주고 받은 토큰을 다른 유저에게 보낼땐 다시 Server가아닌 소유자인 유저가 가스비를 부담하게 되었다. 글작성시 text컬럼이 데이터타입 varchar(255)라 글자수 제한이 있어 text타입으로 바꾸어야한다.

 

Try: 솔리디티 함수내 함수를 적절히 배치할 수 있는 방법과 완전히 유저간 토큰 전송부터 커뮤니티 활동간에 ERC-20 토큰이 활용 되는 부분의 가스비를 서버나 특정 인이 지불 할 방법을 찾아봐야겠다. 어쩌면 Approve를 잘못 사용하고 있을지도.. 그래서 Approve시 토큰이 함께 입금되고 해당 발란스를 볼 수있도록 해봐야 할 것이다. 

 

9. 작동 영상

프론트 작동 DB 및 ERC관련

 

pm2로 트랜잭션 일괄 처리

 

NFT구매 및 토큰 전송

 

10. 코드 및 주소

Github : https://github.com/Parkstelth/Project02_Web2.0_Blockchain_Community/tree/master

 

GitHub - Parkstelth/Project02_Web2.0_Blockchain_Community

Contribute to Parkstelth/Project02_Web2.0_Blockchain_Community development by creating an account on GitHub.

github.com

 

 

 

'포트폴리오 > Project_2' 카테고리의 다른 글

P1: Opensea 제작하기  (4) 2021.12.20

댓글