[korean]업그레이드 가능한 컨트랙트 작성하기(Writing upgradeable contracts)

업그레이드 가능한 컨트랙트 작성하기

OpenZeppelin SDK에서 업그레이드 가능한 컨트랙트를 작업 할 때, Solidity 코드 작성에서 명심해야 할 몇 가지주의 사항이 있습니다. 이러한 제한 사항은 Ethereum VM의 작동 방식에 뿌리를두고 있으며, OpenZeppelin SDK뿐만 아니라 업그레이드 가능한 컨트랙트로 작동하는 모든 프로젝트에 적용됩니다.

이니셜라이저

_constructors_를 제외하고는, OpenZeppelin SDK에서 Solidity 컨트랙트를 수정하지 않고 사용할 수 있습니다. 프록시 기반 업그레이드 시스템의 요구 사항으로 인해 업그레이드 가능한 컨트랙트에는 생성자를 사용할 수 없습니다. OpenZeppelin SDK 업그레이드 패턴 페이지에서 이 제한의 원인에 대해 자세히 읽을 수 있습니다.

즉, OpenZeppelin SDK 내에서 컨트랙트를 사용할 때 생성자를 일반적으로 initialize라는 일반 함수로 변경해야합니다. 여기서 모든 설정 로직을 실행합니다.

// NOTE: 이 코드 스니펫을 사용하지 마십시오. 불완전하며 치명적인 취약점이 있습니다!

contract MyContract {
  uint256 public x;

  function initialize(uint256 _x) public {
    x = _x;
  }
}

그러나 Solidity는 컨트랙트에서 생성자가 한 번만 호출되도록하지만, 일반 함수는 여러 번 호출 할 수 있습니다. 계약이 여러 번 _initialized_되지 않게하려면 initialize 함수가 한 번만 호출되는지 확인해야합니다.

contract MyContract {
  uint256 public x;
  bool private initialized;

  function initialize(uint256 _x) public {
    require(!initialized);
    initialized = true;
    x = _x;
  }
}

이 패턴은 업그레이드 가능한 계약을 작성할 때 매우 일반적이므로 OpenZeppelin SDK는 이를 처리하는 initializer수정자를 가진 기본 컨트랙트 Initializable을 제공합니다.

import "@openzeppelin/upgrades/contracts/Initializable.sol";

contract MyContract is Initializable {
  uint256 public x;

  function initialize(uint256 _x) initializer public {
    x = _x;
  }
}

constructor와 일반 함수의 또 다른 차이점은 Solidity가 계약의 모든 조상 컨트랙트의 생성자를 자동으로 호출한다는 점입니다. 이니셜라이저를 작성할 때 모든 상위 계약의 이니셜라이저를 수동으로 호출하도록 특별한주의를 기울여야합니다.

import "@openzeppelin/upgrades/contracts/Initializable.sol";

contract BaseContract is Initializable {
  uint256 public y;

  function initialize() initializer public {
    y = 42;
  }
}

contract MyContract is BaseContract {
  uint256 public x;

  function initialize(uint256 _x) initializer public {
    BaseContract.initialize(); // Do not forget this call!
    x = _x;
  }
}

‘업그레이드 가능’ 패키지 사용

이 제한 사항은 직접 만든 컨트랙트 뿐만 아니라 라이브러리에서 가져온 컨트랙트에도 영향을 미칩니다. 예를 들어, OpenZeppelin의 ERC20Detailed 토큰 구현을 사용하는 경우, 계약서에서 생성자의 토큰 이름, 기호 및 소수를 초기화합니다.

Contract ERC20Detailed is IERC20 {
  string private _name;
  string private _symbol;
  uint8 private _decimals;

  constructor(string name, string symbol, uint8 decimals) public {
    _name = name;
    _symbol = symbol;
    _decimals = decimals;
  }
}

이는 OpenZeppelin SDK 프로젝트에서 이러한 컨트랙트를 사용하지 않아야 함을 의미합니다. 대신, 생성자 대신 이니셜라이저를 사용하도록 수정 된 openzeppelin-contracts의 공식 포크 인 @ openzeppelin / contracts-ethereum-package를 사용해야합니다. 예를 들어, @openzeppelin/contracts-ethereum-package에서 제공하는 ERC20 구현은 ERC20Mintable 입니다.

contract ERC20Mintable is Initializable, ERC20, MinterRole {
  function initialize(address sender) public initializer {
    MinterRole.initialize(sender);
  }
  [...]
}

