Solidity

Ch 3

Sila 2022. 8. 6. 17:04

Ch 2 마지막에 외부 컨트랙트를 가져오기 위해 CA를 사용했는데, 이럴 경우 해당 CA를 가진 컨트랙트에 문제가 생길 경우

 

우리의 컨트랙트도 사용할 수 없게 되어버린다는 문제가 있다.

 

이를 보완하기 위해 CA 변수를 상수로 값을 대입하지 말고

 

CA가 변하더라도 그 값을 그때 그때 읽어올 수 있도록 변수화 하는 과정을 거치고 시작하자.

 

1. CA 변수화

/*  zombiefeeding.sol  */

// ..중략

contract ZombieFeeding is ZombieFactory {
    KittyInterface kittyContract;
    // CA 대입 없이 선언만 한다.
    
    function setKittyContractAddress(address _address) external {
        kittyContract = KittyInterface(_address);
        // CA를 대입하는 함수
    }
    
    // ...중략
}

 

2. Ownable

근데 이렇게 ca설정 함수를 external로 해버리면 다른 사람 아무나 이걸 바꿀 수가 있게 된다.

 

이를 막기 위해 필요한게 openzeppelin에서 제공하는 Ownable 컨트랙트이다.

 

이는 컨트랙트의 소유권에 관련된 기능을 제공한다. 

 

예를 들어 setKittyContract함수를 컨트랙트의 소유자 (배포자)만 사용할 수 있도록 바꿔줄 수 있다.

 

(msg.sender가 owner인지를 한 번 체크하는 require문을 사용한 것과 유사한 기능을 한다.)

 

다음과 같이 ownable 컨트랙트를 zombieFactory 컨트랙트에 import하고 상속한다.

 

zombieFeeding이 아니라 zombieFactory 컨트랙트에 상속하는 이유는

 

zombieFeeding이 zombieFactory를 다시 상속받기 때문이다.

/*  zombiefactory.sol  */

pragma solidity ^0.4.19;

import "./ownable.sol";


contract ZombieFactory is Ownable {

    event NewZombie(uint zombieId, string name, uint dna);

    uint dnaDigits = 16;
    uint dnaModulus = 10 ** dnaDigits;

    struct Zombie {
        string name;
        uint dna;
    }

    Zombie[] public zombies;

    mapping (uint => address) public zombieToOwner;
    mapping (address => uint) ownerZombieCount;

    function _createZombie(string memory _name, uint _dna) internal {
        uint id = zombies.push(Zombie(_name, _dna)) - 1;
        zombieToOwner[id] = msg.sender;
        ownerZombieCount[msg.sender]++;
        NewZombie(id, _name, _dna);
    }

    function _generateRandomDna(string memory _str) private view returns (uint) {
        uint rand = uint(keccak256(_str));
        return rand % dnaModulus;
    }

    function createRandomZombie(string memory _name) public {
        require(ownerZombieCount[msg.sender] == 0);
        uint randDna = _generateRandomDna(_name);
        randDna = randDna - randDna % 100;
        _createZombie(_name, randDna);
    }

}

 

이제 Ownable 컨트랙트의 기능을 사용할 수 있게 된다.

 

onlyOwner라는 함수제어자 (modifier)를 setKittyContractAddress에 추가해준다.

 

/*  zombiefeeding.sol  */

pragma solidity ^0.4.19;

import "./zombiefactory.sol";

contract KittyInterface {
  function getKitty(uint256 _id) external view returns (
    bool isGestating,
    bool isReady,
    uint256 cooldownIndex,
    uint256 nextActionAt,
    uint256 siringWithId,
    uint256 birthTime,
    uint256 matronId,
    uint256 sireId,
    uint256 generation,
    uint256 genes
  );
}

