본문으로 건너뛰기
0%

Yul 컨트랙트 호출은 어떻게 작동하는가?

Mark Jonathas headshot

작성자 Mark Jonathas

2023년 8월 1일에 게시됨6분 읽기

Yul스마트 컨트랙트 내부에서 일종의 어셈블리 언어를 작성하는 데 사용할 수 있는 중간 수준 프로그래밍 언어입니다. 이제 문법, 스토리지, 그리고 Yul에서 메모리가 어떻게 동작하는지 이해했으니, 컨트랙트를 호출할 차례입니다!

Yul에서 컨트랙트 호출은 어떻게 동작하는가?

이 시리즈의 마지막 섹션에서는 Yul에서 컨트랙트 호출이 어떻게 동작하는지 살펴봅니다. 예제를 살펴보기 전에 몇 가지 Yul 연산을 더 배워야 합니다. 살펴보겠습니다.

Instruction
Explanation

gas()

실행에 아직 사용 가능한 가스량

gasPrice()

트랜잭션의 가스 가격

address()

현재 컨트랙트의 주소

balance(a)

주소 a의 이더 잔액 (wei 단위)

selfbalance()

balance(address())를 호출하는 것과 동일하지만 약간 더 저렴함

caller()

컨트랙트를 호출한 주소 (msg.sender)

origin()

트랜잭션을 시작한 주소 (tx.origin)

callvalue()

컨트랙트 호출 시 전송된 이더의 양 (wei 단위, msg.value)

calldataload(p)

위치 p에서 시작하는 calldata (32바이트 데이터만)

calldatacopy(t, f, s)

calldata가 메모리 위치 t로 복사됩니다. calldata의 위치 f부터 시작하여 s바이트만큼의 데이터를 복사합니다.

extcodesize(a)

주소 a에 있는 코드의 크기

returndatasize()

마지막 returndata의 크기

returndatacopy(t, f, s)

위치 f의 return data에서 s바이트를 메모리의 위치 t로 복사

timestamp()

초 단위의 현재 블록 타임스탬프

number()

현재 블록 번호

call(g, a, v, in, insize, out, outsize)

주소 a에 있는 컨트랙트를 gas g로 호출하며, v wei를 msg.value로 전달하고, 메모리 위치 in - insize에서 tx.data를 전달하며, 반환 데이터를 메모리 위치 out - outsize에 저장합니다. 호출이 성공하면 1을, 그렇지 않으면 0을 반환합니다.

delegatecall(g, a, in, insize, out, outsize)

call()과 유사합니다. 주요 차이점은 delegatecall()이 호출하는 컨트랙트가 아닌 호출자 컨트랙트의 상태 변수를 업데이트한다는 점입니다. 프록시 컨트랙트에 자주 사용됩니다. 의도하지 않은 스토리지 변수를 덮어쓰지 않으려면 호출하는 컨트랙트와 스토리지 레이아웃이 동일해야 한다는 점에 유의해야 합니다. 매개변수 v가 없다는 점에 유의하세요. delegatecall()로는 이더를 전송할 수 없습니다.

staticcall(g, a, in, insize, out, outsize)

call()과 유사합니다. 다만 블록체인 상태를 변경하는 컨트랙트(즉, pure 및 view로 표시된 함수)를 호출하는 데는 사용할 수 없습니다. 매개변수 v가 없다는 점에 유의하세요. staticcall()로는 이더를 전송할 수 없습니다.

이제 이 예제들을 위한 새로운 컨트랙트를 살펴보겠습니다. 먼저 우리가 호출할 컨트랙트를 살펴보겠습니다.

이 컨트랙트에는 각각 스토리지 슬롯 1과 2에 저장된 var1과 var2라는 두 개의 스토리지 변수가 있습니다.

함수 a()는 사용자가 컨트랙트에 최소 1 이더를 보낼 것을 요구하며, 그렇지 않으면 되돌립니다(revert).

다음으로, 함수 a()는 var1과 var2를 업데이트하고 이를 반환합니다.

함수 b()는 단순히 var1과 var2를 읽어 반환합니다.

CallMe 컨트랙트를 호출하는 우리의 컨트랙트로 넘어가기 전에, 함수 선택자(function selector)를 이해하는 데 시간을 좀 들여야 합니다.

트랜잭션 0x773d45e000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002의 다음 calldata를 살펴보겠습니다.

calldata의 첫 4바이트는 함수 선택자(0x773d45e0)라고 불립니다. 이것이 EVM이 호출하려는 함수를 알아내는 방법입니다. 함수 시그니처 문자열의 해시에서 첫 4바이트를 가져와 함수 선택자를 도출합니다.

따라서 함수 a()의 시그니처는 a(uint256,uint256)이 됩니다.

이 문자열의 해시를 구하면: 0x773d45e097aa76a22159880d254a5f1db8365bc2d0f0987a82bda7dfd3b9c8aa이 나옵니다.

첫 4바이트를 보면 0x773d45e0과 같음을 알 수 있습니다.

시그니처에 공백이 없다는 점에 유의하세요. 공백을 추가하면 완전히 다른 해시가 나오기 때문에 이는 중요합니다. 우리의 코드 예제에서는 선택자를 직접 구할 필요가 없으며, 제가 제공하겠습니다.

먼저 스토리지 레이아웃을 살펴보겠습니다.

var1과 var2가 CallMe 컨트랙트와 동일한 레이아웃을 가지고 있음을 확인하세요. delegatecall()이 제대로 작동하려면 다른 컨트랙트와 레이아웃이 동일해야 한다고 제가 말씀드렸던 것을 기억하실 겁니다.

