EIP-211 반환 데이터 조회 옵코드 신설 ("New opcodes: RETURNDATASIZE and RETURNDATACOPY")
최종 확정 · EVM·실행 · 원문 상태 Final · 2017-02-13 작성
컨트랙트 호출이 끝난 뒤 반환 데이터를 가상 버퍼에 보관하고 그 크기를 읽는 RETURNDATASIZE와 내용을 메모리로 복사하는 RETURNDATACOPY 두 옵코드(opcode)를 EVM에 추가합니다.
왜 필요한가
호출이 끝나기 전에는 상대 컨트랙트가 돌려줄 데이터의 크기를 알 수 없는 경우가 있습니다. 기존 EVM에서는 호출하는 쪽이 반환 데이터를 받을 메모리 영역을 미리 지정해야 해서 크기를 모르면 데이터를 온전히 받을 방법이 없었습니다. 크기만 먼저 계산하는 호출을 한 번 더 하는 우회도 가능하지만 상황에 따라 비용이 매우 큽니다. 대표적인 예가 받은 호출 데이터를 검사한 뒤 다른 컨트랙트로 그대로 넘기는 범용 포워딩 컨트랙트입니다. 이런 컨트랙트는 자신이 호출할 상대를 미리 알지 못하므로, 상대 컨트랙트를 고치거나 호출을 여러 번 반복하지 않고는 반환 데이터 크기를 알아낼 길이 없습니다. 이전에 나온 비슷한 제안들은 호출 옵코드에서 가스를 미리 떼어 두어야 해서 명세와 구현이 복잡했습니다. 이 제안은 이미 있는 콜데이터(calldata) 처리 방식을 그대로 따라가므로 가스 계산이 단순하고 기존 코드와 100% 호환됩니다.
무엇이 바뀌나
지금
- 호출하기 전에 반환 데이터를 받을 메모리 영역과 크기를 미리 정해야 합니다
- 반환 크기를 모르면 크기 계산용 호출을 따로 하는 등 비싼 우회가 필요합니다
- 범용 포워딩 컨트랙트는 상대 컨트랙트의 반환 데이터를 온전히 전달할 방법이 없습니다
EIP-211 이후
- 호출이 끝난 뒤 RETURNDATASIZE로 실제 반환 크기를 확인합니다
- RETURNDATACOPY로 반환 데이터를 필요한 만큼 메모리에 복사합니다
- 길이를 미리 알 수 없는 반환 데이터도 호출 한 번으로 받습니다
누구에게 영향이 있나
- 스마트 컨트랙트 개발자: 가변 길이 데이터를 반환하는 함수와 포워딩(프록시) 패턴을 추가 호출 없이 구현할 수 있습니다.
- 컴파일러 구현자: 반환 크기를 모르는 호출에는 0길이 영역을 예약해 두고 RETURNDATASIZE와 RETURNDATACOPY로 데이터를 가져오는 패턴이 권고됩니다.
- 클라이언트 팀: 콜 프레임마다 반환 데이터 버퍼를 두고 두 옵코드를 구현해야 합니다. 버퍼는 콜 프레임 간에 공유하는 최적화가 가능합니다.
심층 설명
반환 데이터 버퍼의 동작 규칙
EVM의 각 콜 프레임에 가변 크기의 내부 버퍼인 반환 데이터 버퍼가 있다고 가정합니다. 버퍼는 새 콜 프레임마다 비어 있는 상태로 만들어집니다. CALL, CREATE, DELEGATECALL처럼 새 콜 프레임을 만드는 옵코드(원문 표현으로 call-like opcode)를 실행하면 버퍼가 먼저 비워지고(크기 0), 실행이 끝나면 호출한 쪽의 버퍼에 반환 데이터(또는 EIP-140의 실패 데이터) 전체가 저장되며 크기도 그에 맞게 바뀝니다. 예외로 CREATE와 CREATE2는 성공 시 빈 버퍼를 반환한 것으로 보고, 실패 시에만 실패 데이터를 담습니다. 잔액 부족으로 송금이 안 되거나 호출 대상 컨트랙트가 없어서 콜 프레임이 실제로 만들어지지 않으면 버퍼는 빈 상태가 됩니다. 어느 시점이든 비어 있지 않은 버퍼는 최대 하나이므로, 구현은 버퍼를 콜 프레임 간에 공유하는 최적화를 할 수 있습니다.
RETURNDATASIZE (0x3d)
반환 데이터 버퍼의 크기를 스택에 올립니다. 가스 비용은 2로, CALLDATASIZE와 같습니다.
RETURNDATACOPY (0x3e)와 범위 초과 처리
CALLDATACOPY와 비슷하게 동작하되, 콜데이터가 아니라 반환 데이터 버퍼에서 데이터를 복사합니다. 범위 초과 접근의 처리가 중요한 차이입니다. start + length가 오버플로하거나 RETURNDATASIZE보다 크면 현재 호출은 out-of-gas 조건으로 중단됩니다. 버퍼 끝에서 0바이트를 읽는 것은 허용되지만, 버퍼를 1바이트라도 벗어난 위치에서 0바이트를 읽으려 하면 예외가 발생합니다. 가스 비용은 3 + 3 * ceil(amount / 32)로 CALLDATACOPY와 같습니다. 두 옵코드의 번호는 CALLDATASIZE, CALLDATACOPY가 있는 니블 블록 안에 배정했습니다.
왜 버퍼 방식인가
동적 길이 반환을 허용하는 다른 해법들도 검토됐지만 모두 호출 옵코드에서 가스를 미리 떼어 두어야 해서 명세와 구현이 복잡했습니다. 이 제안은 콜데이터를 다루는 기존 방식과 매우 비슷해서 EVM의 개념 체계에 자연스럽게 들어맞습니다. eWASM 아키텍처도 이미 반환 데이터를 정확히 같은 방식으로 처리합니다.
구현 비용과 메모리 사용
EVM 구현은 다음 호출이나 현재 호출의 반환 때까지 반환 데이터를 보관해야 합니다. 이 자원은 호출된 쪽의 메모리로 이미 가스가 지불된 것이어서 추가 부담이 되지 않습니다. 구현은 다음 호출까지 호출된 쪽의 메모리 전체를 살려 두거나, 반환 데이터만 별도 메모리 영역으로 복사하는 방식 중에서 선택할 수 있습니다. 호출된 쪽의 메모리를 다음 call-like 옵코드까지 유지해도 최대 메모리 사용량은 늘지 않습니다. 호출 반환 이후에 일어나는 호출자 프레임의 메모리 할당은 가스 비용 변화 없이 호출 앞으로 옮길 수 있고 그 할당이 어차피 최대 할당량에 포함되기 때문입니다.
EIP-140(REVERT)과의 연계
의도적인 상태 되돌림에서 데이터를 반환하게 하는 EIP-140을 훨씬 유용하게 만듭니다. 실패 데이터는 일반 반환 데이터보다 크거나 크기를 아예 모를 수 있는데, 이 제안이 있으면 CALL 옵코드가 실패를 알린 뒤에도 미리 잡아 둔 출력 영역이 모자라든 말든 실패 데이터를 버퍼에서 꺼내 읽을 수 있습니다.
적용 시점과 호환성
block.number >= BYZANTIUM_FORK_BLKNUM 조건으로 활성화됩니다. 즉 비잔티움(Byzantium) 하드포크부터 적용된 규칙입니다. 새 옵코드 두 개를 추가하는 것 외에는 기존 동작을 전혀 바꾸지 않아 완전히 하위 호환됩니다. 이 EIP의 상태는 Final로, 이미 확정되어 네트워크에 반영된 명세입니다.