프록시 시리즈의 마지막 주인공은 Diamond Proxy Pattern (EIP-2535)이다. 복잡하고 대규모의 DApp을 구축할 때 마주하는 컨트랙트 크기 제한과 기능 확장성 문제를 해결하기 위해 고안된 이 패턴은 모듈화된 설계를 통해 뛰어난 유연성과 업그레이드성을 제공한다.
하지만 그만큼 견고한 보안 설계와 구현이 필수적이라서 잘 알지 못하고 사용하면 심각한 보안 사고를 초래할 수 있다. 이번 글은 EIP-2535의 핵심 원리를 파헤치고, 잠재적인 보안 위험 및 모범적인 구현 사례까지 함께 살펴보겠다.
개요
EIP-2535, Diamond Proxy Pattern은 하나의 프록시 컨트랙트가 여러 개의 구현 컨트랙트(Implementation Contract)를 사용할 수 있도록 하는 설계 표준이다. 쉽게 비유하자면, Diamond Pattern에서 프록시 컨트랙트는 다양한 기능을 수행할 수 있는 중앙 허브 역할을 하며, 이 중앙 허브에 연결되는 각각의 기능 단위 컨트랙트들을 통해 수많은 기능을 수행한다.
일반적인 프록시 패턴에서는 하나 또는 다수의 프록시가 하나의 구현 컨트랙트와 연결되지만, Diamond 패턴은 하나의 프록시 컨트랙트가 여러 구현 컨트랙트와 상호작용하며 복잡한 시스템을 효율적으로 관리할 수 있게 해준다. 프록시 컨트랙트를 Diamond, 여기에 연결된 각각의 구현 컨트랙트들을 Facet이라고 한다.
그렇다면 Diamond에 연결된 수많은 Facet 중 어떤 Facet을 사용할지는 어떻게 결정하고, 관리는 어떻게 할까? 이것이 바로 Diamond의 핵심이다.
어느 Facet으로 함수 호출을 중개할지는 콜데이터에 포함된 함수 선택자에 따라 결정된다. Diamond는 각 Facet과 그에 포함된 함수들의 함수 선택자를 저장한 매핑을 갖고 있으며, 이러한 데이터를 조회할 수 있는 public 함수를 제공하여 diamond가 어떤 기능들을 갖고 있는지 투명하게 보여준다.
핵심 작동 방식은 Diamond 컨트랙트로 함수 호출이 들어오면, msg.sig로 이 호출에 포함된 함수 선택자와 매핑된 Facet을 찾아 delegatecall을 사용하여 함수 호출을 중개하는 것이다. 즉 여느 프록시와 같이 모든 Facet 실행은 Diamond(프록시 컨트랙트)의 컨텍스트에서 실행된다.
아래는 EIP-2535에서 작성한 Diamond의 fallback 함수다. Facet을 찾는 것을 제외하면 나머지는 일반 프록시의 것과 크게 다르지 않다.
// Diamond 컨트랙트의 fallback 함수 (핵심 로직)
fallback() external payable {
// 함수 선택자로부터 Facet 주소를 반환
address facet = facetAddress(msg.sig);
require(facet != address(0));
// delegatecall을 통해 Facet의 함수를 실행
assembly {
// 호출 데이터 (함수 선택자 + 인자)를 메모리에 복사
calldatacopy(0, 0, calldatasize())
// Facet 컨트랙트로 delegatecall 실행
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
// 반환 값 또는 에러를 복사하여 호출자에게 반환
returndatacopy(0, 0, returndatasize())
switch result
case 0 {revert(0, returndatasize())}
default {return (0, returndatasize())}
}
}
Solidity
복사
EIP-2535에서는 Facet을 반드시 컨트랙트로 구현하도록 강제하진 않는다. 때로는 external 함수가 있는 라이브러리로 구현된 형태도 구현되기도 한다.
주의사항
순수 이더(ETH) 전송 트랜잭션은 콜데이터를 포함하지 않으므로 msg.sig가 비어있음을 인지해야 한다. 컨트랙트가 이러한 이더 전송을 정상적으로 수신하도록 설계하려면 “0x00...”의 msg.sig에 대한 명시적 수신 로직을 구현하거나, fallback 함수가 의도치 않게 revert되지 않도록 주의 깊게 처리해야 한다.
IDiamondLoupe: 투명한 정보 제공을 위한 인터페이스
Diamond 패턴의 장점 중 하나는 그 동적인 구조를 외부에 투명하게 노출하는 표준화된 방식에 있다. IDiamondLoupe 인터페이스는 Diamond 컨트랙트가 현재 어떤 Facet들을 활성화하고 있으며, 각 Facet이 어떤 함수들을 포함하는지를 외부에 공개하는 4가지 external view 함수를 정의한다. Diamond는 반드시 IDiamoundLoupe인터페이스를 준수해야 하며, 이는 시스템의 가시성과 감사 용이성을 크게 향상시킨다.
1.
facets(): Diamond에 연결된 모든 Facet의 주소와 해당 Facet에 속한 모든 함수 선택자를 반환
struct Facet {
address facetAddress;
bytes4[] functionSelectors;
}
Solidity
복사
function facets() external view returns (Facet[] memory facets_);
Solidity
복사
2.
facetFunctionSelectors(): 특정 Facet 주소를 인자로 받아, 해당 Facet이 지원하는 모든 함수 선택자 목록을 반환
function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory facetFunctionSelectors_);
Solidity
복사
3.
facetAddress(): 주어진 함수 선택자가 어떤 Facet에 매핑되어 있는지를 확인하고 해당 Facet의 주소를 반환
function facetAddress(bytes4 _functionSelector) external view returns (address facetAddress_);
Solidity
복사
4.
facetAddresses(): Diamond에 현재 연결된 모든 Facet 컨트랙트의 주소 목록을 반환
function facetAddresses() external view returns (address[] memory facetAddresses_);
Solidity
복사
Loupe에서 제공되는 함수의 형태를 보고 Diamond에서 Facet과 그 함수들을 어떻게 관리해야 할 지 예상할 수 있다. Facet들은 배열 형태로 작성할 수 있을 것이고, 역시 각 Facet마다 사용하는 함수가 기록된 별도의 매핑이 있어야 한다.
IDiamondCut: 유연한 업그레이드를 위한 인터페이스
Diamond 컨트랙트의 가장 강력한 특징은 ‘유연한 업그레이드 가능성'이다. IDiamondCut 인터페이스는 Diamond의 기능을 동적으로 변경(추가, 교체, 제거)할 수 있는 핵심 메커니즘을 제공한다. 중요한 점은 모든 Facet 변경 작업이 단일 트랜잭션 내에서 완료되어야 한다는 것이다. 이는 중간 단계에서의 데이터 불일치, 경쟁 조건, 프론트러닝(Front-run)과 같은 보안 취약점을 원천적으로 차단하며, 업그레이드 과정의 무결성을 보장한다.
IDiamondCut 인터페이스가 구현되지 않아 업그레이드가 불가능한 Diamond라 할지라도, 모든 Facet 및 함수 선택자 변경 사항은 반드시 이벤트로 기록되어야 한다. 업그레이드가 불가능한 Diamond의 경우에도 배포 시점에는 새로운 Facet과 함수 선택자가 추가되기 때문이다.
이는 온체인 데이터뿐만 아니라 오프체인 시스템(웹2, 클라우드 등)과의 상호운용성을 확보하는 데 필수적이다. 외부 서비스는 이 로그를 파싱하여 Diamond의 최신 상태 변화를 효율적으로 추적하고 동기화할 수 있다. 즉, Diamond는 현재 상태를 IDiamondLoupe를 통한 조회와 DiamondCut 이벤트를 통한 기록 두 가지 방식으로 외부에 제공해야 한다.
diamondCut 함수는 FacetCut 구조체 배열을 통해 여러 변경 사항을 한 번에 처리한다. FacetCut 구조체는 facetAddress, action(Add, Replace, Remove), functionSelectors로 구성되며 각 action은 다음 의미를 가진다.
•
Add: 새로운 함수 선택자를 특정 facetAddress에 매핑(기존 함수와 충돌 시 revert)
•
Replace: 기존 함수 선택자를 새로운 facetAddress로 옮김(유효하지 않은 경우 revert)
•
Remove: 특정 facetAddress에서 함수 선택자를 제거(존재하지 않는 경우 revert)
// IDiamond 인터페이스 (DiamondCut 이벤트 포함)
interface IDiamond {
enum FacetCutAction {Add, Replace, Remove}
struct FacetCut {
address facetAddress;
FacetCutAction action;
bytes4[] functionSelectors;
}
event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}
// IDiamondCut 인터페이스
interface IDiamondCut is IDiamond {
function diamondCut(
FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external;
}
Solidity
복사
Diamond에서 모든 함수 선택자가 제거되면 그 Facet은 제거된 것으로 간주된다. 반대로, 새로운 구현 주소에 해당하는 새로운 함수 선택자가 추가되면 그 주소는 Facet으로 간주된다.
_init과 _calldata는 OpenZeppelin Initializer에서 사용되는 것과 동일하게 새로운 Facet을 초기화하기 위함이다. 초기화 작업이 필요 없다면 _init은 address(0)으로, _calldata는 빈 값으로 설정한다.
스토리지 관리
Diamond Pattern에서 스토리지 관리는 일반적인 프록시 패턴에 비해 훨씬 복잡하고 중요하다. Diamond(프록시)와 Facet(구현) 컨트랙트 간의 스토리지 충돌뿐만 아니라, 서로 다른 Facet들 간의 스토리지 충돌 가능성까지 고려해야 하기 때문이다. 잘못된 스토리지 설계는 중요한 데이터 손상으로 이어지는 치명적인 취약점이 될 수 있다. EIP-2535는 스토리지 관리 방식에 대해 명확한 제한을 두고 있지는 않지만, 다음 두 가지 주요 스토리지 관리 방식을 제안한다.
1.
Diamond Storage 방식
각 Facet이 자체적인 스토리지 네임스페이스(storage namespace)를 사용하여 사용할 스토리지 위치를 명시적으로 지정한다. 이는 마치 각 Facet이 자신만의 전용 구역을 가지는 것과 같다. 이를테면 스토리지의 300번지부터는 facet A, 8000번지부터는 facet B가 사용하도록 한다. 이는 스토리지에 의도적으로 접근하려고 하지 않는 이상, 각 Facet이 실수로 자기 것이 아닌 다른 스토리지에 접근하는 것을 예방한다. 이 방식은 EIP-7201 “Namespaced Storage Layout”의 기반이 되는 개념이기도 하다.
아래는 EIP-2535에서 소개하는 Diamond Storage의 간단한 구현 예시이다.
// Diamond Storage 예시 (ERC721 Facet)
library LibERC721 {
// 고유한 스토리지 위치를 위한 해시 값
bytes32 constant ERC721_POSITION = keccak256("erc721.storage");
struct ERC721Storage {
mapping (uint256 => address) tokenIdToOwner;
mapping (address => uint256) ownerToNFTokenCount;
string name;
string symbol;
}
// 해당 스토리지 구조체에 접근하는 함수
function getStorage() internal pure returns (ERC721Storage storage storageStruct) {
bytes32 position = ERC721_POSITION;
assembly {
storageStruct.slot := position // 지정된 스토리지 슬롯 사용
}
}
}
contract ERC721Facet {
function name() external view returns (string memory name_) {
name_ = LibERC721.getStorage().name; // LibERC721을 통해 스토리지 접근
}
// ...
}
Solidity
복사
2.
App Storage 방식
이 방식은 여러 Facet이 공유해야 하는 상태를 관리하는 데 적합하다. 모든 공유 상태 변수를 하나의 큰 AppStorage 구조체 내에 정의하고, 모든 Facet은 LibAppStorage와 같은 공통 라이브러리를 통해 이 AppStorage의 스토리지 슬롯(대부분 0번 슬롯부터)에 접근한다. 이는 상태의 중앙 집중화된 관리를 가능하게 하며, 전체 시스템의 상태를 한눈에 파악하기 용이하다.
// App Storage 예시
struct AppStorage {
uint256 secondVar;
uint256 firstVar;
uint256 lastVar;
// ... 다른 상태 변수들
}
contract AFacet {
AppStorage internal s; // AppStorage 구조체를 멤버 변수로 선언
function sumVariables() external {
s.lastVar = s.firstVar + s.secondVar; // AppStorage 변수에 직접 접근
}
// ...
}
library LibAppStorage {
function appStorage() internal pure returns (AppStorage storage ds) {
assembly { ds.slot := 0 } // 스토리지 슬롯 0번지부터 사용
}
function someFunction() internal {
AppStorage storage s = appStorage();
s.firstVar = 8; // 라이브러리를 통해 AppStorage 변수 접근
// ...
}
}
Solidity
복사
성공적인 Diamond 구현은 이 두 가지 스토리지 전략을 프로젝트의 필요에 따라 적절히 혼용하여 스토리지 효율성과 안전성을 동시에 확보하는 것이다. 명확한 스토리지 레이아웃과 접근 규약은 스토리지 충돌로 인한 취약점 발생을 근본적으로 차단한다.
facet 간 함수 공유
하나의 Diamond 시스템 내에서 여러 Facet이 서로의 기능을 참조하거나 호출해야 하는 경우가 빈번하다. EIP-2535는 Facet 간 효율적인 내부 통신을 위한 여러 방법을 제시하며, 다음 세 가지가 가장 일반적이고 효율적인 접근 방식이다.
1.
프록시 컨텍스트를 이용한 호출
가장 직관적인 방법으로, 아래와 같이 호출한다.
MyOtherFacet(address(this)).myFunction(arg1, arg2)
Solidity
복사
address(this)가 프록시 컨트랙트(Diamond)의 주소를 가리키므로, 이 호출은 Diamond의 fallback 함수를 통해 올바른 Facet으로 delegatecall 된다. 코드는 매우 간결하지만, 내부적으로 fallback 함수를 거치므로 약간의 가스 오버헤드가 있다.
2.
delegatecall을 이용한 직접 중개
가스 최적화가 중요한 경우, fallback 함수를 거치지 않고 직접 대상 Facet으로 delegatecall을 수행할 수 있다. Facet들은 Diamond의 스토리지를 공유하므로 함수 선택자와 Facet의 매핑인 selectorToFacet 매핑을 통해 대상 Facet 주소를 직접 조회하여 호출한다.
DiamondStorage storage ds = diamondStorage();
bytes4 functionSelector = bytes4(keccak256("myFunction(uint256)"));
address facet = ds.selectorToFacet[functionSelector]; // Facet 주소 조회
bytes memory myFunctionCall = abi.encodeWithSelector(functionSelector, 4);
(bool success, bytes memory result) = address(facet).delegatecall(myFunctionCall); // 직접 delegatecall
Solidity
복사
이 방식은 가스를 절약할 수 있지만, 단일 함수 호출을 위해 작성해야 할 코드가 늘어나며 가독성과 유지보수성이 저하될 수 있다.
3.
공통 Library를 통한 함수 공유
가장 권장되는 접근 방식으로, 여러 Facet에서 공통적으로 사용되는 로직이나 내부 함수들을 별도의 library 컨트랙트에 internal 함수로 구현한다. 이후 각 Facet은 필요한 library를 import하여 사용한다. 이 방식은 코드 중복을 최소화하고, 모듈성을 높이며, 코드 재사용성과 유지보수성 측면에서 가장 효율적이다. Facet 컨트랙트 코드 자체도 깔끔하게 유지된다는 장점이 있다.
Diamond Proxy의 핵심 위협과 방어 전략
Diamond Proxy Pattern의 복잡하고 동적인 특성은 상당한 보안 위험을 수반한다. 다음은 Diamond Proxy 시스템에서 면밀히 검토되어야 할 주요 보안 위협과 그에 대한 방어 전략이다.
1. 소유권 및 인증 관리 부재
diamondCut 함수는 시스템의 핵심 기능을 추가, 교체, 제거하는 강력한 권한을 가진다. EIP-2535 표준은 이 함수의 호출 권한 관리에 대한 명확한 메커니즘을 정의하지 않으므로, 개발자가 반드시 견고한 소유권/인증 로직을 직접 구현해야 한다.
•
위협: 단일 계정에 대한 의존은 해당 계정 탈취 시 시스템 전체가 장악되는 단일 실패 지점(Single Point of Failure, SPOF)을 형성한다.
•
방어 전략: diamondCut 함수는 최소한 다중 서명(Multisig) 또는 탈중앙화 자율 조직(DAO) 기반의 거버넌스 시스템을 통해 제어되어야 한다. 이러한 방식은 업그레이드 권한을 분산하고 투표 프로세스를 통해 악의적인 변경을 방지하여 보안 수준을 크게 높인다.
2. diamondCut을 통한 임의 실행
diamondCut 함수의 _init 주소와 _calldata를 이용한 delegatecall 메커니즘은 매우 강력한 반면, 시스템에 임의의 코드를 실행시킬 수 있는 잠재적 벡터로 작용한다.
•
위협: 악의적인 _init 컨트랙트 주소나 조작된 _calldata가 주입되면, Diamond의 공유 스토리지 데이터가 손상되거나, 재진입(re-entrancy) 공격, 로직 오류, 심지어 전체 시스템 백도어가 생성될 수 있다. 이는 컨트랙트 보안 취약점의 가장 직접적인 원인 중 하나이다.
•
방어 전략:
◦
_init 주소가 실제 컨트랙트 코드를 가지고 있는지 검증해야 한다.
◦
_init 호출 전에 해당 주소가 사전에 승인된 화이트리스트(whitelist)에 포함되는지 검사하는 등의 추가 로직을 구현하는 것이 좋다.
◦
ZkSync Diamond 구현과 같이 _init 컨트랙트가 특정 "매직 값"을 반환하는지 확인하는 패턴을 통해 초기화 함수의 유효성을 강제하는 것도 효과적이다.
3. 스토리지 충돌 (Storage Collisions):
본문에서 언급한 것처럼 다수의 Facet이 동일한 프록시 스토리지 공간을 공유한다는 점은 Diamond 패턴이 안고 있는 가장 심각하고 복합적인 보안 문제이다.
•
위협: 스토리지 슬롯의 정의가 일관되지 않거나, 각 Facet의 스토리지 오프셋 관리에 오류가 발생하면, 서로 다른 Facet의 상태 변수가 동일한 스토리지 슬롯을 사용하게 되어 데이터가 덮어씌워지거나 훼손될 수 있다. 이는 단순히 기능적 오류를 넘어 막대한 금융 손실로 이어질 수 있는 치명적인 취약점이다.
•
방어 전략:
◦
Diamond Storage 또는 App Storage 방식을 일관되게 적용하여 스토리지 레이아웃을 명확하게 분리하고 관리해야 한다.
◦
모든 Facet의 스토리지 변수 정의는 시스템 전반에 걸쳐 엄격하게 계획되고 문서화되어야 한다.
◦
스토리지 충돌을 자동으로 감지할 수 있는 테스팅 프레임워크 및 린팅(linting) 도구를 적극적으로 활용한다.
4. 함수 선택자 충돌
함수 선택자는 4바이트 해시 값으로, 서로 다른 함수가 동일한 선택자를 가질 확률이 존재한다.
•
위협: 함수 선택자 충돌이 발생하면 의도치 않게 기존 함수가 교체되거나, 새로운 함수가 예상대로 추가되지 않는 등 시스템 기능에 혼란을 야기한다. 악의적인 공격자가 이 취약점을 이용하면 특정 기능을 마비시키거나 조작할 수 있다.
•
방어 전략:
◦
diamondCut 함수를 구현할 때 새로운 함수 선택자를 추가하거나 교체하기 전에 기존에 해당 선택자를 사용하는 Facet이나 함수가 없는지 반드시 검증해야 한다.
◦
배포 전 철저한 충돌 테스트(Clash Test)를 수행하고, 알려진 충돌 가능성을 항상 염두에 둔다.
구현 분석
Pinto의 upgradable diamond 구현은 EIP-2535 표준의 모범 사례 중 하나이다. 다음 7개의 주요 구성요소는 이 패턴이 어떻게 견고하게 설계될 수 있는지를 보여준다. 본 분석에서는 스토리지 관리 및 업그레이드 기능 구현을 중심으로 설명한다.
1.
Storage: LibAppStorage.sol, AppStorage.sol, System.sol, Account.sol
2.
libDiamond.sol
3.
Diamond.sol
4.
DiamondCutFacet.sol
5.
DiamondLoupeFacet.sol
6.
OwnershipFacet.sol
7.
PauseFacet.sol
Storage 관리 전략
Pinto는 App Storage 방식과 Diamond Storage 방식을 효율적으로 혼용하여 스토리지의 명확성과 보안성을 동시에 확보한다.
•
App Storage (공유 상태):
시스템 전반에 걸쳐 모든 Facet이 공유하는 상태 변수들은 AppStorage 구조체 내에 통합 관리한다. LibAppStorage.sol 라이브러리의 diamondStorage() 함수를 통해 모든 Facet이 스토리지 슬롯 0번지부터 시작하는 이 공유 영역에 안전하게 접근한다. System.sol과 Account.sol은 각기 시스템 전반 및 개별 사용자 관련 상태 변수를 구조화하며, AppStorage.sol은 이들을 결합하여 Facet들이 쉽게 접근할 수 있도록 하는 역할을 한다. 이는 상태 변수의 중앙 집중화된 선언과 접근 방식을 통해 관리의 용이성을 제공한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AppStorage} from "../beanstalk/storage/AppStorage.sol";
library LibAppStorage {
function diamondStorage() internal pure returns (AppStorage storage ds) {
assembly { ds.slot := 0 } // 슬롯 0번지부터 시작하는 공유 스토리지 영역
}
}
Solidity
복사
pinto/contracts/libraries/LibAppStorage.sol
아래는 library에서 AppStorage를 통해 스토리지에 접근하는 예시다.
AppStorage storage s = LibAppStorage.diamondStorage();
require(s.sys.farmingStatus == C.ENTERED, "Unprotected farm call");
Solidity
복사
pinto/contracts/libraries/LibFarm.sol
•
Diamond Storage(내부 메타데이터):
Diamond 컨트랙트 자체의 내부 메타데이터, 즉 함수 선택자와 Facet 주소 매핑(selectorToFacetAndPosition) 및 Facet 목록(facetAddresses) storage namespace를 사용하여 아래와 같은 고유한 스토리지 슬롯 식별자를 통해 별도로 관리된다.
bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("diamond.standard.diamond.storage");
Solidity
복사
pinto/contracts/libraries/LibDiamond.sol
LibDiamond.sol 라이브러리는 diamondStorage() 함수를 통해 이 특정 위치에 접근하여 Diamond의 메타데이터를 관리한다. 이는 Facet 간의 충돌로부터 Diamond 자체의 핵심 관리 데이터를 보호하는 데 매우 효과적이다. facet들은 LibDiamond.sol 의 diamondStorage()함수를 사용하여 diamond 정보가 저장된 스토리지에 접근할 수 있다.
function diamondStorage() internal pure returns (DiamondStorage storage ds) {
bytes32 position = DIAMOND_STORAGE_POSITION;
assembly {
ds.slot := position
}
}
// ...
DiamondStorage storage ds = diamondStorage();
require(_facetAddress != address(0), "LibDiamondCut: Add facet can't be address(0)");
uint96 selectorPosition = uint96(
>> ds.facetFunctionSelectors[_facetAddress].functionSelectors.length
);
Solidity
복사
pinto/contracts/libraries/LibDiamond.sol
Pinto의 예시는 스토리지 관리를 위해 반드시 어느 한 전략만을 택할 필요가 없음을 보여준다.
업그레이드 기능 및 diamond 구현
Pinto의 Diamond.sol 컨트랙트는 constructor와 fallback 함수만 포함하도록 최소화되어 있으며, 이는 패턴의 모듈화 원칙을 충실히 따른다. 생성 시점에 owner를 지정하고 DiamondCutFacet, DiamondLoupeFacet 등의 필수 Facet의 함수들을 등록한다. 모든 실제 기능 구현 및 상태 변경 로직은 별도의 Facet(DiamondCutFacet.sol, DiamondLoupe.sol)과 라이브러리(LibDiamond.sol)에 분리되어 있다.
diamondCut() 함수의 보안제어
Pinto의 diamondCut()함수는 enforceIsContractOwner과 같이 owner만 호출할 수 있도록 권한 제어 로직을 포함하고 있다. 이는 앞서 강조한 소유권 및 인증 보안 권고 사항을 따른다.
function diamondCut(
FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external override {
LibDiamond.enforceIsContractOwner();
LibDiamond.diamondCut(_diamondCut, _init, _calldata);
}
Solidity
복사
pinto/contracts/beanstalk/facets/diamond/DiamondCutFacet.sol
owner는 별도의 OnwershipFacet으로 관리되며, owner 변경 시 two-step 변경 방식을 적용해 보안을 강화시켰다.
function transferOwnership(address _newOwner) external {
LibDiamond.enforceIsContractOwner(); // sender must be the contract owner
s.sys.ownerCandidate = _newOwner;
}
function claimOwnership() external {
require(s.sys.ownerCandidate == msg.sender, "Ownership: Not candidate");
LibDiamond.setContractOwner(msg.sender);
delete s.sys.ownerCandidate;
}
Solidity
복사
pinto/contracts/beanstalk/facets/diamond/OwnershipFacet.sol
실제 변경은 LibDiamond.diamondCut에서 FacetCutAction에 따라 함수선택자를 추가, 변경, 제거하는 함수를 호출하여 이루어진다. 모든 변경 작업 후에는 DiamondCut이벤트를 발생시켜 Diamond의 변경 이력을 투명하게 추적할 수 있도록 했다.
function diamondCut(
IDiamondCut.FacetCut[] memory _diamondCut,
address _init, // 실행할 함수
bytes memory _calldata // 실행할 함수 데이터(optional)
) internal {
for (uint256 facetIndex; facetIndex < _diamondCut.length; facetIndex++) {
IDiamondCut.FacetCutAction action = _diamondCut[facetIndex].action;
if (action == IDiamondCut.FacetCutAction.Add) {
addFunctions(
// ...
);
} else if (action == IDiamondCut.FacetCutAction.Replace) {
replaceFunctions(
// ...
);
} else if (action == IDiamondCut.FacetCutAction.Remove) {
removeFunctions(
// ...
);
} else {
revert("LibDiamondCut: Incorrect FacetCutAction");
}
}
emit IDiamondCut.DiamondCut(_diamondCut, _init, _calldata);
initializeDiamondCut(_init, _calldata);
}
Solidity
복사
pinto/contracts/libraries/LibDiamond.sol
Diamond를 변경할 때 함수 선택자 충돌 및 _init 임의 코드 실행 위험을 방지하기 위한 중요한 보안 조치 또한 구현에 포함되어 있다.
require(_functionSelectors.length > 0, "LibDiamondCut: No selectors in facet to cut")
// ...
address oldFacetAddress = ds.selectorToFacetAndPosition[selector].facetAddress;
require(
oldFacetAddress == address(0),
"LibDiamondCut: Can't add function that already exists"
);
Solidity
복사
pinto/contracts/libraries/LibDiamond.sol: addFunctions
initializeDiamondCut 함수는 _init 주소가 0이 아닌 경우 해당 주소에 실제 컨트랙트 코드가 존재하는지(enforceHasContractCode) 확인하고, _calldata의 유효성 검증도 함께 수행하여 유효하지 않은 _init 호출을 차단한다.
function initializeDiamondCut(address _init, bytes memory _calldata) internal {
if (_init == address(0)) {
require(
_calldata.length == 0,
"LibDiamondCut: _init is address(0) but_calldata is not empty"
);
} else {
require(
_calldata.length > 0,
"LibDiamondCut: _calldata is empty but _init is not address(0)"
);
if (_init != address(this)) {
enforceHasContractCode(_init, "LibDiamondCut: _init address has no code");
}
(bool success, bytes memory error) = _init.delegatecall(_calldata);
if (!success) {
if (error.length > 0) {
// bubble up the error
revert(string(error));
} else {
revert("LibDiamondCut: _init function reverted");
}
}
}
}
Solidity
복사
pinto/contracts/libraries/LibDiamond.sol
Pinto의 Diamond 구현은 강력한 권한 제어, 스토리지 무결성 확보, 함수 선택자 충돌 방지, 그리고 _init을 통한 임의 코드 실행 위험 완화 등 Diamond Proxy Pattern이 내포한 주요 보안 위협에 대한 방어 로직을 제공하여 EIP-2535 표준의 핵심 권고 사항을 잘 준수한 모범 사례이다.
3줄 요약
1.
Diamond Proxy Pattern은 단일 컨트랙트 크기 제한과 기능 확장성 문제를 해결하는 모듈형 스마트 컨트랙트 표준으로, Diamond(프록시)와 Facet(구현) 분리를 통해 대규모 DApp 구축의 무한한 유연성과 업그레이드성을 제공한다.
2.
IDiamondCut 인터페이스를 통한 Facet의 동적 추가, 교체, 제거가 핵심이며, 모든 변경사항은 단일 트랜잭션 내에서 DiamondCut 이벤트로 기록되어 시스템 투명성과 오프체인 모니터링을 지원한다.
3.
다양한 Facet의 존재 및 업그레이드 기능 등 고유의 복잡성으로 인한 치명적인 보안 위험이 존재하므로, 강력한 권한 제어, 정교한 스토리지 설계, 그리고 엄격한 유효성 검증이 필수적이며, 사고 방지를 위해 전문 보안 감사를 받는 것이 권장된다.
마치며
여기까지 프록시 시리즈를 통해 EVM의 한계를 우회하고 DApp의 유연한 관리 및 확장성을 가능하게 하는 다양한 프록시 패턴을 알아보았다. 다양한 프록시 패턴은 강력한 잠재력을 지녔으나, 스토리지 충돌, 업그레이드 취약성, 소유권 관리 미흡 등 다채로운 보안 위협을 내포하고 있다.
78리서치랩은 블록체인 생태계에 대한 전문성을 갖추고 심층적인 보안 감사를 통해 이러한 잠재적 위험을 철저히 식별하여 고객의 DApp이 가장 안전하고 신뢰성 높은 환경에서 운영되도록 보장합니다.