DEX superswap 프로젝트를 진행하면서 틈틈히 작성했던 내용들을 정리하는 글입니다.
프로젝트 진행 중 주요 기능 개발 내용을 순서대로 나열했고, 작성한 코드를 중점으로 기능 설명을 추가했습니다. 따라서, 환경 설정과 관련된 기초 자료들은 해당 Velog에서 작성된 참고자료 링크로 대체했습니다.
Infura 회원가입 후 API Key, Goerli Testnet Endpoint 생성
참고자료: Goerli 이더리움 테스트넷 사용하기 with Infura
메타마스크 설치 후 계정 생성, Georli Testnet 설정, GoerliETH 발급 받기
참고자료: Goerli 이더리움 테스트넷 사용하기 with Infura
Truffle 환경 구축 및 설정은 해당 자료 참고
참고자료: Truffle - 스마트 컨트랙트 배포 에러 정리
Solidity 문법이나 이더리움 ERC-20 관련 내용은 아래 자료 참고
참고자료: [Solidity] ERC-20
contracts/AnemoToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
contract AnemoToken is ERC20 {
constructor() ERC20('AnemoToken', 'ANM'){
_mint(msg.sender, 100 * 10 ** uint(decimals()));
}
}
contracts/JhchaToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
contract JhchaToken is ERC20 {
constructor() ERC20('JhchaToken', 'JHC'){
_mint(msg.sender, 100 * 10 ** uint(decimals()));
}
}
migrations/contract_migration.js
const AnemoToken = artifacts.require("AnemoToken");
const JhchaToken = artifacts.require("JhchaToken");
module.exports = function (deployer) {
deployer.deploy(AnemoToken);
deployer.deploy(JhchaToken);
}
truffle compile
truffle migrate --reset --network goerli
C:\Users\USER\VsProject\superswap-dex-truffle>truffle console --network goerli
truffle(goerli)> AnemoToken.address
'0x4cFC4d72F7f5Ece837F12FAf1d844bA88D922Be1'
truffle(goerli)> JhchaToken.address
'0x754D052f34b2AC9997e251Ce57843282815E8902'
UI는 Github Superswap 프로젝트를 기반으로 작성한다.
참고자료: https://github.com/cajr11/superswap-dex
Superswap 프로젝트에서는 Moralis를 사용했고, 본 프로젝트에서는 Web3, Ehters, Uniswap을 사용한다. UI는 Uniswap UI를 참고하여 token balance, quote estimated value와 같이 여러 사소한 사용자 편의성을 위한 기능을 개선했다. 디자인은 약간의 tailwind css의 수정만 했을 뿐 추가적으로 변경하지 않았다. 자세한 변경 사항은 깃허브 링크에서 확인할 수 있다.
index.tsx
<React.StrictMode>
<MoralisProvider
serverUrl={SERVER_URL}
appId={APP_ID}
>
...
</MoralisProvider>
</React.StrictMode>,
Moralis를 사용하지 않기 때문에, MoralisProvider를 걷어내고 App.tsx에서 auth 상태 (isAuthenticated, isAuthenticating)를 관리한다.
App.tsx
const [isAuthenticated, setisAuthenticated] = useState(false);
const [isAuthenticating, setisAuthenticating] = useState(false);
navbar에서 wallet을 연결하는 컴포넌트, LoginMethodModal에서 해당 auth 상태 변경만 관리한다.
// 메타마스크 로그인 자세한 설명은 web3 키워드에서..
const loginMetamask = async () => {
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
});
...
setisAuthenticated(true);
setisAuthenticating(false);
}
{!isAuthenticating && !isAuthenticated && (
<div className="flex-1 rounded-2xl p-2 flex flex-col justify-between">
<div
className={`w-full h-[73px] flex justify-between items-center py-2 px-4 rounded-2xl ${
themeCtx.isLight ? "bg-gray-100" : "bg-blue-800"
} cursor-pointer`}
onClick={loginMetamask}
>
<span>{t("login.metamask")}</span>
<img src={metamask} alt="metamask" className="h-8 w-8" />
</div>
</div>
)}
React.useEffect(() => {
if(isAuthenticated){
onInitializeSwapForm();
}
}, [isAuthenticated]);
const onInitializeSwapForm = async () => {
const provider = new ethers.providers.Web3Provider(window.ethereum);
setProvider(provider);
const signer = provider.getSigner();
setSigner(signer);
signer.getAddress()
.then(address => {
setSignerAddress(address);
});
const firstBalace = await uniswapUtils.getTokenBalance(signer, firstToken.address);
setFirstBalance(utils.formatString(firstBalace));
const secondBalace = await uniswapUtils.getTokenBalance(signer, secondToken.address);
setSecondBalance(utils.formatString(secondBalace));
};
이후에 입력값에 대한 예상 거래 정보를 조회하기 위해 swap input의 변경에 따라 getQuote를 호출하고 결과 값을 저장한다. 마찬가지로, uniswapUtils 관련 API들은 다음 장에서 설명한다.
const getQuoteFirst = async (val: string) => {
setFirstAmount(val);
try {
await uniswapUtils.getQuote(
...
).then(data => {
setTransaction(data.transaction);
setGas(data.transaction.gasLimit);
setSecondAmount(data.quoteAmountOut);
setRatio(data.ratio);
});
} catch (error) {
let message;
if (error instanceof Error) message = error.message;
else message = String((error as Error).message);
getErrorMessage(message);
}
};
<DebounceInput
className="min-w-0 h-full rounded-2xl bg-gray-100 text-3xl font-medium font-inc focus:outline-none px-1"
placeholder={t("swap_form.placeholder")}
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => getQuote(e.target.value)}
value={inputValue}
/>
Uniswap UI와 유사하게 스왑 포지션과 해당 선택 토큰 밸런스에 대한 정보를 표시했다. position, balance css는 tailwind를 참고하여 아래와 같이 추가했다.
const position = ["From", "To"];
<span className="w-3/5 font-semibold bg-gray-100 text-slate-500 text-base focus:outline-none px-1 rounded-2xl ">
{position}
</span>
<span className="w-2/5 font-semibold text-slate-500 text-base rounded-2xl ">
{`balance: ${balance}`}
</span>
tailwind 참고자료: https://tailwindcss.com/docs/
Uniswap 컨트랙트 주소는 다음 링크 문서에서 확인할 수 있다.
참고자료: https://docs.uniswap.org/contracts/v3/reference/deployments
https://docs.uniswap.org/contracts/v3/guides/swaps/single-swaps
라우팅 참고자료
https://docs.uniswap.org/sdk/v3/guides/routing
Uniswap을 통해 ERC20 토큰을 거래하기 위해 토큰 페어가 존재해야 한다.
만약, 본 프로젝트에서 생성한 ERC20 토큰의 쌍과 같이 토큰 페어가 존재하지 않는다면 토큰 페어를 생성하고 유동성을 풀에 제공해야 한다.
유동성 제공 (provide liquidity)은 다음과 같이 정리할 수 있다.
1. 토큰 페어 선택
2. 수수료 (Fee Tier)설정
3. 가격 대역 설정
4. 제공할 유동성 금액 (Amount)설정
4.1. 만약, 토큰 페어에 대한 유동성 풀이 존재하지 않는다면, 시작가를 입력하여 유동성 풀을 먼저 생성하게 된다. (아래 생성 과정 참고)
5. 승인
Uniswap 스왑을 이용하기 위해 Uniswap 링크로 이동한다.
월렛 연결 후 설정 화면에서 'Show testnets'를 체크
Goerli testnet 네트워크로 설정
Settings > Pools > New Position
컨트랙트로 배포한 ERC20 토큰 검색 (token.address 입력)
Select Pair, Fee Tier, Price Range, Deposit Amounts 입력
이 때, 존재하는 유동성 풀이 없기 때문에 먼저 풀을 생성하게 된다. (0.001 입력 부분이 Token Pair에서 Starting Price가 된다.)
Add Liquidity
완료되면 Pool에서 확인할 수 있다.
최종적으로 생성한 Pools 목록
ERC20 컨트랙트로 발행한 ANM, JHC 토큰에 대하여 두 개의 유동성 풀을 생성했다.
Uniswap Application에서 토큰 페어를 생성하고, 생성한 ERC20 토큰에 대해 유동성을 제공했다. 이후에 UI에서 스왑 동작은 다음 장에서 단계별로 진행된다.
Uniswap을 기반으로 한 UI 스왑 동작은 다음과 같이 단계별로 진행한다.
const loginMetamask = async () => {
try{
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
setAccount(accounts);
setShortAddress(String(accounts).slice(0, 4) + "..." + String(accounts).slice(-2));
setisAuthenticated(true);
setisAuthenticating(false);
}catch (error) {
console.error("loginMetamask Error", error);
}
};
base project에서 사용하고 있던 Moralis를 삭제했다. window.ethereum은 메타마스크 혹은 다른 월렛 어플리케이션을 설치하면 추가된다.
request method는 사전에 정의되어있다. 메타마스크 로그인은 eth_requestAccounts, 로그아웃 (연결 해제)는 wallet_requestPermissions로 하면 된다.
UI 어플리케이션에서 인증 정보를 저장하는 isAuthenticated 상태 값은 index.tsx의 MoralisProvider를 대신하여 App.tsx에서 선언한다.
따라서, Swapform과 같이 하위 컴포넌트에서 아래와 같이 Props로 받아서 상태 값을 확인한다.
type SwapProps = {
isAuthenticated: boolean;
isAuthenticating: boolean;
...
};
const Swap = ({
isAuthenticated,
isAuthenticating,
}: SwapProps): JSX.Element => {
<SwapForm
isAuthenticated={isAuthenticated}
isAuthenticating={isAuthenticating}
...
/>
}
const logoutMetamask = async () => {
await window.ethereum.request({
method: "wallet_requestPermissions",
params: [{ eth_accounts: {} }],
});
setAccount("");
setShortAddress("");
setisAuthenticated(false);
setisAuthenticating(false);
}
const onInitializeSwapForm = async () => {
const provider = new ethers.providers.Web3Provider(window.ethereum);
setProvider(provider);
const signer = provider.getSigner();
setSigner(signer);
signer.getAddress()
.then(address => {
setSignerAddress(address);
});
const firstBalace = await uniswapUtils.getTokenBalance(signer, firstToken.address);
setFirstBalance(utils.formatString(firstBalace));
const secondBalace = await uniswapUtils.getTokenBalance(signer, secondToken.address);
setSecondBalance(utils.formatString(secondBalace));
};
export const uniswapUtils = {
getTokenBalance: async (signer, contractAddress) => {
const contract = new ethers.Contract(contractAddress, Erc20ABI, signer)
const bigNumberBalanace = await contract.balanceOf(signer.getAddress());
return ethers.utils.formatEther(bigNumberBalanace);
},
}
ethers.Contract로 배포한 컨트랙트 인스턴스를 생성한다. 해당 컨트랙트 인스턴스를 생성하기 위해 컨트랙트 주소와, signer 그리고 해당 컨트랙트에 대한 ABI이 필요하다. ABI는 3장에서 truffle compile 과정을 통해 생성된 ABI 파일을 가져오면 된다.
const Erc20ABI = require("../abi/ERC20.json").abi;
이 때, getTokenBalance 함수에서 가져오는 토큰은 ERC20 기반이기 때문에 ERC-20 컨트랙트의 ABI Json 파일을 인자로 넣으면 된다.
컨트랙트 인스턴스 생성 후 해당 컨트랙트의 balanceOf function으로 잔액을 조회하고 반환한다. 반환된 balance는 Uniswap UI처럼 스왑폼의 Input 오른쪽 상단위에 표시했다.
getQuote: async (inputToken, outputToken, inputAmount, slippageAmount, deadline, walletAddress) => {
const percentSlippage = new Percent(slippageAmount, 100);
const wei = ethers.utils.parseUnits(inputAmount.toString(), 18);
const currencyAmount = CurrencyAmount.fromRawAmount(tokenMap.get(inputToken), JSBI.BigInt(wei));
const options = {
recipient: walletAddress,
slippageTolerance: percentSlippage,
deadline: deadline,
type: SwapType.SWAP_ROUTER_02,
}
const route = await router.route(
currencyAmount,
tokenMap.get(outputToken),
TradeType.EXACT_INPUT,
options
);
const transaction = {
from: walletAddress,
to: V3_SWAP_ROUTER_02_ADDRESS,
value: BigNumber.from(route.methodParameters.value),
data: route.methodParameters.calldata,
gasPrice: BigNumber.from(route.gasPriceWei),
gasLimit: await this.estimateGasLimit(walletAddress,
V3_SWAP_ROUTER_02_ADDRESS,
BigNumber.from(route.methodParameters.value),
route.methodParameters.calldata
),
};
const quoteAmountOut = route.quote.toFixed(6);
const ratio = (inputAmount / quoteAmountOut).toFixed(3);
return {transaction, quoteAmountOut, ratio};
Uniswap 라우터에 전달할 옵션을 정의하고, router.route 메소드로 라우터에게 입력값에 대한 스왑 경로 및 계산을 실행한다. 이 때, 스왑 실행에 대한 transaction 정보를 생성하고, Uniswap 토큰 예상 거래 비율 값을 반환한다.
utils 파일에서 Uniswap 상태 변수값을 저장하고, 컴포넌트에서 추가적인 연산을 실행시키지 않기 위해 트랜잭션 형태로 반환하도록 했다. 이후에 해당 트랜잭션을 스왑 실행에 인자로 보내서 실행시킨다.
const makeSwap = async () => {
try{
const txHash = await uniswapUtils.executeSwap(transaction, signer, firstToken.address, firstAmount);
openTransactionModal(true);
getTxHash(txHash.hash);
setMadeTx(true);
const firstBalance = await uniswapUtils.getTokenBalance(signer, firstToken.address);
setFirstBalance(utils.formatString(firstBalance));
const secondBalance = await uniswapUtils.getTokenBalance(signer, secondToken.address);
setSecondBalance(utils.formatString(secondBalance));
} catch (error) {
...
}
setFirstAmount("");
setSecondAmount("");
setGas("");
setRatio("");
};
Uniswap의 스왑이 실행하기 전에 스왑에 필요한 allowance를 확인한다. 만약 유니스왑 라우터가 해당 토큰에 대한 allowance가 없다면, 해당 토큰의 컨트랙트에서 approve function을 호출한다.
executeSwap: async (transaction, signer, contractAddress, inputAmount) => {
const contract = new ethers.Contract(contractAddress, Erc20ABI, signer);
const approvalAmount = ethers.utils.parseUnits("10", 18);
const allowance = await contract.allowance(signer.address, V3_SWAP_ROUTER_02_ADDRESS);
if (allowance < inputAmount.mul(ethers.utils.parseUnits("10", 18))) {
const approveTx = await contract.connect(signer).approve(V3_SWAP_ROUTER_02_ADDRESS, approvalAmount);
await approveTx.wait();
}
const tx = await signer.sendTransaction(transaction);
await tx.wait();
return tx;
}
마지막으로, signer.sendTransaction으로 트랜잭션을 생성 및 실행하여 UniswapV3SwapRouter에 스왑을 실행시킨다.
// swapExactInputSingle function
const tx = await routerContract.swapExactInputSingle(
inputTokenAddress,
outputTokenAddress,
wallet.address,
recipient,
deadline,
inputAmount,
minOutputAmount,
100 // Fee amount in token terms
);
uniswap router와 sdk는 사용 관점에서 여러 차이점이 있다. 먼저, router는 사용자가 uniswap을 사용하기 간편하게 만들어놓은 진입점 라이브러리라고 한다. router는 주문 경로 및 스왑 계산을 중심으로 복잡한 스왑 기능과 주문 경로 탐색등을 지원한다.
반면에, sdk는 uniswap 프로토콜 기능을 추상화하고 SDK 형태로 제공하기 때문에 Solidity 예제에서 다루는 것 처럼, 해당 컨트랙트의 기능을 목적에 맞게 맞춰서 호출하여 사용해야 한다.
따라서, 해당 프로젝트에서는 uniswap의 smart-order-router를 참고해서 ethers를 사용하여 구현했다.
프로젝트 코드는 아래 Github 링크에서 확인할 수 있습니다.
React Front 프로젝트 > https://github.com/jh-cha/superswap-dex-front
스마트 컨트랙트 배포 프로젝트 > https://github.com/jh-cha/superswap-dex-truffle