보다 더 생생한 기록

[Solidity][공부 마라톤] 업그레이더블 컨트랙트 프로젝트 + 하드햇 테스트 본문

블록체인

[Solidity][공부 마라톤] 업그레이더블 컨트랙트 프로젝트 + 하드햇 테스트

viviviviviid 2024. 6. 16. 21:49
직전 포스트에서 업그레이더블 컨트랙트의 개념과 원리를 이해했으니,
이제 실제로 구현해보고 하드햇(Hardhat)을 이용해 테스트를 진행해 보자.

 

이번 프로젝트는 업그레이더블 스마트 컨트랙트를 통해 로직을 변경할 수 있는 시스템을 설계하는 것이다.

프로젝트 설명

이번 프로젝트에서는 프록시 컨트랙트(Proxy Contract)와 로직 컨트랙트(Logic Contract)를 활용하여, 필요 시 로직을 쉽게 업그레이드할 수 있는 스마트 컨트랙트를 구현한다. 주된 목적은 기존 상태를 유지하면서 새로운 기능을 추가하거나 버그를 수정하는 것이다.

구조 설명

아래 다이어그램은 프록시 패턴의 구조를 시각적으로 나타낸 것이다.

 

  • User: 프록시 컨트랙트를 호출하는 사용자.
  • Owner: 프록시 컨트랙트를 업그레이드할 수 있는 권한을 가진 관리자.
  • Proxy: 로직 컨트랙트를 대리하여 함수를 실행.
  • Logic: 실제 로직과 상태 변수를 포함한 컨트랙트.

https://github.com/viviviviviid/otherworld 

 

GitHub - viviviviviid/otherworld

Contribute to viviviviviid/otherworld development by creating an account on GitHub.

github.com

 

Proxy.sol

프록시 컨트랙트로, 로직 컨트랙트를 대리 호출하며 업그레이드 기능을 제공한다.