contract ZombieFeeding is ZombieFactory {

  KittyInterface kittyContract;

  // 이 함수를 수정하게:
  function setKittyContractAddress(address _address) external onlyOwner {
    kittyContract = KittyInterface(_address);
  }

  function feedAndMultiply(uint _zombieId, uint _targetDna, string memory _species) public {
    require(msg.sender == zombieToOwner[_zombieId]);
    Zombie storage myZombie = zombies[_zombieId];
    _targetDna = _targetDna % dnaModulus;
    uint newDna = (myZombie.dna + _targetDna) / 2;
    if (keccak256(_species) == keccak256("kitty")) {
      newDna = newDna - newDna % 100 + 99;
    }
    _createZombie("NoName", newDna);
  }

  function feedOnKitty(uint _zombieId, uint _kittyId) public {
    uint kittyDna;
    (,,,,,,,,,kittyDna) = kittyContract.getKitty(_kittyId);
    feedAndMultiply(_zombieId, kittyDna, "kitty");
  }

}

 

3. 구조체 압축

이더리움같은 플랫폼에서 컨트랙트를 구동한다는 전제하에 코드를 작성할 땐

 

항상 코드의 효율성을 따져가면서 작업을 해야한다.

 

여기서 효율성이란 가스비의 소모를 의미하는데, 사용자가 컨트랙트를 사용할때마다

 

불필요하게 가스비가 많이 빠져나가지 않도록 코드를 짜는 것이 코드를 '호율적으로' 짯다는 것을 의미한다.

 

그 방법 중 하나가 저장 공간을 적게 사용하도록 구조체를 압축하는 것이다.

 

 

보통 솔리디티는 uint 타입의 변수 저장시 항상 256비트의 저장 공간을 사용하지만

 

구조체 내에서는 더 작은 크기의 uint를 사용하는 것이 가능하다. (uint8, uint32 등..)

 

또한, 동일한 데이터 타입을 하나로 묶어서 선언하는 것이 좋다.

 

여깃 '묶어서 선언한다' 는 것은 '구조체내 속성의 나열 순서'를 의미한다.

 

예를 들어 uint32 a; uint c; uint32 b; 의 순서로 선언하는 것보다 uint c; uint 32 a; uint32 b; 순서로 선언하는 것이

 

가스를 더 적게 소모한다. uint32필드가 묶여있기 때문.

 

 

zombieFactory로 돌아가서 Zombie구조체에 level, readyTime 2가지를 추가할 것이다.

 

level과 reatytime(timestamp)를 저장하는데는 굳이 256비트나 필요가 없다. 32비트 정도면 충분하므로

 

이 둘은 uint32 속성으로 함께 선언해주자.

 

/*  zombiefactory.sol  */

contract ZombieFactory is Ownable {
    // ...중략
    
    struct Zombie {
        string name;
        uint dna;
        uint32 level;
        uint32 readyTime;
    }
    
    // ...이하 생략
}

 

그래서 이 두 속성이 뭐냐? level은 그냥 level이고,

 

readyTime은 feed 기능을 너무 짧은 간격으로 여러 번 사용할 수 없게 쿨타임을 추가하기 위해 만든 속성이다.

 

우리는 공격 (추후 추가 예정)이나 포식 기능을 사용하면

 

다시 이 기능을 사용할 때까지 1일의 쿨다운 기간을 주고 싶다.

 

 

이를 위해서 컨트랙트 전역변수로 cooldownTime (uint) 변수를 선언하고 그 값을 1 days를 준다.

 

(솔리디티에선 seconds, minutes, ...등의 시간 단위나 ,ether등 가격 단위를 제공한다.

 

이는 코드 실행시 단위 변환에 사용되는 가스비를 줄이기 위함이다.)

 

 

다음으로 _createZombie함수를 업데이트 해주어야 한다. (level과 readyTime 관련 코드를 추가해야 하므로)

 

/*  zombieFactory.sol  */

// ...중략