이러한 요구사항을 충족하면서도, 새로운 변수(selectorA & selectorB)가 끝에 추가되는 한 다른 변수를 가질 수 있습니다. 이는 스토리지 충돌을 방지합니다.

이제 첫 번째 컨트랙트 호출을 만들 준비가 되었습니다.

Yul에서 staticcall() 사용하는 방법

간단한 것부터 시작해보겠습니다, staticcall()입니다. 다음은 우리의 함수입니다:

여기서 일어나는 일은 다음과 같습니다:

  • 슬롯 2를 로드하여 스토리지에서 b()의 함수 선택자를 가져옵니다(두 선택자 모두 하나의 슬롯에 패킹되어 있습니다)
  • 4바이트(32비트) 오른쪽으로 시프트하여 selectorB를 분리합니다
  • 함수 선택자를 메모리의 스크래치 공간에 저장합니다
  • static call을 만듭니다
  • gas()를 전달합니다(가스량을 직접 지정할 수도 있습니다)
  • 컨트랙트 주소로 매개변수 _callMe를 전달합니다
  • 함수 호출이 성공했는지 확인하고, 그렇지 않으면 데이터 없이 반환합니다
  • 메모리에서 데이터를 반환하고 값 1과 2를 확인합니다

참고: 0x1c와 0x20은 스크래치 공간에 저장한 것의 마지막 4바이트를 전달하고자 함을 나타냅니다. staticcall()의 마지막 두 매개변수는 반환 데이터를 메모리 위치 0x80 - 0xc0에 저장하고자 함을 지정합니다.

Yul에서 call() 사용하는 방법

다음으로 call()을 살펴보겠습니다. CallMe에서 함수 a()를 호출할 것입니다. 컨트랙트에 최소 1 이더를 보내는 것을 잊지 마세요! 이 예제에서는 _var1과 _var2로 3과 4를 전달하겠습니다.

코드는 다음과 같습니다:

이전 예제와 마찬가지로 slot2를 로드해야 합니다. 하지만 이번에는 selectorB를 마스킹하여 selectorA를 분리할 것입니다.

이제 0x80에 선택자를 저장하겠습니다.

calldata에서 매개변수가 필요하므로 calldatacopy()를 사용하겠습니다. calldatacopy()에 다음을 지시합니다:

  • calldata를 메모리 위치 0xa0에 저장
  • 처음 36바이트를 건너뜀
  • calldata의 크기에서 36바이트를 뺀 만큼을 저장

참고: 건너뛰는 처음 4바이트는 callA()의 함수 선택자이며, 다음 32바이트는 callMe의 주소입니다.

이제 컨트랙트 호출을 만들 준비가 되었습니다!

지난번과 마찬가지로 gas()와 _callMe를 전달합니다. 하지만 이번에는 0x9c(0x80 메모리 시리즈의 마지막 4바이트) - 0xe0에서 calldata를 전달하고, 데이터를 메모리 위치 0x100 - 0x120에 저장합니다.

다시 한번 호출이 성공했는지 확인하고 출력을 반환합니다. CallMe 컨트랙트를 확인하면 값이 성공적으로 3과 4로 업데이트되었음을 알 수 있습니다.

무슨 일이 일어나는지 더 명확히 하기 위해, 반환 직전의 메모리 레이아웃은 다음과 같습니다:

Memory Location
Value Stored

0x00

스크래치 공간 (비어 있음)

0x20

스크래치 공간 (비어 있음)

0x40

0x80 (Yul에서는 모든 작업이 수동으로 이루어지므로 Free Memory Pointer를 업데이트하지 않았습니다.)

0x60

비어 있음

0x80

0x0000000000000000000000000000000000000000000000000000000000773d45e0 (함수 선택자)

0xa0

0x0000000000000000000000000000000000000000000000000000000000000003 (_var1)

0xc0

0x0000000000000000000000000000000000000000000000000000000000000004 (_var2)

0xe0

비어 있음

0x100

0x0000000000000000000000000000000000000000000000000000000000000003 (첫 번째 반환값)

0x120

0x0000000000000000000000000000000000000000000000000000000000000004 (두 번째 반환값)

0x140

Free Memory Pointer (비어 있음)

Yul에서 delegate call 사용하는 방법

마지막 섹션에서는 delegatecall()을 살펴보겠습니다. 코드는 딱 한 가지 변경 사항을 제외하고는 call()과 거의 동일해 보일 것입니다.

우리가 변경한 유일한 부분은 call()을 delegatecall()로 바꾸고 callvalue()를 제거한 것입니다.

callvalue()가 필요하지 않은 이유는 delegate call이 CallMe의 코드를 자신의 상태 안에서 실행하기 때문입니다. 따라서 a()의 require() 문은 이더가 우리의 Caller 컨트랙트로 전송되었는지를 확인합니다. CallMe에서 var1과 var2를 확인하면 변경 사항이 없음을 알 수 있습니다. 하지만 Caller 컨트랙트의 var1과 var2는 성공적으로 업데이트되었습니다.

이것으로 컨트랙트 호출 섹션을 마칩니다! 다음으로, Yul에서 메모리, 스토리지, 스마트 컨트랙트 호출이 어떻게 동작하는지 알아보세요.

Background gradient

블록체인 매직을 만드세요

Alchemy는 가장 강력한 Web3 개발자 제품 및 도구를 리소스, 커뮤니티, 그리고 전설적인 지원과 결합합니다.