[Bytecode] 컨트랙트 함수 호출 트랜잭션 직접 만들기

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

Smart Contract

목록 보기
7/11
post-thumbnail

목표 : 라이브러리 도움없이 트랜잭션 데이터 만들기

메타마스크, Web3.js 쓰니까 아주 편했었지? 이제 좀 맞자.


1. 서론 : 당신은 트랜잭션을 만들 수 있는가?

이더리움에서 트랜잭션의 종류는 총 3가지다. (롤업 트랜잭션 제외)

  • 컨트랙트 생성 트랜잭션
  • 컨트랙트 함수 호출 트랜잭션
  • 이더 전송 트랜잭션

당신은 각각의 종류의 트랜잭션 데이터를 아는가?
뭔 개소리냐고?

{
  "to": "0x1234567890abcdef1234567890abcdef12345678",
  "from": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
  "gas": "0x5208",
  "gasPrice": "0x3B9ACA00",
  "value": "0x0",
  "nonce": "0x1",
  "data": "0xa3f32d2c000000000000000000000000000000000000000000000000000000000000000a
           ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
           0000000000000000000000000000000000000000000000000000000000000001
           000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcd
           0000000000000000000000000000000000000000000000000000000000000020
}

이 JSON 데이터를 적재적소에 맞게 직접 만들 수 있냐는 거다.

태어나서 처음본다고? 당연하다.

이더를 전송할 때 저런 데이터를 만들어서, 이더리움 노드에 HTTP-RPC 요청을 내가 왜해,
메타마스크에서 알아서 저런 데이터를 만들어서 특정 노드에 알아서 보낸다.

컨트랙트 생성이나 호출?
리믹스에서 딸깍으로도 가능하고

JS로 프론트나 백엔드에서 web3.js를 사용해서 휴먼답게 코드를 써서 보낼 수 있다.

2. 본론 : 메타마스크, web3.js, 리믹스가 없는 평행세계라면

이제 그런거 없다. 당신이 직접 풀노드의 HTTP-RPC 서버 URL에 POST 요청으로,
BODY에 JSON으로 transaction 데이터를 넣어줘야 한다.

요런식으로 말이다.
자, 저 데이터를 어떻게 넣어야 할까?

2-1. from,to,value,nonce,signature

from, to : 보내고 받는 주소
value : 전송 eth
nonce : from의 nonce
signature : from의 개인키로 암호화한 값

여긴 ㅈㄴ 쉽다. 사실 여긴 중요한 게 아냐.

2-2. data 인코딩

이 데이터 필드가 컨트랙트 함수 호출을 위한 필드다.
즉, to에는 컨트랙트 주소를 적고,

"이 함수에 이런 이런 파라미터를 넣어서 실행해주세요~"

즉, 함수명 + 파라미터 값
을 넣어야 할 것이다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract TestContract {
    function testFunction(
        uint256 number,
        int256 signedNumber,
        bool flag,
        address user,
        string memory text,
        bytes memory data,
        uint256[] memory numbers
    ) public pure returns (bool) {
        return true;
    }
}

위와 같은 함수가 있다. 저 함수에, 여러 파라미터를 넣어서 data를 어떻게 만들어야 하나?
data 필드는 4가지 필드로 순서대로 나눈다.

  • Function Selector
  • 정적 데이터
  • 포인터
  • 동적 데이터

Function Selector

함수를 특정하기 위한 식별자다.
위의 예시를 보면, 내가 부르고 싶은 함수 선언문은
testFunction(uint256, int256, bool, address, string, bytes, uint256[])
다.
이걸 통으로 Keccak256에 넣고, 앞 4바이트를 가져온다.
그래서 0xa3f32d2c다.

Arguments

위 함수는 인자가 7개다.

uint256 number,     // 정적 데이터
int256 signedNumber, // 정적 데이터
bool flag,          // 정적 데이터 (1바이트지만 32바이트 사용)
address user,       // 정적 데이터 (20바이트지만 32바이트 패딩)
string memory text, // 동적 데이터
bytes memory data,  // 동적 데이터
uint256[] memory numbers // 동적 데이터