contract ZombieFactory is Ownable {
    // ...중략
    
    uint cooldownTime =1 days;
    
    // ...중략
    
    function _createZombie(string memory _name, uint _dna) internal {
        uint id = zombies.push(Zombie(_name, _dna, 1, uint32 (now + cooldownTime))) - 1;
        // ...중략
    }
    
    // ... 이하 생략
}

 

새로운 Zombie 구조체를 만들어 zombies배열에 넣을 때,  level, cooldownTime을 추가해야 하는데,

 

초기 level은 1이고 초기 readyTime은 구조체 생성 시간에서 1일 후로 지정해주면 된다.

 

 

4. 쿨타임 기능 구현

다음으로 쿨타임에 대한 기능을 구현해보자.

 

feedAndMultiply 함수를 실행하면 이를 기점으로 쿨타임이 돌기 시작하고,

 

쿨타임이 끝날때까진 다시 이 함수를 실행할 수 없게 하고 싶다.

 

(Zombie 구조체의 readyTime을 변경하고, 이 readyTime이 지나기 전까지는 다시 함수 실행 불가)

 

매개변수로 넣어준 Zombie 구조체의 readyTime을 바꿔주는 _triggerCooldown 함수,

 

쿨타임이 끝나고 다시 특정 함수를 실행할 수 있는지 여부를 t/f로 판별해주는 _isReady 함수를

 

다음과 같이 추가해 줄 수 있다.

 

/*  zombiefeeding.sol  */

// ...중략

contract ZombieFeeding is ZombieFactory {
    // ...중략
    
    function _triggerCooldown (Zombie storage _zombie) internal {
        _zombie.readyTime = uint32 (now + cooldownTime);
        // readyTime을 함수 실행 시점(now)의 1일 후(cooldownTime)으로 변경한다.
        // 이 변한 readyTime이 지나기 전까지는 일부 기능을 사용 불가
    }
    
    function _isReady(Zombie storage _zombie) internal view returns(bool) {
        return (_zombie.readyTime <= now);
        // 함수 실행 시점 (now) 가 readyTime보다 쿨타임이 끝난 것이다.
    }
    
}

 

이제 feedAndMultiply 함수에서 추가한 두 함수를 호출할 것이다.

 

함수의 기능을 실행하기 전에 _isReady 함수를 실행해 쿨타임이 다 돌았는지 확인하고,

 

함수가 잘 실행되었다면 _triggerCooldown 함수를 실행해 readyTime을 1일 후로 바꿔준다.

 

/*  zombiefeeding.sol  */

function feedAndMultiply(uint _zombieId, uint _targetDna, string _species) internal {
    require(msg.sender == zombieToOwner[_zombieId];
    Zombie storage myZombie = zombies[_zombieId];
    
    require(_isReady(myZombie));
    // 쿨타임이 다 돌았으면 true를 리턴해 다음 코드를 실행한다.
    _targetDna = _targetDna % dnaModulus;
    uint newDna = (myZombie.dna + _targetDna) / 2;
    if(keccak256(abi.encodePacked(_species)) == keccak256(abi.encodePacked("kitty"))) {
        newDna = newDna - newDna % 100 + 99;
    }
    
    _createZombie("NoName", newDna);
    _triggerCoolDown(myZombie);
    // 쿨타임을 1일 후로 다시 셋업한다.
}

 

5. 함수 제어자 (modifier)

 조금 전 사용한 onlyOwner 처럼 함수의 기능을 추가해주는 modifier를 조금 더 배워보자.

 

require같은 조건문 등을 분리해서 모듈화하고 싶을 때 사용해주면 되는 것 같은데

 

여기서는 예시로 Zombie 구조체의 level이 일정 값 이상으로 높아지면 함수를 실행할 수 있도록 제어자를 추가해보자.

 

/*  zombiehelper.sol  */

pragma solidity >=0.5.0 <0.6.0;
import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding {

  modifier aboveLevel (uint _level, uint _zombieId) {
    require(zombies[_zombieId].level >= _level);
    _;
  }
}

 

modifier 실행 이후, 이게 붙은 function이 실행될 수 있도록 마지막줄에 _; 를 적어주어야 한다.

 

이 modifier를 사용해 레벨 제한에 걸리지 않는지 확인한 후,

 

2 레벨 이상이면 Zombie 구조체의 name를, 20 레벨이상이면 dna를 바꿀 수 있도록 해보자.

 

/*  zombiehelper.sol  */

pragma solidity >=0.5.0 <0.6.0;
import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding {

  modifier aboveLevel (uint _level, uint _zombieId) {
    require(zombies[_zombieId].level >= _level);
    _;
  }
  
  function changeName(uint _zombieId, string calldata _newName) external aboveLevel(2, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].name = _newName;
  }

  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].dna = _newDna;
  }
}

 

