이번 프로젝트의 목표는 유저의 활동에 따른 인센티브 부여 앱이다.
그러므로 유저의 앱 이용 시 토큰 관련 시나리오를 정리했다.
1. 사용자가 회원 가입을 하면 스팀잇에서 사용할 address를 생성하여 사용자 정보와 함께 저장한다.
2. 사용자가 글을 작성하면 사용자의 address로 일정량의 토큰을 전송한다.
3. 사용자A가 다른 사용자B가 작성한 글에 좋아요를 누르면 A가 가진 토큰 중 일부를 B의 address로 전송한다.
4. 사용자는 마이 페이지에서 자신이 가진 토큰의 갯수를 조회할 수 있다.
사용자가 회원 가입을 하면 입력한 비밀번호를 기반으로 하여 새 니모닉 지갑을 생성한다.
생성한 지갑의 private key는 AES256으로 암호화 해 DB에 저장한다.
import Seed from 'mnemonic-seed-js'
import { web3 } from '../server'
export type Wallet = {
privateKey: string
publicKey: string
mnemonic: string
}
export async function generateWallet(reqPassword: string): Promise<Wallet> {
const seed = Seed.new({ passphrase: reqPassword })
const mnemonic = seed.mnemonic.toString() //mnemonic 문자열생성
const privateKey = '0x' + seed.privatekey.toString('hex') //private key 생성하는부분
const publicKey = web3.eth.accounts.privateKeyToAccount(privateKey).address // 지갑address생성
return {
privateKey,
publicKey,
mnemonic,
}
}
이번 프로젝트에서 사용한 토큰 관련 스마트 컨트랙트는 다음과 같다.
remix를 사용하여 ropsten 테스트 네트워크에 배포하였다.
이더스캔 링크 : https://ropsten.etherscan.io/token/0xda2cc46367a64a2798904f52ce39c4f66d4d776d
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.10;
interface ERC20Interface {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function transferFrom(address spender, address recipient, uint256 amount) external returns (bool);
event Transfer(address indexed from, address indexed to, uint256 amount);
event Transfer(address indexed spender, address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 oldAmount, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint value);
}
contract SIToken is ERC20Interface {
mapping (address => uint256) private _balances;
mapping (address => mapping (address => uint256)) public _allowances;
mapping(address => uint) public nonces;
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
uint256 public _totalSupply;
string public _name;
string public _symbol;
uint8 public _decimals;
uint private E18 = 1000000000000000000;
bytes32 public DOMAIN_SEPARATOR;
constructor() {
_name = "SI Token";
_symbol = "SIT";
_decimals = 18;
_totalSupply = 100000000 * E18;
_balances[msg.sender] = _totalSupply; // 추가
uint chainId;
assembly {
chainId := chainId
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256('EIP712Domain(string _name,string version,uint256 chainId,address verifyingContract)'),
keccak256(bytes(_name)),
keccak256(bytes('1')),
chainId,
address(this)
)
);
}
function name() public view returns (string memory) {
return _name;
}
function symbol() public view returns (string memory) {
return _symbol;
}
function decimals() public view returns (uint8) {
return _decimals;
}
function totalSupply() external view virtual override returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) external view virtual override returns (uint256) {
return _balances[account];
}
function transfer(address recipient, uint amount) public virtual override returns (bool) {
_transfer(msg.sender, recipient, amount);
emit Transfer(msg.sender, recipient, amount);
return true;
}
function allowance(address owner, address spender) external view override returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint amount) external virtual override returns (bool) {
uint256 currentAllowance = _allowances[msg.sender][spender];
require(_balances[msg.sender] >= amount,"ERC20: The amount to be transferred exceeds the amount of tokens held by the owner.");
_approve(msg.sender, spender, currentAllowance, amount);
return true;
}
function transferFrom(address sender, address recipient, uint256 amount) external virtual override returns (bool) {
_transfer(sender, recipient, amount);
emit Transfer(msg.sender, sender, recipient, amount);
uint256 currentAllowance = _allowances[sender][msg.sender];
require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");
_approve(sender, msg.sender, currentAllowance, currentAllowance - amount);
return true;
}
function _transfer(address sender, address recipient, uint256 amount) internal virtual {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
uint256 senderBalance = _balances[sender];
require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
_balances[sender] = senderBalance - amount;
_balances[recipient] += amount;
}
function _approve(address owner, address spender, uint256 currentAmount, uint256 amount) internal virtual {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
require(currentAmount == _allowances[owner][spender], "ERC20: invalid currentAmount");
_allowances[owner][spender] = amount;
emit Approval(owner, spender, currentAmount, amount);
}
function _approve(address owner, address spender, uint value) private {
_allowances[owner][spender] = value;
emit Approval(owner, spender, value);
}
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);
}
}
프론트엔드에서 createPost
리퀘스트를 보내면 post.resolvers
에서 사용자 요청을 처리한다.
// src/graphql/post/post.resolvers.ts
...
Mutation: {
async createPost(_: any, args: inputPost) {
// jwt 토큰 여부 확인
if (!verifyAccessToken(args.access_token)) {
return status.TOKEN_EXPIRED;
}
// post 테이블에 글 작성 데이터 입력
let post = await postModel.create({
title: args.title,
post_content: args.post_content,
user_id: args.user_id,
});
if (!post) {
return status.SERVER_ERROR;
}
let userInfo = await userModel.findOne({
where: {
id: args.user_id,
},
});
// 글 작성 사용자의 정보 전달하고 토큰 발행
if (userInfo) {
sendTokenToWriter(userInfo.account, userInfo.id);
}
if (args.images != null && args.images.length > 0) {
for (let image of args.images) {
let savedImage = await imageModel.create({
image_path: image,
post_id: post.id,
});
if (!savedImage) {
return status.SERVER_ERROR;
}
}
}
if (args.hashtags != null && args.hashtags.length > 0) {
for (let inputHashtag of args.hashtags) {
var hashtag = await hashtagModel.findOne({
where: {
hashtag: inputHashtag,
},
});
if (!hashtag) {
hashtag = await hashtagModel.create({
hashtag: inputHashtag,
});
}
await postHashtagModel.create({
post_id: post.id,
hashtag_id: hashtag.id,
});
}
}
return post.id;
},
},
token util에서 글을 작성한 사용자에게 토큰을 전송한다.
// src/token/tokenUtil.ts
import { SITokenABI } from './SITokenABI'
import userModel from "../models/user.model";
// import { signERC2612Permit } from 'eth-permit';
// import { ethers } from 'ethers'
const TOKEN_CONTRACT_ADDRESS='0xDa2cC46367a64A2798904f52Ce39c4f66D4d776d'
const TOKEN_CONTRACT_DEPLOYED_OWNER_ADDRESS='0x3dD51a69aA684EC7CbE2CC2C075311aA872C7A4B'
const VOTING_TOKEN_AMOUNT=1
const INCENTIVE_TOKEN_AMOUNT=3
const GAS_FEE=1000000
require('dotenv').config()
declare var process : {
env: {
INFURA_ENDPOINT: string,
SPARK_IT_TOKEN_ACCOUNT_SECRET_KEY: string,
PRIVATE_KEY_SECRET: string
}
}
export async function sendTokenToWriter(writerAccount:String, writerUserId:Number){
const Web3 = require('web3');
// infura 엔드포인트로 web3 연결
const web3 = new Web3(new Web3.providers.HttpProvider(process.env.INFURA_ENDPOINT));
const contract = new web3.eth.Contract(SITokenABI, TOKEN_CONTRACT_ADDRESS, { from: TOKEN_CONTRACT_DEPLOYED_OWNER_ADDRESS, gas: GAS_FEE});
// 토큰 전송을 위해 토큰 발행 계정 private key add
web3.eth.accounts.wallet.add(process.env.SPARK_IT_TOKEN_ACCOUNT_SECRET_KEY);
// 글 작성한 유저의 address로 토큰 전송
await contract.methods.transfer(writerAccount, INCENTIVE_TOKEN_AMOUNT).send({from: TOKEN_CONTRACT_DEPLOYED_OWNER_ADDRESS, gas: GAS_FEE});
// DB에 저장된 유저 토큰 잔고 수 업데이트
updateUserBalance(writerUserId, writerAccount)
}
async function updateUserBalance(userId:Number, userAccount:String) {
// 유저의 현재 토큰 보유량 조회
let currentBalance = await getUserBalance(userAccount)
// DB에 현재 보유량으로 데이터 업데이트
await userModel.update({balance: currentBalance}, {where: {id: userId}})
}
export async function getUserBalance(userAccount:String) {
const Web3 = require('web3');
const web3 = new Web3(new Web3.providers.HttpProvider(process.env.INFURA_ENDPOINT));
const contract = new web3.eth.Contract(SITokenABI, TOKEN_CONTRACT_ADDRESS, { from: TOKEN_CONTRACT_DEPLOYED_OWNER_ADDRESS, gas: GAS_FEE});
// 현재 유저의 토큰 보유량으로 데이터 업데이트
return await contract.methods.balanceOf(userAccount).call()
}
사용자가 다른 사용자의 게시글에 좋아요를 눌렀을 때 자신이 가진 토큰을 일부 전송하는 기능을 구현하였다.
이때 트랜잭션 가스비 관련 이슈가 있었는데, 전체 회고에서 자세히 설명하겠다.
// src/token/tokenUtil.ts
export async function voteToOtherUser(senderAccount:string, senderPrivateKey:String, senderUserId:Number, receiverAccount:String, receiverUserId:Number) {
const Web3 = require('web3');
const web3 = new Web3(new Web3.providers.HttpProvider(process.env.INFURA_ENDPOINT));
const contract = new web3.eth.Contract(SITokenABI, TOKEN_CONTRACT_ADDRESS, { from: TOKEN_CONTRACT_DEPLOYED_OWNER_ADDRESS, gas: GAS_FEE});
//aes256으로 암호화 된 private key 디코딩
const decryptedPrivateKey = require('aes256').decrypt(process.env.PRIVATE_KEY_SECRET, senderPrivateKey)
web3.eth.accounts.wallet.add(decryptedPrivateKey);
// 좋아요를 누른 사용자의 계정에서 글을 작성한 사용자의 계정으로 토큰을 전송한다.
await contract.methods.transfer(receiverAccount, VOTING_TOKEN_AMOUNT).send({from: senderAccount, gas: GAS_FEE});
// permit 사용 시도
// const wallet = new ethers.Wallet(decryptedPrivateKey, new ethers.providers.JsonRpcProvider(process.env.INFURA_ENDPOINT));
// const senderAddress = wallet.address
// const result = await signERC2612Permit(wallet, tokenAddress, senderAddress, spender, value);
// await token.methods.permit(senderAddress, spender, value, result.deadline, result.v, result.r, result.s).send({
// from: senderAddress,
// });
// const result = await signERC2612Permit(wallet, TOKEN_CONTRACT_ADDRESS, wallet.address, TOKEN_CONTRACT_DEPLOYED_OWNER_ADDRESS, VOTING_TOKEN_AMOUNT);
// await contract.methods.permit(wallet.address, TOKEN_CONTRACT_DEPLOYED_OWNER_ADDRESS, VOTING_TOKEN_AMOUNT, result.deadline, result.v, result.r, result.s).call({
// from: senderAccount,
// });
updateUserBalance(senderUserId, senderAccount)
updateUserBalance(receiverUserId, receiverAccount)
}