OpenZeppelin Contracts이든 다른 Ethereum Package이든 관계없이 항상 패키지가 업그레이드 가능한 계약을 처리하도록 설정되어 있는지 확인하십시오.

필드 선언에서 초기 값을 피하세요

솔리디티는 컨트랙트에서 필드를 선언 할 때 필드의 초기 값을 정의 할 수 있게합니다.

contract MyContract {
  uint256 public hasInitialValue = 42;
}

이는 생성자에서 이러한 값을 설정하는 것과 같으므로, 업그레이드 가능한 컨트랙트에서는 작동하지 않습니다. 모든 초기 값이 아래와 같이 이니셜라이저 함수안에 설정되어 있는지 확인 해 주세요. 그렇지 않으면 업그레이드 가능한 인스턴스에는 이러한 필드가 설정되지 않습니다.

contract MyContract is Initializable {
  uint256 public hasInitialValue;
  function initialize() initializer public {
    hasInitialValue = 42;
  }
}

컴파일러는 이러한 변수에 대한 스토리지 슬롯을 예약하지 않으며, 모든 발생이 각각의 상수 식으로 대체되기 때문에, 상수(constant)는 여전히 여기서 설정하는 것이 좋습니다. 따라서 OpenZeppelin SDK에서는 다음이 여전히 작동합니다.

contract MyContract {
  uint256 constant public hasInitialValue = 42;
}

컨트랙트 코드에서 새 인스턴스 작성

컨트랙트 코드에서 컨트랙트의 새 인스턴스를 생성 할 때, 이러한 생성은 OpenZeppelin SDK가 아닌 Solidity에 의해 직접 처리되므로 계약을 업그레이드 할 수 없습니다 .

예를 들어, 다음 예 에서 MyContract가 업그레이드 가능하더라도 (openzeppelin create MyContract을 통해 작성된 경우) 작성된 토큰 컨트랙트는 그렇지 않습니다.

import "@openzeppelin/upgrades/contracts/Initializable.sol";
import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/RC20Detailed.sol";

contract MyContract is Initializable {
  ERC20 public token;

  function initialize() initializer public {
    token = new ERC20Detailed("Test", "TST", 18); // This contract will not be upgradeable
  }
}

이 문제를 해결하는 가장 쉬운 방법은 직접 컨트랙트를 포함하지 않는 것입니다. 초기화 함수에서 컨트랙트를 작성하는 대신 해당 컨트랙트의 인스턴스를 매개 변수로 승인하고 OpenZeppelin SDK에서 컨트랙트를 작성한 후 삽입하세요.

import "@openzeppelin/upgrades/contracts/Initializable.sol";
import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/ERC20.sol";

contract MyContract is Initializable {
  ERC20 public token;

  function initialize(ERC20 _token) initializer public {
    token = _token; // This contract will be upgradeable if it was created via the OpenZeppelin SDK
  }
}
$ TOKEN=$(openzeppelin create TokenContract)
$ openzeppelin create MyContract --init --args $TOKEN

더 나은 대안으로는, 업그레이드 가능한 계약을 즉시 작성해야하는 경우, 계약에 OpenZeppelin SDK App의 인스턴스를 가지는 것입니다. The App 은 OpenZeppelin SDK 프로젝트의 시작점 역할을하는 계약으로, 논리 구현에 대한 참조가 있으며 새로운 계약 인스턴스를 만들 수 있습니다.

import "@openzeppelin/upgrades/contracts/Initializable.sol";
import "@openzeppelin/upgrades/contracts/application/App.sol";

contract MyContract is Initializable {
  App private app;

  function initialize(App _app) initializer public {
    app = _app;
  }

  function createNewToken() public returns(address) {
    return app.create("@openzeppelin/contracts-ethereum-package", "StandaloneERC20");
  }
}

잠재적으로 안전하지 않은 작업

업그레이드 가능한 스마트 컨트랙트로 작업 할 때는, 항상 컨트랙트 인스턴스와 상호 작용하며, 기본 논리 컨트랙트와는 상호 작용하지 않습니다. 그러나 악의적 인 행위자가 트랜잭션을 논리 컨트랙트에 직접 보내는 것을 막는 것은 없습니다. 논리 컨트랙트의 저장은 프로젝트에서 사용되지 않으므로, 논리 컨트랙트의 상태를 변경해도 컨트랙트 인스턴스에 영향을 미치지 않으므로, 이는 위협이되지 않습니다.

