Code4rena 콘테스트에 참여하거나 리포트를 보다보면 assembly를 종종 볼 수 있다. 볼 때마다 아직도 정확하게 돌아가는 메커니즘을 이해하지 못해서 제대로 배워보려고 한다. 저번에 All about Assembly 글을 쓰면서 기초는 배웠지만 아직 그 이상을 이해하지 못하는 수준이다. 그러던 중 좋은 트윗을 발견했다. 해당 글을 따라가면서 학습과정을 기록하고자 한다.
contract AngleExplainsBase {
uint private secretNumber;
mapping(address => uint) public guesses;
bytes32 public secretWord;
// obviously this doesn't make sense
// but it will be fun to write it in assembly :D
function getSecretNumber() external view returns(uint) {
return secretNumber;
}
// this should only be set by an admin
// no access control because we want to keep it simple in assembly
function setSecretNumber(uint number) external {
secretNumber = number;
}
// a user can add a guess
function addGuess(uint _guess) external {
guesses[msg.sender] = _guess;
}
// yes i know... it doesn't make sense because you can change guesses for any user
// it's just to teach you how to parse arrays in assembly
function addMultipleGuesses(address[] memory _users, uint[] memory _guesses) external {
for (uint i = 0; i < _users.length; i++) {
guesses[_users[i]] = _guesses[i];
}
}
// this is useless since the `secretWord` is not used anywhere
// but this will teach us how to hash a string in assembly. Really cool! :)
function hashSecretWord(string memory _str) external {
secretWord = keccak256(abi.encodePacked(_str));
}
}
위 컨트랙트는 간단한 추첨 컨트랙트다. access control도 없고 secret number도 그냥 보여주는 허술한 컨트랙트다. 일부러 간단하게 만든 이유는 나중에 assembly로 바꿀 때 이해하기 쉽도록 만들었다고 한다.
assembly 코드에는 다양한 opcode가 있다. evm codes에 보면 잘 정리되어 있으니 참고하자.
uint private secretNumber;
//..
function getSecretNumber() external view returns(uint) {
return secretNumber;
}
getSecretNumber
는 secretNumber를 리턴하는 간단한 함수다. memory에 저장된 secretNumber를 가져오려면 SLOAD
를 사용하면 된다. EVM은 memory에 저장된 데이터만 리턴할 수 있다. 따라서 memory에 저장된 secretNumber를 가져오기 전에 memory에 해당 값을 저장해줘야 한다. 이러한 역할을 하는 opcodes는 MSTORE
다. 이제 위 함수를 assembly로 바꿔보자.
function getSecretNumber() external view returns(uint) {
assembly {
// 스토리지의 0번째 slot에 있는 secretNumber 값을 가져와서 변수에 할당한다.
// Yul에서는 `.slot`을 통해 변수 자체의 slot을 가져올 수 있다.
// `sload(secretNumber.slot)`과 같이 작성할 수 있다.
let _secretNumber := sload(0)
// "free memory pointer" -> 우리가 사용할 수 있는 메모리 주소
// MLOAD를 이용해 해당 메모리의 주소를 가져온다.
// 0x40 (64)에 저장된 값을 가져온다.
// 0x40은 고정적으로 free memory의 주소가 저장된 위치다.
let ptr := mload(0x40)
// MSTORE를 이용해 해당 위치에 secretNumber 값을 저장한다.
// mstore(저장할 주소 위치, 저장할 값)
mstore(ptr, _secretNumber)
// 마지막으로 값을 리턴한다.
// return(저장된 위치, 해당 인자의 크기)
// 값은 항상 32바이트로 저장되기 때문에 0x20이 온다.
return(ptr, 0x20)
}
}
좀 더 간결하게 아래처럼 작성할 수 있다.
// free memory pointer를 사용하는 대신 메모리의 0번째 슬롯에 값을 저장할 수 있다.
// 처음 2개의 슬롯은 "scratch space"로 사용되기 때문이다.
// "scratch space"에는 리턴값과 같은 임시값(temporary values)을 저장할 수 있다.
assembly {
let _secretNumber := sload(0)
mstore(0, _secretNumber)
return(0, 0x20)
}
이제 setSecretNumber
를 assembly로 바꿔보자.
function setSecretNumber(uint number) external {
secretNumber = number;
}
setSecretNumber
는 의외로 간단하다. 해당 변수가 저장된 slot을 불러오고, 해당 슬롯에 인자로 받은 값(_number
)을 저장하면 된다.
function setSecretNumber(uint _number) external {
assembly {
// We get the slot number for `secretNumber`
let slot := secretNumber.slot
// We use SSTORE to store the new value
sstore(slot, _number)
}
}
mapping(address => uint) public guesses;
//..
function addGuess(uint _guess) external {
guesses[msg.sender] = _guess;
}
addGuess
는 mapping을 이용한다. mapping은 key값과 슬롯 넘버를 합친다음 keccak256
을 통해 해싱하는 방식으로 동작한다. 현재 guesses는 slot 1에 저장돼있다.(0 번째는 secretNumber가 저장돼있으므로 그 다음 순서에 저장된다.) 따라서 솔리디티에서는 아래와 같은 방법으로 슬롯을 가져올 수 있다.
keccak256(abi.encode(msg.sender, 1))
Yul에서 해싱할 때는 우선 메모리에 값을 저장해야 된다. keccak256
은 메모리에만 접근할 수 있기 때문이다. 슬롯 넘버를 가져오는 것은 다음과 같은 순서로 진행된다.
msg.sender
주소를 가져온다.addGuess
를 아래처럼 assembly로 바꿀 수 있다.
function addGuess(uint _guess) external {
assembly {
// 우리가 저장할 슬롯 위치를 가져온다.(우리가 사용할 수 있는 메모리 주소)
let ptr := mload(0x40)
// caller()를 통해 msg.sender 주소를 불러온다.
// msg.sender 주소를 ptr에 저장한다.
mstore(ptr, caller())
// 그 다음, guesses의 슬롯 넘버를 저장한다.
mstore(add(ptr, 0x20), guesses.slot)
// 위 2개의 MSTORE는 abi.encode(msg.sender, 1)와 같다.
// msg.sender 주소와 guesses.slot 값을 해싱한다.
// 현재 2개의 값은 ptr에 저장되어 있고 총 2개의 슬롯을 사용한다.(2x 32bytes -> 0x40 = 64)
//keccak256의 두 번째 인자는 해싱할 데이터의 크기를 의미한다.
let slot := keccak256(ptr, 0x40)
// 이제 슬롯에 인자로 받은 값을 저장한다.
sstore(slot, _guess)
}
}
add(ptr, 0x20)
는 ptr로 부터 32바이트만큼 떨어진 위치를 나타낸다. 즉, ptr의 다음 메모리 슬롯을 의미한다. 솔리디티로 바꾸면 아래처럼 나타낼 수 있다.
ptr = ptr + 32
function hashSecretWord(string memory _str) external {
secretWord = keccak256(abi.encodePacked(_str));
}
값을 받아서 해싱하는 간단한 함수다. 더 진행하기 전에 간단히 알아두어야 할 것이 있다.
array
, mapping
, bytes
, string
은 EVM에서 다른 데이터 타입과 다르게 2개의 파트로 나뉘어서 저장된다. 첫 번째는 데이터의 길이 값이 저장되고 두 번째는 해당 데이터 값이 저장된다. 예를 들어 angel
이라는 값을 저장한다고 해보자. angel
의 길이 값인 5와 angel
이 같이 저장된다. 32바이트로 바뀌어서 저장되기 때문에 아래처럼 표현할 수 있다.
0000000000000000000000000000000000000000000000000000000000000005616e676c65000000000000000000000000000000000000000000000000000000
616e676c65(=angel)
앞에 길이 값인 5가 있는 것을 알 수 있다. 이제 assembly로 바뀐 함수를 살펴보자.
// computes the keccak256 hash of a string and stores it in a state variable
function hashSecretWord1(string memory _str) external view returns(bytes32) {
assembly {
// _str은 string을 가리키는 포인터다.
// 즉, string이 시작하는 메모리 주소를 가리킨다.
// _str에 string의 길이에 대한 정보가 있다.
// _str에 32를 더하면 string 정보가 있다.
// string의 크기값을 가져온다.
let strSize := mload(_str)
//_str에 32를 더해서, string 정보(값)을 가져온다.
let strAddr := add(_str, 32)
// string 주소와 크기를 인자로 넣어서 해싱한다.
let hash := keccak256(strAddr, strSize)
// 해싱 결과는 메모리의 0 번째 슬롯(temporary storage / scratch space)에 저장된다.
// 더 싸고 빠르기 때문에 free memory pointer를 사용할 필요가 없다.
mstore(0, hash)
// 0 번째 슬롯에 저장된 값을 리턴한다.(32는 데이터의 크기를 의미)
return (0, 32)
}
}
stablecoin
을 _str
로 넣어줬을 때 위와 같이 나타낼 수 있다. 0xc0은 stablecoin
다음 슬롯을 가리키는 free memory pointer다. 그런데 어떻게 맨 처음 위치(memory)에 값을 할당할 수 있었을까? 이전에 데이터가 저장되는 경우는 없을까? 답은 string memory _str
에 있다. 파라미터에 memory
를 사용할 때 EVM은 해당 파라미터가 사용할 공간을 미리 준비한다. 만약 memory
대신 calldata
를 사용한다면 이러한 방식을 사용하지 않기 때문에 가스비를 아낄 수 있다. 똑같은 함수를 calldata
를 사용해서 나타내면 다음과 같다.
// hash를 리턴하는 대신, secretWord 변수를 스토리지에 할당한다.
function hashSecretWord2(string calldata) external {
assembly {
// calldata는 함수가 호출될 때 컨트랙트에 들어오는 모든 데이터를 말한다.
// 첫 4 바이트는 함수를 나타내고, 나머지는 파라미터를 나타낸다.
// CALLDATALOAD를 사용하면 calldata로 부터 32바이트의 정보를 가져올 수 있다.
// calldataload(4)를 사용하면 함수를 나타내는 바이트를 스킵할 수 있다. 따라서 우리는 첫 번째 파라미터에 대한 정보를 가져온다.
// non-value types (array, mapping, bytes, string)을 사용할 때, 첫 번째 파라미터는 파라미터가 시작하는 offset이 된다.
// offset을 통해 파라미터의 길이 값과 value 값을 찾을 수 있다.
// calldataload(4) -> string이 시작하는 offset을 가져온다.
// signature bytes를 가져오기 위해 offset에 4를 더한다.
let strOffset := add(4, calldataload(4))
// offset에 calldataload()를 다시 해서 string 길이 값을 가져온다. (offset에 value 값이 저장되어 있음)
let strSize := calldataload(strOffset)
// free memory pointer를 가져오고
let ptr := mload(0x40)
// CALLDATACOPY를 이용해 free memory에 string 값을 복사한다.
// CALLDATACOPY(데이터를 붙여넣을 위치, 복사할 데이터의 위치, 데이터의 크기)
// string은 다음 메모리 슬롯에서 시작하므로 0x20을 더해준다.
calldatacopy(ptr, add(strOffset, 0x20), strSize)
// 이후 string을 해싱한다. (현재 string은 ptr에 저장되어 있음)
let hash := keccak256(ptr, strSize)
// 해싱 값을 storage에 저장한다.
sstore(secretWord.slot, hash)
}
}
offset
은 정보가 시작하는 위치를 말한다.
function myToken(string memory name, uint randomValue, address[] memory _addresses)
위 함수에 다음과 같은 인자를 넣어줬다고 해보자.
“angle”, 7, [“0x31429d1856aD1377A8A0079410B297e1a9e214c2”, “0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8”]
해당 calldata를 살펴보면 다음과 같다. 첫 번째 4바이트는 함수 시그니쳐를 나타낸다.
050eed26
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000007
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000005
616e676c65000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000002
00000000000000000000000031429d1856ad1377a8a0079410b297e1a9e214c2
0000000000000000000000001a7e4e63778b4f12a199c062f3efdd288afcbce8
0x04를 보면 60(0x60)이 있는데, 이는 string의 길이값이 저장되어있는 슬롯의 위치를 나타낸다. 즉 0x60에는 angel
의 글자 수인 5가 저장되어 있고, 그 다음에는 angel
을 의미하는 616e676c65
이 있다. 신기한 것은 파라미터의 순서 상 angel
이 먼저지만 슬롯에 저장되는 순서는 uint인 7이 먼저다.
표의 첫 번째 열은 함수 시그니쳐를 포함한 offset을 나타낸다. 따라서 파라미터의 정보를 나타내는 offset은 여기서 4를 더해야 한다.
이제 거의 다 왔다. 마지막 함수를 살펴보자.
function addMultipleGuesses(address[] memory _users, uint[] memory _guesses) external {
for (uint i = 0; i < _users.length; i++) {
guesses[_users[i]] = _guesses[i];
}
}
function addMultipleGuesses(address[] memory _users, uint[] memory _guesses) external {
assembly {
// `_users` array를 메모리로부터 가져오면 array의 크기 값을 가져올 수 있다.
// 크기 값에 32 bytes 이후에는 array의 value 값이 있다.
let usersSize := mload(_users)
// `_guesses`도 마찬가지
let guessesSize := mload(_guesses)
// 두 array가 같은 크기인지 비교
// eq는 값이 같으면 1을 리턴하고, 다르면 0을 리턴한다.
// iszero는 값이 0이면 1을 리턴하고, 0이 아니면 0을 리턴한다.
if iszero(eq(usersSize, guessesSize)) { revert(0, 0) }
// 배열의 item마다 for-loop 적용
// lt(a,b)는 a < b이면 1을 리턴, 아니면 0을 리턴
for { let i := 0 } lt(i, usersSize) { i := add(i, 1) } {
// array에서 index를 가져오면 32 (0x20)를 곱하고 `_users`에 더해준다.
// 항상 i에 1을 먼저 더해줘야 하는데, 우리가 받아오는 i는 크기를 나타내는 값이기 때문에 1을 더해줘서(결국 32바이트를 더해주는 것) 그 다음 바이트 값인 value 값을 가져와야 하기 때문이다.
let userAddress := mload(add(_users, mul(0x20, add(i, 1))))
let userBalance := mload(add(_guesses, mul(0x20, add(i, 1))))
// memory slot 0을 임시 저장 공간으로 사용
mstore(0, userAddress)
// `guesses`가 담겨있는 slot 번호를 가져오고
mstore(0x20, guesses.slot)
// 저장할 storage slot 번호 계산
let slot := keccak256(0, 0x40)
// 해당 storage slot에 값 저장
sstore(slot, userBalance)
}
}
}
아직 익숙하진 않아서 assembly 코드들을 많이 봐야할 것 같다. 그래도 조금씩 나아지고 있는 느낌이 든다!