ERC-1967 프록시 저장 슬롯 표준 (Proxy Storage Slots)
Final · 기타 · 2019-04-24 작성
업그레이드용 프록시 컨트랙트가 실제 코드가 있는 로직 컨트랙트 주소, 비컨 주소, 관리자 주소를 저장할 자리를 정한 표준입니다. 블록 탐색기 같은 도구가 어떤 프록시든 같은 방법으로 읽을 수 있습니다.
왜 필요한가
컨트랙트를 업그레이드하거나 배포 비용을 줄이려는 앱은 프록시 컨트랙트(proxy contract)를 앞에 두고 실제 코드는 로직 컨트랙트(logic contract)에 둡니다. 프록시는 delegatecall이라는 명령으로 로직 컨트랙트의 코드를 빌려 쓰면서 저장소와 잔고는 자기 것을 유지합니다. OpenZeppelin, Gnosis, AragonOS 등 많은 프로젝트가 이 방식을 씁니다. 그런데 로직 컨트랙트 주소를 어디에 저장할지가 프로젝트마다 달랐다는 문제가 있었습니다. 공통 규칙이 없으니 블록 탐색기는 어떤 주소가 프록시인지, 그 뒤의 진짜 코드가 무엇인지 알 수 없어 프록시 자체의 함수 목록(ABI)만 보여 주었습니다. 사용자는 실제 기능을 쓰기 어려웠습니다. 로직 컨트랙트가 자신이 프록시 뒤에서 동작한다는 점을 활용해 코드 업데이트를 하려 해도 프록시 구현마다 따로 맞춰야 했습니다. 이 표준은 그 자리를 세 개의 슬롯으로 고정해 누구나 같은 방법으로 읽게 합니다.
구현해야 하는 것
event Upgraded(address indexed implementation);: 로직 컨트랙트 주소 슬롯이 바뀔 때 내보내는 이벤트입니다. 0에서 첫 값으로 넣는 초기화도 포함합니다.event BeaconUpgraded(address indexed beacon);: 프록시가 가리키는 비컨 컨트랙트 주소 슬롯이 바뀔 때 내보내는 이벤트입니다. 비컨 안의 구현이 바뀌는 것과는 다릅니다.function implementation() returns (address): 비컨 컨트랙트가 반드시 구현해야 하는 함수입니다. 그 비컨에 연결된 프록시들이 사용할 로직 컨트랙트 주소를 돌려줍니다.event AdminChanged(address previousAdmin, address newAdmin);: 관리자 주소 슬롯이 바뀔 때 이전 관리자와 새 관리자를 함께 내보내는 이벤트입니다.
심층 설명
세 가지 표준 슬롯과 계산 방식
이 표준은 프록시 전용 정보를 저장할 슬롯 세 개를 정합니다. 로직 컨트랙트 주소는 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, 비컨 컨트랙트 주소는 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50, 관리자 주소는 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103입니다. 각 값은 bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)처럼 문자열 eip1967.proxy.implementation, eip1967.proxy.beacon, eip1967.proxy.admin을 keccak256으로 해시한 뒤 1을 뺀 값입니다. 추가 정보가 필요하면 이후 ERC에서 슬롯을 더 정할 수 있다고 밝힙니다.
로직 컨트랙트 슬롯과 Upgraded 이벤트
로직 컨트랙트 슬롯에는 이 프록시가 delegatecall로 코드를 위임하는 로직 컨트랙트의 주소를 둡니다. 비컨을 대신 쓰는 프록시라면 이 슬롯은 비어 있어야 합니다(SHOULD). 이 슬롯의 값이 바뀌면 event Upgraded(address indexed implementation)을 내보내야 합니다(SHOULD). 참조 구현에서는 _setImplementation이 새 주소가 컨트랙트인지 Address.isContract로 확인한 뒤 StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value에 저장합니다. _upgradeTo는 저장 뒤 Upgraded 이벤트를 발행합니다. _upgradeToAndCall은 data가 비어 있지 않거나 forceCall이 참이면 새 구현에 delegatecall을 한 번 더 보내 초기화 호출을 처리합니다.
비컨 슬롯과 implementation() 함수
비컨(beacon)은 여러 프록시가 쓰는 로직 주소를 한곳에 모아 두는 컨트랙트입니다. 비컨의 저장 슬롯 하나만 바꾸면 그 비컨에 연결된 모든 프록시를 한 번에 업그레이드할 수 있습니다. 비컨 컨트랙트는 function implementation() returns (address)를 반드시 구현해야 합니다(MUST). 그 결과는 호출자인 msg.sender에 따라 달라지지 않아야 합니다(SHOULD NOT). 비컨 기반 프록시는 로직 슬롯을 쓰지 않고 비컨 슬롯에 자기가 붙은 비컨의 주소를 저장합니다. 클라이언트는 비컨 슬롯을 읽고 그 주소의 implementation()을 호출해 로직 컨트랙트를 알아냅니다. 비컨 슬롯은 로직 슬롯이 비어 있을 때만 고려해야 하고 로직 주소를 직접 쓰는 프록시라면 비컨 슬롯은 비어 있어야 합니다. 값이 바뀌면 event BeaconUpgraded(address indexed beacon)을 내보내야 합니다.
관리자 슬롯과 AdminChanged 이벤트
관리자 슬롯에는 이 프록시의 로직 컨트랙트 주소를 바꿀 권한이 있는 주소를 둡니다. 이 슬롯은 선택 사항이라 쓰지 않는 프록시도 있습니다. 값이 바뀌면 event AdminChanged(address previousAdmin, address newAdmin)을 내보내야 합니다(SHOULD). 다른 두 이벤트와 달리 이전 값과 새 값을 함께 담고 인자에 indexed가 붙지 않습니다. 참조 구현의 _changeAdmin은 먼저 _getAdmin()으로 읽은 이전 관리자와 새 관리자를 이벤트로 내보낸 뒤 _setAdmin을 호출합니다. _setAdmin은 새 관리자가 0 주소가 아닌지 require로 확인하고 _ADMIN_SLOT에 저장합니다.
이벤트 발행 권고와 초기화
원문은 프록시 감시가 많은 앱의 보안에 필수라고 봅니다. 그런데 저장 슬롯의 변경은 외부에서 추적하기 어렵습니다. 그래서 이 세 슬롯 중 하나라도 바꾸는 함수는 그에 맞는 이벤트를 함께 내보내야 한다고 권고합니다(SHOULD). 여기에는 슬롯이 0x0에서 첫 번째 0이 아닌 값으로 바뀌는 초기화도 포함됩니다. 즉 프록시를 배포하면서 첫 로직 주소를 넣는 순간에도 Upgraded 이벤트가 나와야 합니다. 참조 구현의 ERC1967Proxy 생성자는 _upgradeToAndCall(_logic, _data, false)를 호출하므로 배포 시점에 Upgraded가 발행됩니다.
왜 공개 함수가 아니라 저장 슬롯인가
프록시에 로직 주소를 돌려주는 공개 함수를 두는 대신 저장 슬롯을 표준으로 정한 이유는 함수 선택자 충돌입니다. ABI는 함수를 구별할 때 4바이트 선택자만 쓰기 때문에 이름이 다른 함수끼리도 선택자가 같을 수 있습니다. 프록시가 가진 함수와 로직 컨트랙트의 함수가 충돌하면 호출이 로직 컨트랙트에 전달되지 않고 프록시가 가로채 자기 값을 돌려줍니다. 원문은 Nomic Labs의 글 Malicious backdoors in Ethereum proxies를 인용해 컴파일러는 한 컨트랙트 안의 충돌은 잡아내지만 컨트랙트 사이의 충돌은 잡지 못하므로 겉보기에 정상인 컨트랙트에 백도어를 숨기는 데 악용될 수 있다고 설명합니다. 그래서 프록시는 최종 사용자에게 로직 컨트랙트와 충돌할 수 있는 함수를 노출하지 않아야 하고 로직 주소는 함수가 아닌 저장 슬롯으로 표준화했습니다.
컴파일러 저장소 배치와 충돌하지 않는 이유
선택한 슬롯은 컴파일러가 상태 변수를 저장할 때 절대 고르지 않는 자리여야 한다는 조건이 핵심입니다. 그렇지 않으면 로직 컨트랙트가 자기 변수에 값을 쓰다가 프록시 정보를 덮어쓸 수 있습니다. Solidity는 상속 체인을 선형화한 뒤 변수 선언 순서대로 첫 변수를 첫 슬롯에, 다음을 그다음 슬롯에 배치합니다. 동적 배열과 매핑의 값만 키와 슬롯 번호를 이어 붙인 해시 위치에 저장합니다. Solidity 개발팀은 이 저장소 배치가 언어의 외부 인터페이스에 속하므로 바꾸면 호환성을 깨는 변경으로 다룬다고 확인했습니다. Vyper도 같은 방식을 따릅니다. 표준 슬롯은 저장 인덱스로 시작하지 않는 문자열의 해시이므로 컴파일러가 배정하는 슬롯과 겹치지 않습니다. 여기에 1을 빼서 해시의 원본(preimage)을 알 수 없게 해 공격 가능성을 더 낮춥니다. 다만 다른 언어나 어셈블리로 직접 작성한 컨트랙트에서는 충돌이 생길 수 있습니다.
참조 구현의 구조
참조 구현은 Proxy, ERC1967Upgrade, ERC1967Proxy, StorageSlot 네 부분으로 나뉩니다. Proxy는 fallback과 receive에서 _delegate를 호출해 calldata를 그대로 복사한 뒤 delegatecall을 보내고 결과를 호출자에게 돌려주거나 revert합니다. ERC1967Upgrade는 세 슬롯의 getter와 이벤트를 내보내는 setter를 제공합니다. _setBeacon은 새 비컨이 컨트랙트인지와 그 비컨의 implementation()이 컨트랙트인지 둘 다 확인합니다. UUPS용 _upgradeToAndCallSecure는 새 구현으로 바꾼 뒤 롤백 플래그 슬롯 _ROLLBACK_SLOT(eip1967.proxy.rollback의 keccak256에서 1을 뺀 값, 0x4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143)을 켜고 새 구현의 upgradeTo(address)로 옛 구현으로 되돌아가는지 시험합니다. 되돌아가지 못하면 upgrade breaks further upgrades 오류로 되돌리고 성공하면 다시 새 구현으로 올려 Upgraded를 발행합니다. StorageSlot은 인라인 어셈블리로 r.slot := slot을 지정해 임의 슬롯을 address, bool, bytes32, uint256으로 읽고 쓰는 라이브러리입니다.
쓸 때 주의할 점
- 컴파일러가 배정하지 않는 슬롯이라는 전제: 이 표준은 선택한 슬롯을 Solidity 컴파일러가 절대 배정하지 않는다는 사실에 의존합니다. 그래서 구현 컨트랙트가 프록시 동작에 필요한 정보를 실수로 덮어쓰지 않습니다. 컴파일러가 배정하는 슬롯과 겹치지 않도록 번호가 매우 큰 위치를 골랐습니다.
- 알려진 원본이 없는 해시 위치: 슬롯 위치는 해시 원본(preimage)이 알려지지 않은 값으로 골랐습니다. 매핑에 악의적으로 만든 키로 값을 써서 프록시 슬롯을 덮어쓰는 공격이 통하지 않게 하려는 것입니다.
- 로직 컨트랙트의 의도적 쓰기: 프록시 전용 정보를 바꾸려는 로직 컨트랙트는 UUPS 방식처럼 해당 저장 슬롯에 직접, 의도적으로 써야 합니다.