Solidity Assembly & ABI Encoding

4000D
Tokamak Network
Published in
12 min readAug 7, 2018

Carl Park(4000D, github)

Solidity의 Assembly(이하 Assembly)는 Solidity에서 EVM의 low level 연산을 수행할 수 있도록 도와줍니다. msg.data 을 접근하거나, 특정 어카운트(컨트랙)의 code 를 복사하거나 MLOAD , MSTORE , SLOAD , SSTORE 를 통해 메모리 혹은 스토리지에 직접 값을 읽고 쓰는 것이 가능합니다. 이번 글에서는 Assembly 와 함수의 인자를 인코딩하는 ABI Encoding 을 설명하겠습니다. (EVM에 대한 사전 이해가 다소 요구될 수 있습니다.)

Ethereum Opcodes

우선 공식 문서에 어떤 명령어들을 사용할 수 있는지 설명되어있습니다. opcodes에 대한 설명은 이더리움 황서 Appendix 에도 유사하게 설명되어있습니다. (PUSH 등 변수를 할당하는 부분은 Assembly에 없습니다.) EVM은 32바이트 단위로 연산을 처리하기에 1 워드 = 32 바이트 입니다.

  • PUSH1~PUSH32: 스택의 맨 위(top)에 1~32 바이트를 추가합니다.
  • DUP1~DUP16: 스택의 1~16번째 값을 복사해 스택의 top에 추가합니다.
  • SWAP1~SWAP16: 스택의 2~17번째 값과 스택의 top의 값을 교환합니다.
  • MLOAD(p): 메모리의 특정 위치(p)에 있는 32바이트(1 워드)를 읽습니다. OPCODE는 이를 스택의 top에 저장하고, Assembly에서는 변수에 저장되거나 다른 연산의 피연산자로 사용될 수 있습니다. (expression)
  • MSTORE(p, v): 메모리의 특정 위치(p)에 v를 저장합니다.
  • SLOAD(p): MLOAD와의 다른 점은 어카운트의 스토리지를 읽는다는 것입니다. 스토리지의 자료구조는 Trie 이며 (key, value) 쌍을 저장합니다. 여기서 key는 해당 value를 찾기 위한 Trie의 path (p)로 사용됩니다.
  • SSTORE(p, v): 스토리지 Trie의 path pv를 저장합니다.
  • JUMP / JUMPI: PC를 이동하여 다른 bytecode 를 실행합니다. JUMPI는 conditional jump를 의미합니다.
  • CALLDATALOAD(p): msg.data의 p번째 워드를 읽습니다.
  • CALLDATASIZE: msg.data의 길이를 반환합니다. 이 때 단위는 “바이트" 입니다.
  • CALLDATACOPY(t, f, s): msg.dataf번째 위치에서 s개의 바이트를 읽어 메모리의 t번째 위치에 저장합니다.
  • EXTCODESIZE(a): 어카운트 a 의 코드 사이즈를 반환합니다. CODESIZE 는 현재 실행 환경의 컨트랙트을 대상으로 합니다. (a = this)
  • EXTCODECOPY(a, t, f, s): 어카운트 a 의 bytecode 를 복사합니다. t, f, s는 CALLDATACOPY와 동일한 의미를 가집니다.
  • RETURNDATASIZE: 가장 마지막 리턴 값의 크기를 반환합니다.
  • RETURNDATACOPY(t, f, s): 가장 마지막 리턴 값을 메모리에 복사합니다. 이 opcode가 중요한 이유는 Solidity의 <address>.delegatecall(bytes) 혹은 Assembly의 DELEGATECALL 은 리턴 값이 true 혹은 false 이기 때문에 함수 반환값에 대한 접근을 못하는데 해당 opcode는 가능하기 때문입니다. Aragon의 구현체를 참조하세요.
  • CALL(g, a, v, in, insize, out, outsize): 다른 컨트랙의 함수를 호출합니다. (황서에선 Message-call into an account라고 표현합니다.) a는 대상 어카운트 주소, g는 가스량, vmsg.value 에 대응합니다. inmsg.data값의 메모리상의 위치이며 insize는 해당 인풋의 길이입니다. 메모리의 in, ..., in + insize — 1 위치의 값이 인풋(msg.data)으로 사용됩니다. 만약 위 함수가 값을 반환한다면 메모리의 out, ..., out + outsize — 1 에 해당 반환값을 저장합니다.
  • DELEGATECALL(g, a, in, insize, out, outsize): CALL과 동일하나, context(msg.sender, msg.value, this)가 변하지 않습니다. this 가 변하지 않는다는 것은 a 의 스토리지를 변경할 수 없다는 것입니다. DELEGATECALL 의 반환 값은 true / false 이지만 RETURNDATA 관련 opcode를 통해 리턴 값을 읽을 수 있습니다.