그러나 예외는 있습니다. 논리 컨트랙트에 대한 직접 호출이 자체 파괴작업을 트리거하면, 논리 컨트랙트가 소멸되고 모든 컨트랙트 인스턴스가 코드없이 주소로 모든 호출을 위임하게됩니다. 이렇게하면 프로젝트의 모든 컨트랙트 인스턴스가 효과적으로 중단됩니다.

논리 컨트랙트에 delegatecall작업이 포함되어 있으면 비슷한 효과를 얻을 수 있습니다. selfdestruct를 포함하는 악의적인 컨트랙트로 delegatecall 컨트랙트를 실행 할 수 있으면, 호출된 컨트랙트는 파기됩니다.

따라서 컨트랙트에서 selfdestruct 또는delegatecall을 사용하지 않는 것이 좋습니다. 이를 포함해야하는 경우 초기화되지 않은 논리 컨트랙트에서 공격자가 호출 할 수 없도록하십시오.

컨트랙트 수정

새로운 기능이나 버그 수정으로 인해 컨트랙트의 새 버전을 작성할 때, 준수해야 할 추가 제한 사항이 있습니다. 컨트랙트 상태 변수의 선언 된 순서 나 유형을 변경할 수 없습니다. 패턴 섹션에서이 제한의 원인에 대한 자세한 내용을 읽을 수 있습니다.

즉, 다음과 같은 초기 컨트랙트가있는 경우

contract MyContract {
  uint256 private x;
  string private y;
}

이후엔, 변수 유형을 변경할 수 없으며,

contract MyContract {
  string private x;
  string private y;
}

순서 역시 변경할 수 없고,

contract MyContract {
  string private y;
  uint256 private x;
}

기존에 존재하는 변수 이전에, 새 변수를 추가할 수 없고,

contract MyContract {
  bytes private a;
  uint256 private x;
  string private y;
}

기존 존재하는 변수를 제거할 수 없습니다.

contract MyContract {
  string private y;
}

새로운 변수를 추가하고자 하는경우엔, 항상 기존 변수 마지막에 추가되어야 합니다.

contract MyContract {
  uint256 private x;
  string private y;
  bytes private z;
}

변수 이름을 바꾸면 업그레이드 후와 동일한 값을 유지한다는 점에 유의하십시오. 새 변수가 의미 적으로 이전 변수와 동일한 경우 이는 바람직한 동작 일 수 있습니다.

contract MyContract {
  uint256 private x;
  string private z; // starts with the value from `y`
}

컨트랙트의 마지막에있는 변수를 제거해도, 스토리지에서는 지워지지 않습니다. 새 변수를 추가하는 후속 업데이트는 해당 변수가 삭제 된 값에서 남은 값을 읽도록합니다.

contract MyContract {
  uint256 private x;
}

// Then upgraded to...

contract MyContract {
  uint256 private x;
  string private z; // starts with the value from `y`
}

부모 컨트랙트를 을 변경하여 컨트랙트의 저장 변수를 실수로 변경하는 경우도 있습니다. 예를 들어, 다음과 같은 컨트랙트가있는 경우

contract A {
  uint256 a;
}

contract B {
  uint256 b;
}

contract MyContract is A, B { }

그런 다음 기본 컨트랙트가 선언 된 순서를 바꾸거나 새 기본 컨트랙트를 도입하여 MyContract를 수정하면, 변수가 실제로 저장되는 방식이 변경됩니다.

contract MyContract is B, A { }

하위에 자체 변수가있는 경우 기본 컨트랙트에 새 변수를 추가 할 수 없습니다. 다음과 같은 시나리오가 있습니다.

contract Base {
  uint256 base1;
}

contract Child is Base {
  uint256 child;
}

변수를 추가하기 위해Base가 수정 된 경우

contract Base {
  uint256 base1;
  uint256 base2;
}

그런 다음 변수base2에는 이전 버전에서child가 있던 슬롯이 할당됩니다. 이에 대한 임시 해결책은, 해당 슬롯을 "예약"하는 수단으로 향후 확장하려는 기본 컨트랙트에서 사용되지 않는 변수를 선언하는 것입니다. 이 트릭에는 가스 사용량이 증가하지 않습니다.

Caution 이러한 스토리지 레이아웃 제한을 위반하면 업그레이드 된 계약 버전의 스토리지 값이 혼합되어 애플리케이션에 심각한 오류가 발생할 수 있습니다.

3 Likes