[EVM] 마스터링 Call & Delegatecall

드림보이즈·2025년 3월 26일
0

Smart Contract

목록 보기
5/11
post-thumbnail

목표 : EVM 실행컨텍스트와 엮어서 Call, Delegatecall을 이해하자.


1. 예시로 이해하는 EVM 동작

당신이 블록체인 노드를 개발해야 된다. EVM을 직접 만들어야 돼.
상식적으로 생각하면 된다.

만약

"누군가 특정 컨트랙트의 특정 함수를 호출하는 트랜잭션을 보냈다."

상황을 가정하고, EVM의 동작 방식을 먼저 살펴보자.

1. 트랜잭션 데이터 파싱

{
  "to": "0x1234567890abcdef1234567890abcdef12345678", 
  "from": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
  "gas": "0x5208",
  "gasPrice": "0x3B9ACA00",
  "value": "0x0",
  "nonce": "0x1",
  "data": "0x552410770000000000000000000000000000000000000000000000000000000000000064"
}

여기서 우리가 주목할 건 to, data, gas!

2. StateDB에서 컨트랙트 조회

상태 DB에서 to인 주소를 찾아간다.
해당하는 컨트랙트의 값으로

{CodeHash, StorageRootHash, Balance, Nonce}

가 있을 것이다. 이들을 통해 Code, Storage를 불러올 수 있다.

3. Code, Storage 불러오기

State DB와는 별도의, Code Storage에서 바이트 코드를 불러와야 한다.
CodeHash를 Key로, 해당 컨트랙트의 런타임 코드를 겟또!

컨트랙트 마다 존재하는, Storage Trie에서 StorageRootHash를 사용한다.
MPT구조이기 때문에, 단순히 조회가 안되고,
keccak256(slot 번호)를 사용해 변수의 위치를 찾아야 한다.
그리고 해당 슬롯에서 변수 값을 겟또!

오케이, 이젠 우린 코드와 전역변수 준비완료.

4. 글로벌 변수 설정 : msg, tx, block, gasleft()

컨트랙트는 위와 같은 글로벌 객체에 접근할 수 있다는 걸 알 것이다.
EVM도 이를 대비해 트랜잭션으로 부터 msg,tx 객체를,
최신 블록을 불러와 block을 가져온다.

5. 스택, 메모리, pc 초기화

이건 뭐 별거 없다.

이러면 준비가 끝난 것이다.
코드를 실행하기 위한 사전 준비, 즉 실행 컨텍스트를 생성하는 과정이다.

이러면 트랜잭션 실행을 위한 EVM 세팅(인스턴스 생성)이 완료되었다.

존나 중요한 점은

트랜잭션마다 EVM 인스턴스가 생성된다!!!

라는 점이다. 한 트랜잭션을 위해서 새로 위와 같은 세팅을 한다는 것을 기억하자.


2. 다른 컨트랙트 호출 : send,call,callcode, delegatecall

2-1. Call

문제 발생시 false를 반환하는, 동일한 OPCODE와 대응하는,
컨트랙트 간 호출을 수동으로 구성할 수 있는 저수준 함수다.

contract Hi is Insa {
	constructor(address _faucet) {
		_faucet.call("withdraw", 0.1ether)
	}

new, 인터페이스, 컨트랙트 파일 가져와서 호출할 수도 있지만,
이렇게 무식하게 컨트랙트 주소만을 가지고 호출도 가능한 것을 알 것이다.

중요한 건 그게 아니다.

Call을 쓰면 EVM 인스턴스가 내부적으로 새로 생성된다!!!!!!!!


내가 아까 뭐랬는가? 트랜잭션 마다, EVM 인스턴스가 새로 생성된다고 했다.
만약 내가 트랜잭션 A를 실행했다면, 위와 같이 실행 컨텍스트를 생성해 EVM이 세팅된다.
만약 내가 일으킨 트랜잭션이, 다른 함수를 호출하는 call이 포함되었다면,
새로운 EVM 인스턴스가 내부적으로 생성된다는 것이다.

그러면 이런 의문이 들어야한다.

"그럼 내부적으로 실행컨텍스트 생성할 때, 위처럼 msg 객체, ,gas는 어디서 받아와야하나?"

외부 EVM에서 상속을 받거나, 정보를 바탕으로 다시 세팅된다.

  • gas : 부모가 사용하던 gas를 자식 EVM이 상속
  • tx : 부모와 같다.
  • msg.sender, msg.value : 컨트랙트 주소, call 파라미터(위에서 0.1이더)

이렇게 새로 EVM 인스턴스가 생성되는 것도 중요하지만, 더 중요한 것은,

내부 컨트랙트가 실행을 완료하기 전까지 외부는 아무고토 못한다!!!

이것을 이용한 보안문제가 "재진입성 문제"다.

컨트랙트 A에서 함수 a가

  • 컨트랙트 B로 이더 전송
  • 해당 컨트랙트 잔액 업데이트

순서대로 작성되었다면,
컨트랙트 B가

  • 돈 받으면 바로 a 실행

을 해놓으면, 잔액 업데이트는 실행되지 않고, 외부 EVM은 이더전송에 멈춰잇고,
내부 EVM에선 계속 돈 받으면서 무한 a 실행을 할 것이다.
내부적으로 계속 evm이 생성되니까 잔액 업데이트를 할 수가 없는 것이다.
몬느낌인지 알지?

자 정리,

call은 내부적으로 새로운 EVM 인스턴스 생성

이에 따라

  • gas는 외부 EVM 물려받음
  • tx는 같음
  • msg.sender, msg.value 변경
  • 내부적으로 EVM 생성돼 실행 마칠 떄 까지 외부 EVM 실행 못함.

2-2. delegatecall

delegatecall = 위임하여 호출, 대리 호출.

이제 감이 오는가? 본래 외부 컨트랙트를 호출하려면,
패시브가 새로운 EVM을 생성해야 된다.
가 원칙이다.
그런데 위임한다? 대리한다?

아, 외부 컨트랙트 호출은 하되, 새로운 EVM 인스턴스를 만드는게 아니라 기존 것을 고대로 쓴다!

정말 지리는 완벽한 빌드업이다.
이런식으로 가르쳐야지 무식하게 뭔 외우라고 하면 이해가 되겠냐? 제가 책 쓸게요 ㄹㅇ

거의 다 왔다. 주의할 점이 뭐가 있을까?
다른 컨트랙트를 호출하는데, EVM 세팅은 그대로라면?

contract B {
    uint256 public num;  // 🔹 storage slot #0
}

contract A {
    string public hi;    // 🔹 storage slot #0 (이전과 다른 타입)

    function delegatecallSetNum(address _contractB, uint256 _num) public {
        (bool success, ) = _contractB.delegatecall(
            abi.encodeWithSignature("setNum(uint256)", _num)
        );
        require(success, "Delegatecall failed");
    }
}

컨트랙트 A를 실행하고 있었다. delegatecall로 B의 변수를 변경하려고 한다.
EVM 실행 컨텍스트의 Storage에는 뭐가 세팅되어있는가? A의 스토리지 슬롯들만 있다.
그러면 실제로 변경되는건 B가 아니라? A의 슬롯 데이터다.
이걸 주의해야 한다.
msg.sender는 바뀌나? 그대로. tx?block? 다 그대로.

라이브러리 호출을 생각해보자.
라이브러리 객체는 전역변수가 없다. 오로지 함수만 있는데,
라이브러리를 호출할 때도 내부적으로 delegatecall을 사용한다.

마무리

profile
시리즈 클릭하셔서 카테고리 별로 편하게 보세용

0개의 댓글