Assembly 에 대한 문법이나 언어의 특성들은 공식 문서에 충분히 설명하기에 문서를 참조하면 될 것 같습니다. Assembly 코드를 처음 봤을때 맞닥뜨린 낯선 어려움은 단순히 연산자들의 이름에서 오지 않았습니다. 이를 해소하기 전에 잠시 Solidity의 내용을 다시 한번 살펴봅시다.

Data Location

데이터는 스토리지 혹은 메모리에 저장될 수 있습니다. Solidity의 기본적인 자료형 uint8, …, uint256 , bool , bytes1, …, bytes32 들은 모두 32바이트에 담을 수 있습니다. 위 자료형들은 메모리에서 충분히 저장할 수 있습니다. 따라서 위 타입의 지역 변수들은 항상 메모리에 위치하고 상태 변수들은 항상 스토리지에 위치합니다. 이는 함수의 내부에서 아래와 같은 지역변수 선언이 불가능함을 의미합니다.

function t() public {
uint256 i storage = 0xdead;
}

혹은 아래와 같은 상태변수 선언도 불가능합니다.

contract T {
uint256 memory i = 0xdead;
}

하지만 복잡한 자료형(array, struct)는 메모리 또는 스토리지에 위치할 수 있습니다. (예외적으로 mapping 은 항상 스토리지에 위치해야합니다. mapping은 hash table 을 Trie에 저장한 것인데, Trie는 메모리에서 처리할 수 없기 때문입니다.) 그리고 이러한 자료형들을 Reference type이라고 부릅니다. 이는 변수의 값은 데이터 자체가 아닌 데이터가 저장된 메모리의 위치를 가리킵니다. 따라서 Solidity가 아닌 Assembly 에서 배열의 특정 인덱스에 접근해야 할 경우 MLOAD혹은 SLOAD를 사용해야합니다. 주로 배열을 메모리에 올린 상태에서 사용하는 경우가 많기 때문에 앞으로 MLOAD만 사용하겠습니다. (만약 SLOAD 를 통해 배열에 직접 접근하고 싶으면 Trie의 path를 직접 계산해야합니다.)

자료형 T에 대한 T[]뿐만 아니라 bytes , string 또한 배열로 취급합니다. 특히 uint[4] 같은 정적인 크기의 배열과 다른 점은 (당연하지만) Length 필드가 필요하다는 점입니다. 지금은 길이가 정해진 배열들은 Length 필드를 가지지 않지만, 추후 동적 배열과의 호환성을 위해 추가될 예정입니다.

이러한 배열들은 앞에서 말했듯이 Assembly 에서 pointer 로 쓰입니다. 아래 컨트랙 코드를 보면서 설명하겠습니다.

https://ethfiddle.com/yNi7CmZUnk

pragma solidity 0.4.24;contract AssemblyTest {
uint[] d = [1,2,3];
uint[3] s = [1,2,3];

function d_1() public view returns (uint r) {
uint[] memory arr = d;
assembly {
r := arr
}
}

function d_2() public view returns (uint r) {
uint[] memory arr = d;
assembly {
r := mload(arr)
}
}

function s_2() public view returns (uint r) {
uint[3] memory arr = s;

assembly {
r := mload(arr)
}
}
}

상태 변수 d, s는 각각 동적 배열,정적 배열입니다. 각 함수들은 상태 변수들을 지역 변수로 불러온 후 Assembly 연산을 수행하여 이를 곧바로 리턴하고 있습니다.

  • d_1: arr 을 곧바로 r 에 할당하였습니다. 이 때 함수의 반환 값은 128 (= 0x80) 입니다. 이는 free memory pointer0x80 에서 시작하기 때문입니다. arr 은 컨트랙트가 시작되는 최초의 free memory pointer(mload(0x40)) 에 위치하기 때문에 Assembly에서 arr변수 또한 그와 동일한 0x80입니다.
메모리의 3번째 word [0x40, …, 0x5f]는 다음에 사용할 수 있는 메모리의 위치를 가리킵니다.
Free memory pointer는 컨트랙트가 실행 직후 지정됩니다. 그 다음엔 Function Selector와 동일한 함수 선언부를 찾아 실행합니다.
  • d_2: r := mload(arr) 을 통해 arr 변수가 가리키는 메모리의 값을 r에 할당합니다. 동적 자료형은 첫 32바이트를 통해 Length 필드를 저장합니다. 따라서 d_2는 상태변수 d의 길이를 반환합니다. 따라서 arr[i]에 접근하기 위해서 mload(arr + 0x20 + 0x20 * i) 를 사용해야 합니다.
    (물론 Assembly는 arithmetic operator 를 지원하지 않기 때문에 mload(add(arr, add(0x20, mul(0x20 * i)))) 를 사용해야 합니다. 보통 반복문을 사용하지 않기 때문에 위치를 하드코딩하여 mload(add(arr, 0x40)) 과 같은 방식으로 직접 접근합니다.)
  • s_2: d_2와 동일한 기능을 상태변수 s에 대해 수행합니다. 정적 배열는 (아직) Length 필드를 포함하지 않기 때문에 s[0]을 반환합니다. arr[i]에 접근하기 위해서 mload(arr + 0x20 * i) 를 사용해야 합니다.