contract Proxy is Initializable, UUPSUpgradeable, ERC721URIStorageUpgradeable, OwnableUpgradeable {
    
    uint256 public paymentPrice; 
    uint256 public nextTokenId;
    ERC20Upgradeable public paymentToken;
    address public paymentTokenAddress;
    address public paymentLogic;

	```

}

 

초기 변수 선언 내용이다. 뒤에서 나올 Logic.solpaymentPrice와 변수 선언 순서가 같음을 볼 수 있다.

직전 블로그 포스트에서 설명한 스토리지 레이아웃을 이용하여 DelegateCall State 변경을 위해 세팅한 것이다.

 

Logic.sol의 함수를 DelegateCall하는 부분이다.

function updatePaymentPrice() public onlyOwner {
    (bool success, ) = paymentLogic.delegatecall(
        abi.encodeWithSignature("calculatePrice()")
    );
    require(success, "Logic call failed");
    emit PaymentPriceUpdated(paymentPrice);
}

 

이 함수를 유저가 실행시키면 Logic.sol의 함수가 실행되고, DelegateCall의 특성답게 이 프록시 컨트랙트의 paymentPrice가 변경된다. 그리고 함수의 호출자는 Proxy 컨트랙트가 아닌 현재 지갑주소가 된다.

 

그리고 Logic 컨트랙트를 재배포하고 업그레이드 할 경우 이 함수를 사용하면 된다.

function updateLogicContract(address _paymentLogic) public onlyOwner {
    require(paymentLogic != _paymentLogic, "It's the same contract address as before.");
    paymentLogic = _paymentLogic;
    emit UpdateLogicContract(paymentLogic);
}

그 뒤 delegateCall을 하면 재배포된 프록시 컨트랙트의 함수가 호출된다.

 

 

Logic.sol

단순한 상태 변수 count와 이를 증가시키는 increment 함수를 포함한 로직 컨트랙트이다.

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

contract PaymentLogic {
    uint256 public paymentPrice; 
    uint256 private constant a = 1664525;
    uint256 private constant c = 1013904223;
    uint256 seed = block.timestamp;

    function calculatePrice() public {
        seed = (a * seed + c) % 100;
        paymentPrice = uint256(seed);
    }

    function getPaymentPrice() public view returns (uint256) {
        return paymentPrice;
    }
}

 

Proxy.sol에서 updatePaymentPrice 함수를 호출하면, delegateCall을 통해 이 코드내의 calculatePrice 함수가 호출된다.

여기서 중요한 점은 calculatePrice가 실행되었을때, 이 코드내의 paymentPrice 변수의 state가 변경되는 것이 아닌, Proxy 컨트랙트 코드내의 paymentPrice가 변경된다는 점이다.

 

 


하드햇을 이용한 컨트랙트 테스트

코드는 다음과 같다. 직전의 솔리디티 코드들에서 필요한 부분들에 대해 테스트케이스를 작성하고 컨트랙트의 유효성 테스트를 진행했다. 
주요 테스트로는 '초기 설정이 유효한지', 'delegateCall이 정상적으로 이루어지는지', 'Upgradable이 가능한지'가 있다.

const { expect } = require("chai");
const {
  loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");

const mockingURI = "https://fuchsia-comprehensive-earwig-142.mypinata.cloud/ipfs/QmVhTz6GjrEQKMt32qRi8j7N18F932VJTGPGrMs8SBVkWF";
const mockingNum = 34985238;
const mockingAddr = "0xAAC129A3e6e9f44147951dDD5655d66c312A4713";

const deployTokenFixture = async () => {
  const [owner, addr1, addr2] = await ethers.getSigners();
  const tokenContract = await ethers.deployContract("PaymentToken");
  await tokenContract.waitForDeployment();
  return { tokenContract, owner, addr1, addr2 };
}

const deployLogicFixture = async () => {
  const [owner, addr1, addr2] = await ethers.getSigners();
  const logicContract = await ethers.deployContract("PaymentLogic");
  await logicContract.waitForDeployment();
  return { logicContract, owner, addr1, addr2 };
}

const deployProxyFixture = async () => {
  const [owner] = await ethers.getSigners();
  const {tokenContract, logicContract} = await deploySubContracts();
  const proxyContract = await ethers.deployContract("Proxy");
  await proxyContract.waitForDeployment();

  await proxyContract.initialize(tokenContract.target, mockingNum, logicContract.target)
  await proxyContract.updatePaymentPrice();

  return { proxyContract, tokenContract, logicContract, owner, "initTokenPrice":mockingNum};
}

const deploySubContracts = async () => {
  const tokenContract = await ethers.deployContract("PaymentToken");
  await tokenContract.waitForDeployment();
  const logicContract = await ethers.deployContract("PaymentLogic");
  await logicContract.waitForDeployment();

  return {tokenContract, logicContract};
}

describe("Token contract", () => {
  it("배포자에게 토큰의 초기 공급량을 할당해야 합니다.", async () => {
    const { tokenContract, owner } = await loadFixture(deployTokenFixture);
    const ownerBalance = await tokenContract.balanceOf(owner.address);
    expect(await tokenContract.totalSupply()).to.equal(ownerBalance);
  });

  it("계정간에 토큰을 전송할 수 있어야 합니다.", async () => {
    const { tokenContract, owner, addr1, addr2 } = await loadFixture(
      deployTokenFixture
    );
    
    await expect(
      tokenContract.transfer(addr1.address, 50)
    ).to.changeTokenBalances(tokenContract, [owner, addr1], [-50, 50]);

    await expect(
      tokenContract.connect(addr1).transfer(addr2.address, 50)
    ).to.changeTokenBalances(tokenContract, [addr1, addr2], [-50, 50]);
  });
});

describe("Logic contract", () => {
  it("시드를 기반으로 선형 합동 생성기 공식을 사용하여 0부터 99까지의 난수를 생성하고, 매번 값이 달라져야 합니다.", async () => {
    const { logicContract, owner } = await loadFixture(deployLogicFixture);
    let values = [];
    
    for (let i = 0; i < 10; i++) {
      await logicContract.calculatePrice();
      let temp = await logicContract.getPaymentPrice();
      values.push(temp); 
    }

    const allSame = values.every((val, _, arr) => val === arr[0]);
    expect(allSame).to.be.false;
  });
});

describe("Proxy Contract", () =>  {

  describe("Contract Initialize", () => {
    it("초기화 후 파라미터로 사용된 토큰 컨트랙트 주소값이 정상적으로 확인되어야 합니다.", async () => {
      const { proxyContract, tokenContract } = await loadFixture(deployProxyFixture);
      expect(await proxyContract.getTokenContract()).to.equal(tokenContract.target);
      
    });

    it("초기화 후 파라미터로 사용된 로직 컨트랙트 주소값이 정상적으로 확인되어야 합니다.", async () => {
      const { proxyContract, logicContract } = await loadFixture(deployProxyFixture);
      expect(await proxyContract.getLogicContract()).to.equal(logicContract.target);
    });
  });

  describe("Excute DelegateCall", () => {
    it("DelegatCall이 정상적으로 수행되어야 합니다.", async () => {
      const { proxyContract, mockingNum} = await loadFixture(deployProxyFixture);
      const initPrice = await proxyContract.getPaymentPrice();
      await proxyContract.updatePaymentPrice();
      expect(await proxyContract.getPaymentPrice()).to.not.equal(initPrice);
    });

    it("일반적인 Call과는 반대로, DelegateCall로 인한 state 변화는 Logic 쪽에선 일어나지 않아야 합니다.", async () => {
      const { proxyContract, logicContract} = await loadFixture(deployProxyFixture);
      const beforeValueOfLogic = await logicContract.getPaymentPrice();
      await proxyContract.updatePaymentPrice();
      expect(await logicContract.getPaymentPrice()).to.equal(beforeValueOfLogic);  
    });
  });

  describe("Upgrade Logic Contract", () => {
    it("로직 컨트랙트를 정상적으로 업그레이드 할 수 있어야합니다.", async () => {
      const {tokenContract, logicContract} = await deploySubContracts();
      const proxyContract = await ethers.deployContract("Proxy");
      await proxyContract.waitForDeployment();
      await proxyContract.initialize(tokenContract.target, mockingNum, mockingAddr);
      const initPrice = await proxyContract.getPaymentPrice();
      await proxyContract.updateLogicContract(logicContract);
      await proxyContract.updatePaymentPrice();
      expect(await proxyContract.getPaymentPrice()).to.not.equal(initPrice);
    });
  });

  describe("Mint NFT", () => {
    it("NFT 발행 후 지갑 잔고가 올바르게 업데이트되어야 합니다.", async () => {
      const { proxyContract, tokenContract , owner } = await loadFixture(deployProxyFixture);
      const initialBalance = await tokenContract.balanceOf(owner.address);
      await approveAndMint(proxyContract, tokenContract, owner);
      const currentPaymentPrice = await proxyContract.getPaymentPrice();
      expect(await tokenContract.balanceOf(owner.address)).to.equal(initialBalance-currentPaymentPrice);
    });

    it("토큰 메타데이터에 정확한 URI가 포함되어야 합니다.", async () => {
      const { proxyContract, tokenContract, owner } = await loadFixture(deployProxyFixture);
      const tokenID = await approveAndMint(proxyContract, tokenContract, owner);
      expect(await proxyContract.getTokenURI(tokenID)).to.equal(mockingURI);
    });

    it("NFT 발행 후 다음 토큰 ID가 1 증가해야 합니다.", async () => {
      const { proxyContract, tokenContract, owner } = await loadFixture(deployProxyFixture);
      const tokenID = await approveAndMint(proxyContract, tokenContract, owner);
      expect(await proxyContract.getNextTokenID() - tokenID).to.equal(1);
    });

    it("msg.sender와 토큰 소유자가 일치해야 합니다.", async () => {
      const { proxyContract, tokenContract, owner } = await loadFixture(deployProxyFixture);
      const tokenID = await approveAndMint(proxyContract, tokenContract, owner);
      expect(await proxyContract.ownerOf(tokenID)).to.equal(owner.address);
    });
  });
});

const approveAndMint = async (proxyContract, tokenContract, owner) => {
  const tokenID = await proxyContract.getNextTokenID();
  await tokenContract.approve(proxyContract.target, await proxyContract.getPaymentPrice());
  await proxyContract.mintNFT(owner.address, mockingURI);
  return tokenID;
}

 

실행하면 다음과 같은 내용이 출력된다.