6. storage vs view

좀 전에 솔리디티 코드의 효율성에 대해서 얘기를 했었다.

 

솔리디티에서 효율성이란 코드 실행시 가스비를 적게 소모하도록 코드를 작성하는 것을 의미한다.

 

storage 즉, 저장장치를 사용하는 코드들은 가스비를 많이 소모하기 때문에 이를 최대한 우회를 하는 것이 좋다.

 

예를 들어 사용자의 지갑 주소를 넣으면 그 지갑 소유의 모든 Zombie를 보여주는 view함수를 만든다고 생각해보자.

 

mapping(address => uint[]) public ownerToZombies;

function getZombiesByOwner(address _owner) external view returns(uint []) {
    return ownerToZombies[_owner];
}

 

address와 ZombieId의 배열을 연결하는 매핑을 만든 후, 이를 함수에서 호출하면 된다.

 

간단해보이지만 이렇게 하면 Zombie 구조체의 소유권을 다른 사람에게 이전할 때 문제가 생긴다.

 

zombieId가 2,3,5,8,13인 Zombie 구조체를 가진 사람이

 

zombieId가 3인 구조체를 다른 사람에게 준다고 하면 배열은 다음과 같이 수정된다.

 

[2,3,5,8,13] > [2,5,8,13]

 

이렇게 바꾸려면 원래 id=3인 index부터 모든 배열의 index를 다 수정해야하므로 가스비 소모가 너무 많다.

 

동일한 기능을 다음과 같이 반복문을 이용해 작성할 수도 있다.

 

/*  zombiehelper.sol  */

function getZombieByOwner(address _owner) external view returns(uint[]) {
    uint[] memory result = new uint[](ownerZombieCount[_owner]);
    // memory에만 저장할 (저장 장치를 사용하지 않는) 변수 result 선언
    // 이 변수는 _owner의 소유 Zomibe 수만큼의 길이를 가진 배열이다.
    
    uint counter = 0;
    // result 배열의 index 역할을 새줄 것이다.
    
    for(uint i = 0; i < zombies.length; i++) {
    // 컨트랙트의 zombies 배열을 아예 통으로 가져온다.
        if(zombieToOwner[i] == _owner) {
            result[counter] = i;
            counter++;
        }
        // 반목문으로 모든 zombies 내의 Zombie를 확인해
        // 일치하는 소유자를 가진 Zombie의 id만을 result 배열에 추가한다.
    }
    return result;
}

 

얼핏보면 매번 zombies 배열을 가져와서 모든 Zombie 구조체를 전부 읽어

 

매개변수로 준 owner와 일치하는 소유자인지 확인해야 하는 이 방식이 비효율적으로 보일지 몰라도,

 

이 방식대로 실행하면 stoarge를 전혀 사용하지 않는다.

 

가스비를 사용하지 않는다는 점에서 이 방식이 더 '효율적'이라는 의미이다.

'Solidity' 카테고리의 다른 글

Cryptozombie - Ch1. Making Zombie Factory  (2) 2023.12.03
Ch2  (0) 2022.07.24
Ch1  (0) 2022.07.19