ERC-2535 다이아몬드 다면 프록시 (Diamonds, Multi-Facet Proxy)
Final · 기타 · 2020-02-22 작성
컨트랙트 주소 하나 뒤에 기능 조각인 패싯(facet)을 여러 개 붙여, 배포 뒤에도 기능을 더하고 바꾸고 뺄 수 있는 모듈형 컨트랙트 구조를 정합니다. 크기 제한도 사실상 없습니다.
왜 필요한가
이더리움 컨트랙트는 한 개의 크기가 24KB를 넘을 수 없습니다. 기능이 많은 앱은 코드를 여러 컨트랙트로 쪼개야 하고 그러면 주소가 여러 개가 되어 배포, 테스트, 다른 소프트웨어와의 연동이 번거로워집니다. 크기를 맞추려고 오류 메시지 같은 것을 지우기도 합니다. 배포 뒤에 버그를 고치거나 새 표준을 붙이려면 기존 기능까지 다시 배포해야 하고 여러 트랜잭션에 걸쳐 업그레이드하면 중간에 데이터가 깨질 위험도 있습니다. 또 업그레이드되는 컨트랙트는 검증된 소스코드만 봐서는 지금 어떤 기능이 들어 있는지 알 수 없어 사용자와 도구가 안을 들여다볼 공통 방법이 없었습니다. 이 표준은 이런 문제를 한 주소 아래 기능 조각을 붙였다 떼는 구조와 그 변화를 기록하고 조회하는 공통 인터페이스로 풉니다.
구현해야 하는 것
event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);: 함수가 추가·교체·삭제될 때마다 바뀐 패싯과 셀렉터, 초기화 대상과 호출 데이터를 기록합니다.function facets() external view returns (Facet[] memory facets_);: 모든 패싯 주소와 각 패싯의 4바이트 함수 셀렉터를 돌려줍니다.function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory facetFunctionSelectors_);: 특정 패싯이 지원하는 함수 셀렉터 목록을 돌려줍니다.function facetAddresses() external view returns (address[] memory facetAddresses_);: 다이아몬드가 사용하는 모든 패싯 주소를 돌려줍니다.function facetAddress(bytes4 _functionSelector) external view returns (address facetAddress_);: 주어진 셀렉터를 담당하는 패싯 주소를 돌려주며 없으면 address(0) 을 돌려줍니다.
심층 설명
다이아몬드·패싯·루프·불변 함수의 정의
다이아몬드(diamond)는 외부 호출을 받아 패싯으로 delegatecall 하는 파사드 컨트랙트입니다. 상태가 있으며 데이터는 다이아몬드의 컨트랙트 저장소에 쌓입니다. 패싯(facet)은 외부 함수를 가진 상태 없는 컨트랙트 또는 Solidity 라이브러리입니다. 한 번 배포한 패싯의 함수 일부 또는 전부를 하나 이상의 다이아몬드에 추가합니다. 패싯은 자기 저장소에 데이터를 두지 않지만 상태를 정의하고 다이아몬드 저장소를 읽고 쓸 수 있습니다. 루프 패싯(loupe facet)은 내부를 들여다보는 조회 함수를 제공하는 패싯입니다. 불변 함수(immutable function)는 다이아몬드에 직접 정의되었거나 로직상 수정이 막혀 교체·삭제가 불가능한 외부 함수입니다. 이 문서에서 그냥 함수라고 하면 외부 함수를 뜻하고 외부 함수에 적용되는 내용은 public 함수에도 적용됩니다. 매핑(mapping)은 두 대상의 연결을 뜻할 뿐 특정 구현을 가리키지 않습니다.
폴백 함수가 셀렉터로 패싯을 찾아 delegatecall
다이아몬드에 외부 함수를 호출하면, 다이아몬드에 직접 정의된 불변 함수라면 바로 실행됩니다. 그렇지 않으면 폴백 함수가 실행되어 호출 데이터 앞 4바이트인 함수 셀렉터로 연결된 패싯을 찾고 delegatecall 로 그 함수를 실행합니다. delegatecall 이므로 msg.sender 와 msg.value 는 바뀌지 않고, 읽고 쓰는 저장소는 다이아몬드의 것뿐입니다. 즉 패싯의 함수가 다이아몬드 자신이 구현한 것처럼 동작합니다. 셀렉터에 연결된 패싯이 없으면 선택적으로 기본 함수(default function)를 실행할 수 있습니다. 기본 함수도 없고 다른 처리 방법도 없으면 실행은 되돌려집니다(revert). 기본 함수를 어떻게 구현할지는 이 표준이 정하지 않습니다. 원문의 폴백 예시는 selectorTofacet[msg.sig] 로 패싯 주소를 얻고 calldatacopy 로 호출 데이터를 복사한 뒤 delegatecall 결과를 returndatacopy 로 받아 그대로 반환하거나 revert 하는 구조입니다.
저장소 배치와 Solidity 라이브러리 패싯
Solidity 의 기본 저장소 배치는 프록시나 다이아몬드를 지원하지 않으므로 별도의 상태 변수 배치 패턴이 필요합니다. 구체적인 배치는 이 EIP 가 정하지 않으며 이후 제안에서 정할 수 있습니다. 원문은 함께 쓸 수 있는 예로 Diamond Storage 와 AppStorage 를 듭니다. 패싯들은 같은 저장소 위치에 같은 struct 를 두어 상태 변수를 공유하고 같은 컨트랙트를 상속하거나 같은 라이브러리를 써서 내부 함수를 공유합니다. 모든 데이터는 다이아몬드 저장소에 있지만 패싯마다 접근 범위가 다를 수 있습니다. 배포된 Solidity 라이브러리도 패싯이 될 수 있는데, 외부 함수가 하나 이상 있는 라이브러리만 배포되어 패싯이 됩니다. 내부 함수만 있는 라이브러리는 배포할 수 없고 패싯이 될 수 없으며 그 코드는 이를 쓰는 패싯의 바이트코드에 포함되어 패싯 간 내부 함수 공유에 쓰입니다. 라이브러리 패싯은 삭제할 수 없고 상태가 없으며 문법상 Diamond Storage 밖에 상태 변수를 선언할 수 없습니다.
IDiamond 인터페이스와 DiamondCut 이벤트
모든 다이아몬드는 IDiamond 인터페이스를 구현해야 합니다. FacetCutAction 열거형은 Add=0, Replace=1, Remove=2 입니다. FacetCut 구조체는 facetAddress, action, functionSelectors(bytes4 배열) 세 필드로 어느 패싯의 어떤 셀렉터를 어떻게 바꿀지 담습니다. DiamondCut 이벤트는 _diamondCut(FacetCut 배열), _init, _calldata 를 기록합니다. 외부 함수가 추가·교체·삭제될 때마다 반드시 DiamondCut 이벤트를 내보내야 합니다. 이는 diamondCut 함수를 거치든 아니든, 언제 어떤 방식의 업그레이드든 모두 해당합니다. 배포 시점에도 불변 함수와 추가되는 모든 외부 함수를 DiamondCut 이벤트로 내보내야 합니다. 이렇게 쌓인 이벤트가 다이아몬드의 모든 함수 변경 이력이 됩니다.
diamondCut 의 Add·Replace·Remove 규칙과 _init 실행
배포 뒤 함수 셀렉터 매핑을 바꿀 수 있는 다이아몬드는 IDiamondCut 을 구현해야 합니다(should). diamondCut 은 여러 패싯의 여러 함수를 한 트랜잭션에서 갱신합니다. 여러 트랜잭션에 걸친 업그레이드에서 생길 수 있는 데이터 손상을 막기 위해서입니다. 각 FacetCut 에 대해 action 이 Add 면 functionSelectors 의 각 항목을 facetAddress 로 매핑하되, 이미 매핑된 셀렉터가 하나라도 있으면 되돌립니다. Replace 면 매핑을 facetAddress 로 바꾸되, 기존 값이 facetAddress 와 같거나 셀렉터가 비어 있으면 되돌립니다. Remove 면 매핑을 지우되, 비어 있던 셀렉터가 있으면 되돌립니다. 불변 함수를 교체하거나 삭제하려는 시도는 반드시 되돌려야 합니다. 이렇게 의도를 명시하게 해 업그레이드 실수를 잡습니다. 함수 변경이 끝나면 _calldata 를 _init 주소에 delegatecall 로 실행해 데이터 초기화나 정리를 합니다. _init 이 address(0) 이면 실행을 건너뛰며 이때 _calldata 는 0바이트이거나 임의 정보일 수 있습니다. 도구와 UI 는 상호운용을 위해 이 표준 diamondCut 함수를 기대하고 사용해야 합니다.
루프 함수 네 개로 패싯과 함수를 조회
다이아몬드는 IDiamondLoupe 를 구현해 패싯과 함수를 조회할 수 있어야 합니다. facets() 는 모든 패싯 주소와 각각의 4바이트 셀렉터를 Facet 구조체 배열로 돌려줍니다. facetFunctionSelectors(_facet) 는 특정 패싯이 지원하는 셀렉터 목록을, facetAddresses() 는 사용 중인 패싯 주소 목록을, facetAddress(_functionSelector) 는 그 셀렉터를 담당하는 패싯 주소를 돌려주며 없으면 address(0) 입니다. 불변 함수가 있으면 루프 함수가 그 정보도 돌려줘야 하고 이때 패싯 주소는 다이아몬드 자신의 주소입니다. 일반 컨트랙트는 검증된 소스코드만 보면 함수를 알 수 있지만 다이아몬드의 소스코드에는 어떤 함수가 있는지 들어 있지 않으므로 이 조회 수단이 필요합니다. 원문은 UI 에서 다이아몬드 정보를 보여 주고 함수를 호출하게 하는 일, Etherscan 같은 서비스에서 소스코드와 ABI 를 가져오는 일, 함수 추가·교체·삭제 트랜잭션이 성공했는지 검증하는 일, 배포·업그레이드 도구와 테스트에 쓰는 일을 용도로 듭니다. facetAddress 로 어떤 패싯 주소가 함수를 구현하는지 확인하면 함수의 버전을 알 수 있습니다.
EIP-165 인터페이스 ID와 바뀌지 않는 주소
EIP-165 의 supportsInterface 구현은 선택 사항입니다. 다이아몬드에 diamondCut 함수가 있으면 그 인터페이스 ID 는 IDiamondCut.diamondCut.selector 입니다. 루프 인터페이스 ID 는 IDiamondLoupe.facets.selector ^ IDiamondLoupe.facetFunctionSelectors.selector ^ IDiamondLoupe.facetAddresses.selector ^ IDiamondLoupe.facetAddress.selector 로 네 셀렉터를 XOR 한 값입니다. 사용자가 상호작용하는 주소는 다이아몬드 주소이며 이 주소는 바뀌지 않습니다. diamondCut 이나 다른 함수로 바뀌는 것은 패싯 주소뿐입니다. 인터페이스 구현 방식도 두 가지가 허용됩니다. contract Contract is Interface 처럼 직접 구현하거나, 하나 이상의 패싯에서 함수를 추가해 구현하는 방식 모두 이 제안에서는 인터페이스를 구현한 것으로 봅니다.
가스 고려와 패싯 간 함수 공유 방법
delegatecall 로 위임하면 가스 부담이 약간 생기지만 원문은 완화 방법을 듭니다. 크기 제한이 없으므로 EIP-721 구현에 일괄 전송 함수를 넣는 식으로 용도별 가스 최적화 함수를 추가할 수 있습니다. 한 트랜잭션에서 여러 컨트랙트를 부르던 구조를 다이아몬드 하나로 합쳐 저장소에 직접 접근하면 가스를 아낍니다. 함수가 적은 컨트랙트가 많은 컨트랙트보다 호출 가스가 싸므로 패싯에 외부 함수를 적게 둘 수 있고 Solidity 최적화기를 높게 설정해 실행 가스를 줄일 수 있습니다. 다른 패싯의 함수를 쓰는 방법으로는 내부 함수 코드를 복사하기, 공통 내부 함수를 여러 패싯이 상속하는 컨트랙트나 라이브러리에 두기, MyOtherFacet(address(this)).myFunction(arg1, arg2) 처럼 타입 안전하게 외부 함수 호출하기, 저장소의 selectorToFacet 에서 패싯 주소를 찾아 delegatecall 하기, 외부 함수의 내부 버전을 만들어 필요한 패싯에 넣기가 있습니다. 같은 함수 시그니처를 가진 외부 함수 두 개를 한 다이아몬드에 동시에 넣을 수는 없습니다. 패싯의 모든 함수를 다이아몬드에 추가할 필요는 없으며, 배포된 패싯 하나를 여러 다이아몬드가 쓸 수 있습니다.
쓸 때 주의할 점
- 소유권·인증 설계는 표준에 포함되지 않음: 다이아몬드의 소유권과 인증을 어떻게 설계하고 구현할지는 이 표준의 일부가 아닙니다. 원문과 참조 구현의 예시는 가능한 방법의 예일 뿐입니다. 계정 하나가 함수 추가·교체·삭제 권한을 갖는 단순한 방식부터, 탈중앙 자율조직(DAO)이 특정 함수만 바꿀 수 있게 하는 방식, 여러 사람이 승인 함수를 호출한 뒤에야 diamondCut 이 실행되는 합의 방식까지 모두 가능합니다. 원문은 소유권·제어·인증에 관한 표준과 구현 개발을 권장합니다.
- diamondCut 은 저장소에 접근하는 임의 실행이 가능: diamondCut 함수는 delegatecall 을 통해 다이아몬드 저장소에 접근하는 임의 코드 실행을 허용합니다. 이 함수에 대한 접근은 신중하게 제한해야 합니다.
- 패싯에서 selfdestruct 사용 금지: 패싯에서 selfdestruct 는 쓰지 않도록 강하게 권장합니다. 잘못 쓰면 다이아몬드나 패싯이 삭제될 수 있습니다.
- 함수 셀렉터 충돌: 서로 다른 함수 시그니처의 4바이트 해시가 같은 셀렉터 충돌이 생기면, 새 함수를 추가하려다 기존 함수를 덮어쓸 수 있습니다. 제대로 구현된 diamondCut 은 이미 존재하는 셀렉터의 추가를 막으므로 이 시나리오는 일어나지 않습니다.
- 변경은 모두 이벤트로 드러남: 다이아몬드는 함수가 추가·교체·삭제될 때마다 이벤트를 내보내고 모든 소스코드는 검증할 수 있습니다. 사람과 소프트웨어가 컨트랙트 변경을 감시할 수 있어 악의적인 함수가 추가되면 눈에 띕니다. 보안·도메인 전문가가 변경 이력을 검토해 부정행위 흔적을 찾을 수 있습니다.