Examples

loomnetwork/plasma-cash의 Sparse Merkle Tree 구현체를 살펴보도록 하겠습니다.

omisego/plasma-mvp 에서 사용하는 RLP 라이브러리 또한 Assembly 를 이용하고 있습니다. 간단한 형변환 함수들을 살펴보도록 하겠습니다.

ABI Encoding

TX를 보내 컨트랙트의 특정 함수를 호출시킬 경우 TX의 data 필드에 실행시킬 함수와 관련 파라미터를 지정합니다. ABI 스펙Function Selector파라미터 인코딩 규칙을 정의합니다. Function Selector 는 함수명과 인자의 자료형을 keccak256 한 결과의 앞 4바이트입니다. 가령 function foo(uint128,bytes32,bool) 함수는 bytes4(keccak256("foo(uint128,bytes32,bool[2])")) = 0x5c8d6296 가 해당 함수의 선택자입니다.

파라미터는 자료형이 정적인지, 동적인지에 따라 결정됩니다. 정적인 자료형(primitive type, statically-sized array)인 경우 해당 값들이 32바이트로 패딩됩니다. 가령 function foo(uint128,bytes32,bool[2]) 함수에 대해 파라미터가 (0xdead, 0xddeeaadd, [true, false]) 일 경우

0x5c8d6296
0000000000000000000000000000000000000000000000000000000000dead
ddeeaadd000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000001
00000000000000000000000000000000000000000000000000000000000000

로 인코딩 됩니다. bytes 또는 string 들은 right-padded 되고, 그 밖의 값들은 left-padded 됩니다. 여기서 bool[2] 자료형은 길이가 정해져있기 때문에 1과 0이 순차적으로 표기됩니다.
(Padding은 데이터의 길이를 맞추기위해 빈 값(0)을 채워넣는다는 의미입니다. 이더리움에서는 1워드 = 32바이트 이기 때문에 항상 32바이트의 길이를 가져야하지만, 자료형에 따라 적은 값을 가질 수 있습니다. 이 때 0이 붙는 위치가 왼쪽인지 오른쪽인지는 자료형에 따라 다릅니다.)

동적인 자료형들에 대해서는 아래와 같은 규칙을 적용합니다.

  1. 해당 데이터가 실제로 위치한 곳을 표시합니다.
  2. 1)에서 포인팅한 곳에는 데이터의 첫 32바이트는 해당 데이터의 첫 길이 입니다.
  3. 2)의 32바이트 이후 실제 값들이 순차적으로 들어갑니다.

이는 Assembly 에서 동적인 자료형을 취급하는 방식과 동일합니다.
1)은 arr 변수가 메모리의 위치를 포인팅 한다는 점에서,
2)는 mload(arr)arr[0] 이 아닌 arr.length 이 저장되어있다는 점에서,
3)은 mload(arr + 0x20 + 0x20 * i) 를 통해 arr[i] 에 접근할 수 있다는 점에서 동일합니다.

function bar(uint8, bytes3[], bool[]) 함수에 대해서 인자가 (0xff, ["aaa", "bbb"], [true, true, false]) 일 경우 아래와 같이 인코딩 됩니다.

0x600xC0은 각각 4번째 워드, 7번째 워드를 가리킵니다.

이러한 ABI 인코딩은 TX보낼 때, 다른 어카운트의 함수를 호출할 때 사용됩니다. 또한 Solidity의abi.encodePacked 함수를 이용하여 직접 인코딩할 수 도 있습니다. 해당 함수의 다른 용도는 bytes32 값 2개를 하나의 bytes 로 합치는데 사용되기도 합니다. (참고) 메시지 콜을 보낼 때 동일한 방식으로 msg.data 부분이 인코딩됩니다.

마치며

Assembly 와 ABI Encoding 은 서로 관련이 없어 보이지만, 동적인 자료형을 취급하는 방식은 동일합니다. Assembly는 특히 bytes 자료형의 값을 사전에 정의한 방식(RLP, Plasma의 Transaction)으로 전달하여 이를 Solidity에서 파싱할 수 있도록 도와줍니다. 또한 bytes에서 bytes32, uint256, uint64 등 다른 자료형으로 형변환 하는 것도 간결합니다.

--

--