정적 데이터는 각각, 32바이트 크기로 정렬이 된다.
저장이랑 헷갈리면 안된다. bool은 1비트지만 표현은 32바이트로 해야 된다.

또한 동적 데이터는,

  • 포인터 : 데이터 저장 위치 가르킴
  • 실제 데이터 : 길이 + 값 순서로 저장된다.

string인 text를 보자. 만약 "hello"를 넣고 싶다면, data로 변환하려면

포인터 : 잠시 기다려봐
문자열 길이 : 00000....5
hello 값 : 48656c6c6f00000....

다음 bytes인 data에 "hey"가 들어간다면

포인터 : 잠시 기다려봐
길이 : 000000....3
hey 값 : 6865792100000.....

마지막 uint256[]에 3개가 들어간다면
포인터 : 잠시 기다려봐
길이 : 3
값 : 배열 요소 1, 2,3을 각각 32바이트로 처리.

즉 포인터 떼고 3개의 변수가
string : 2줄
bytes : 2줄
uint256[] : 4줄

이걸 구하고 pointer의 위치를 각각 구할 수 있다.

종합하면 data는

0xa3f32d2c  // Function Selector

// 정적 데이터
000000000000000000000000000000000000000000000000000000000000000a  // uint256 number = 10
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff  // int256 signedNumber = -1
0000000000000000000000000000000000000000000000000000000000000001  // bool flag = true
000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcd  // address user

// 포인터 (Offset)
00000000000000000000000000000000000000000000000000000000000000e0  // text (string) 위치
0000000000000000000000000000000000000000000000000000000000000120	  // data (bytes) 위치
0000000000000000000000000000000000000000000000000000000000000160  // numbers (uint256[]) 위치

// 동적 데이터 (text)
0000000000000000000000000000000000000000000000000000000000000005  // 문자열 길이(5)
48656c6c6f000000000000000000000000000000000000000000000000000000  // "Hello"

// 동적 데이터 (data)
0000000000000000000000000000000000000000000000000000000000000004  // 바이트 길이(4)
6865792100000000000000000000000000000000000000000000000000000000  // "hey!"

// 동적 데이터 (numbers)
0000000000000000000000000000000000000000000000000000000000000003  // 배열 길이(3)
0000000000000000000000000000000000000000000000000000000000000001  // 배열 요소 1 (1)
0000000000000000000000000000000000000000000000000000000000000002  // 배열 요소 2 (2)
0000000000000000000000000000000000000000000000000000000000000003  // 배열 요소 3 (3)

이렇게 만들어지는 것이다.

OPCODE : gas 계산

함수를 실행했을 때, 실행되는 OPCODE의 가스비를 모조리 계산해서 좀 더 여유있게 넣어야 한다.
위 예시 함수는 pure 함수로, 스토리지에 저장도, 조회도 하지 않는다.
그렇다고 가스비가 발생 안하냐? 연산이 없냐? 그건 아니다.

  • CALLDATA(ABI 인코딩된 입력값 크기) : 4 + (CALLDATA 크기 / 32) * 16 EVM이 calldata를 읽는 비용
  • 스택 연산 (PUSH, DUP, SWAP 등) : 500~1,000 스택에서 인자를 읽고 함수 실행
  • 메모리 확장 비용 (MSTORE)(메모리 사용량에 따라 증가) : string, bytes, uint256[] 등 동적 데이터 저장
  • EVM 함수 호출 (JUMPDEST, JUMP) 8~10 per instruction EVM이 testFunction 실행
  • 리턴값 (RETURN) 700 return true 실행
  • 기본 트랜잭션 오버헤드 21,000 트랜잭션 기본 가스 비용

약 30000 이상의 가스가 든다.
Remix, Web3.js는 estimateGas()를 사용해 노드에게 연산 시뮬레이션을 돌려
OPCODE에 따른 가스비를 계산할 수 있다.
혹은 어차피 돌려 받으니까 넣을 수도 있는 것이다.

마무리

이제 우리는 라이브러리, 지갑 도움 없이 직접 트랜잭션 JSON 데이터를 만들어서 보낼 수 있다.
이제 JS 라이브러리를 만들어 볼까?

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

0개의 댓글