Solidity · Chapter 1

Solidity 스마트 컨트랙트 함수 호출과 ABI 인코딩

작성자: Choi.eth

19 min read

Intro

EVM 기반 블록체인에서는 트랜잭션을 통해 스마트 컨트랙트의 함수를 호출할 수 있습니다. 그렇다면 트랜잭션은 어떤 함수를 실행해야 하는지, 그리고 해당 함수에 어떤 인수를 전달해야 하는지 어떻게 알 수 있을까요?

이 과정에서는 트랜잭션의 data 필드와 ABI(Application Binary Interface) 인코딩이 사용됩니다.

이번 글에서는 트랜잭션의 Payload 구조와 ABI 인코딩 방식을 살펴보겠습니다. 이후 Solidity의 저수준 함수 호출 방식인 call()을 활용하여 스마트 컨트랙트 간 함수 호출 과정을 알아보겠습니다.

1. 트랜잭션의 Payload

이더리움 트랜잭션에는 value와 data 필드가 존재하며, 이를 통해 이더를 전송하거나 스마트 컨트랙트의 함수를 호출할 수 있습니다.

  • value: 트랜잭션을 통해 전송할 ETH의 양을 Wei 단위로 지정
  • data: 스마트 컨트랙트의 함수 호출 정보나 컨트랙트 배포에 필요한 데이터를 포함

이 두 필드의 사용 여부에 따라 트랜잭션은 다음과 같은 형태로 구분할 수 있습니다.

구분 value data 용도
ETH 전송 있음 없음 일반적인 ETH 전송
컨트랙트 함수 호출 없음 있음 ETH 전송 없이 함수 실행
ETH 전송 및 함수 호출 있음 있음 ETH를 함께 전송하며 함수 실행

단, 실제 트랜잭션에서는 value가 0이거나 data가 비어 있을 수 있으며, 실행 결과는 수신 주소와 스마트 컨트랙트의 구현에 따라 달라집니다.

이번 글에서는 이 중 data 필드에 함수 호출 정보를 담아 스마트 컨트랙트의 함수를 실행하는 과정을 살펴보겠습니다.

2. Payload의 Data 필드

스마트 컨트랙트의 함수를 호출하려면 EVM이 어떤 함수를 실행해야 하는지 식별하고, 해당 함수에 필요한 인수를 전달해야 합니다.

이를 위해 이더리움에서는 ABI(Application Binary Interface)라는 규격을 사용합니다.

ABI는 스마트 컨트랙트의 함수와 외부 프로그램이 상호작용할 때 데이터를 인코딩하고 디코딩하는 방식을 정의합니다. 일반적인 함수 호출 데이터는 다음과 같이 구성됩니다.

  • Function Selector(4 bytes) : 호출할 함수 식별
  • Function Arguments(32 bytes) : 함수 파라미터 인코딩 값. 파라미터 당 32 bytes

2.1. Function Selector (함수 선택기)

Function Selector는 스마트 컨트랙트에서 호출할 함수를 식별하기 위한 4바이트 값입니다.

함수 이름과 인수의 타입으로 구성된 함수 시그니처(Signature)에 Keccak-256 해시 함수를 적용한 후, 결과의 첫 4바이트를 사용합니다.

예를 들어 다음과 같은 Solidity 함수가 있다고 가정하겠습니다.

function withdraw(uint amount) public {}

이 함수의 시그니처는 다음과 같습니다.

withdraw(uint256)

Solidity에서 uint는 uint256의 별칭이므로 ABI 함수 시그니처에서는 uint256을 사용합니다. 함수 시그니처에는 함수의 이름과 인수 타입만 포함되며, 인수 이름이나 접근 제어자 등은 포함되지 않습니다.

해당 시그니처에 Keccak-256을 적용하면 다음과 같은 해시값을 얻을 수 있습니다.

keccak256("withdraw(uint256)")

0x2e1a7d4d13322e7b96f9a57413e1525c250fb7a9021cf91d1540d5b69f16a49f

이 해시값의 첫 4바이트가 Function Selector입니다.

0x2e1a7d4d

따라서 Function Selector는 함수 시그니처를 Keccak-256으로 해싱한 결과의 첫 4바이트입니다.

2.2. Function Argument (함수 인수)

Function Selector가 호출할 함수를 식별한다면, Function Argument는 해당 함수에 전달할 값을 나타냅니다.

함수 인수는 ABI 규격에 따라 인코딩됩니다. 예를 들어 uint256 타입은 부호 없는 256비트 정수이며, ABI 인코딩 시 32바이트 공간을 사용합니다.

다음 함수를 호출한다고 가정하겠습니다.

function withdraw(uint256 amount) public {}

여기서 amount에 0.01 ether를 전달하려고 합니다.

Solidity에서 0.01 ether는 Wei 단위로 다음과 같습니다.

0.01 ETH = 10000000000000000 Wei

이 값을 16진수로 표현하면 다음과 같습니다.

0x2386f26fc10000

ethers.js를 사용하면 다음과 같이 변환할 수 있습니다.

const { ethers } = require("ethers");

let amount = "0.01";

amount = ethers.parseEther(amount).toString();
amount = ethers.toBeHex(amount);

console.log(amount);
// 0x2386f26fc10000

다만 이 값은 아직 ABI의 uint256 인코딩 결과가 아닙니다.

uint256은 32바이트로 인코딩되므로 부족한 공간을 앞쪽에서 0으로 채우는 Padding 과정이 필요합니다.

const encodedAmount = ethers.zeroPadValue(amount, 32);

console.log(encodedAmount);
// 0x000000000000000000000000000000000000000000000000002386f26fc10000

이렇게 인코딩된 값이 스마트 컨트랙트 함수에 전달되는 인수 데이터입니다.

2.3. Payload Data 인코딩

이제 Function Selector와 Function Argument를 조합하여 실제 함수 호출에 사용되는 데이터를 생성해 보겠습니다.

호출할 함수는 다음과 같습니다.

function withdraw(uint256 amount) public {}

전달할 인수는 0.01 ether입니다.

먼저 함수 시그니처를 해싱하여 Function Selector를 생성하고, 인수를 32바이트로 인코딩합니다. 이후 두 값을 순서대로 연결하면 최종 Payload Data가 완성됩니다.

const { ethers } = require("ethers");

const { keccak256, toUtf8Bytes } = ethers;

// 1. Function Selector 생성
const functionSignature = "withdraw(uint256)";

const functionSelector = keccak256(
    toUtf8Bytes(functionSignature)
).slice(0, 10);

// 2. Function Argument 인코딩
const amount = ethers.parseEther("0.01");

const encodedAmount = ethers.zeroPadValue(
    ethers.toBeHex(amount),
    32
);

// 3. 최종 Payload Data 생성
const payloadData = functionSelector + encodedAmount.slice(2);

console.log(payloadData);

최종 결과는 다음과 같습니다.

0x2e1a7d4d000000000000000000000000000000000000000000000000002386f26fc10000

이 값을 구분하면 다음과 같습니다.

Function Selector
0x2e1a7d4d

Function Argument
000000000000000000000000000000000000000000000000002386f26fc10000

즉, 함수 선택기 4바이트와 인코딩된 함수 인수 32바이트를 연결한 값이 최종 함수 호출 데이터입니다.

이렇게 생성한 데이터를 트랜잭션의 data 필드에 담아 스마트 컨트랙트 주소로 전송하면, 컨트랙트는 Function Selector를 통해 실행할 함수를 식별하고 인수를 디코딩하여 해당 함수를 실행합니다.

3. Solidity의 call() 함수

지금까지 트랜잭션의 data 필드에 들어가는 함수 호출 데이터를 직접 생성해 보았습니다.

Solidity에서는 이러한 데이터를 사용하여 다른 스마트 컨트랙트의 함수를 호출할 수 있도록 저수준 함수 호출 방식인 call()을 제공합니다.

call()은 호출 대상 주소에 데이터를 전달하고, 호출 성공 여부와 반환 데이터를 전달받습니다.

(bool success, bytes memory data) = target.call(
    abi.encodeWithSignature("withdraw(uint256)", 0.01 ether)
);

여기서 반환되는 값은 다음과 같습니다.

  • success: 저수준 호출의 성공 여부를 나타내는 bool 값
  • data: 호출 대상에서 반환한 데이터를 담은 bytes 값

실제 코드를 통해 확인해 보겠습니다.

3.1. Solidity 예제 코드

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

contract Receiver {
    event Received(address caller, uint amount);

    fallback() external payable {
        emit Received(msg.sender, msg.value);
    }

    function withdraw(uint256 amount)
        public
        payable
        returns (uint)
    {
        emit Received(msg.sender, amount);

        return amount + 1;
    }
}

contract Caller {
    event Response(bool success, bytes data);

    function testCallFoo(address payable _addr) public payable {
        (bool success, bytes memory data) = _addr.call{
            value: msg.value
        }(
            abi.encodeWithSignature(
                "withdraw(uint256)",
                0.01 ether
            )
        );

        emit Response(success, data);
    }

    function testCallDoesNotExist(address payable _addr) public payable {
        (bool success, bytes memory data) = _addr.call{
            value: msg.value
        }(
            abi.encodeWithSignature(
                "doesNotExist()"
            )
        );

        emit Response(success, data);
    }
}

원문의 첫 번째 예제에 지정된 gas: 5000은 호출 대상의 함수 실행에 필요한 가스보다 부족할 수 있어, 우선 가스 제한을 별도로 지정하지 않은 형태로 실습합니다. 가스 지정 방식은 뒤에서 살펴보겠습니다.

Receiver 컨트랙트에는 withdraw() 함수와 fallback() 함수가 정의되어 있습니다.

Caller 컨트랙트는 call()을 통해 Receiver의 함수를 호출하며, 반환된 success와 data를 Response 이벤트로 기록합니다.

3.2. call()이 성공한 경우

먼저 Caller 컨트랙트의 testCallFoo() 함수를 실행하여 Receiver의 withdraw() 함수를 호출해 보겠습니다.

(bool success, bytes memory data) = _addr.call{
    value: msg.value
}(
    abi.encodeWithSignature(
        "withdraw(uint256)",
        0.01 ether
    )
);

abi.encodeWithSignature()는 함수 시그니처와 인수를 전달받아 ABI 규격에 맞는 호출 데이터를 생성하는 함수입니다.

앞서 ethers.js를 통해 직접 생성했던 Function Selector와 Function Argument를 조합하는 작업을 Solidity에서 수행하는 것입니다.

Receiver 컨트랙트의 withdraw() 함수는 전달받은 amount에 1을 더한 값을 반환합니다.

function withdraw(uint256 amount)
    public
    payable
    returns (uint)
{
    emit Received(msg.sender, amount);

    return amount + 1;
}

따라서 호출에 성공하면 success는 true가 되며, data에는 withdraw() 함수가 반환한 값이 ABI 인코딩된 형태로 담깁니다.

이번 예제에서는 0.01 ether에 해당하는 10000000000000000에 1을 더한 값이 반환됩니다.

success = true

data = 0x000000000000000000000000000000000000000000000000002386f26fc10001

data는 bytes 타입이므로 반환된 정수를 직접 확인하려면 ABI 디코딩을 수행해야 합니다.

uint256 result = abi.decode(data, (uint256));

이를 통해 withdraw() 함수가 반환한 정수 값을 확인할 수 있습니다.

3.3. 존재하지 않는 함수를 호출한 경우

이번에는 Caller의 testCallDoesNotExist() 함수를 실행하겠습니다.

(bool success, bytes memory data) = _addr.call{
    value: msg.value
}(
    abi.encodeWithSignature(
        "doesNotExist()"
    )
);

Receiver 컨트랙트에는 doesNotExist()라는 함수가 정의되어 있지 않습니다.

따라서 일반적으로 존재하지 않는 함수를 호출하면 실행에 실패할 것으로 예상할 수 있습니다.

하지만 이번 예제에서는 Receiver에 다음과 같은 fallback() 함수가 정의되어 있습니다.

fallback() external payable {
    emit Received(msg.sender, msg.value);
}

Solidity에서 fallback()은 호출 데이터가 어떤 함수와도 일치하지 않는 경우 실행될 수 있는 특수 함수입니다.

따라서 doesNotExist()에 해당하는 Function Selector를 전달하더라도, Receiver의 fallback()이 실행됩니다.

해당 fallback()은 payable로 선언되어 있으므로 ETH를 함께 전달하는 호출도 처리할 수 있습니다.

또한 함수 내부에서 Received 이벤트만 발생시키고 별도의 반환 데이터를 제공하지 않으므로 결과는 다음과 같습니다.

success = true
data = 0x

여기서 주의할 점은 존재하지 않는 함수가 정상적으로 실행된 것이 아니라, fallback() 함수가 실행되어 저수준 호출이 성공한 것이라는 점입니다.

success는 호출 대상의 실행이 성공적으로 완료되었는지를 나타내며, 의도한 함수가 실제로 실행되었는지까지 보장하지는 않습니다.

4. call() 추가 설명

4.1. abi.encodeWithSignature()

앞서 사용한 abi.encodeWithSignature()는 함수 시그니처와 인수를 기반으로 ABI 인코딩된 호출 데이터를 생성합니다.

abi.encodeWithSignature(
    "withdraw(uint256)",
    0.01 ether
)

이는 앞서 ethers.js로 Function Selector를 생성하고, 인수를 32바이트로 인코딩한 뒤 두 값을 결합했던 과정과 동일한 결과를 생성합니다.

따라서 Solidity에서는 함수 호출 데이터를 직접 조합하지 않고 abi.encodeWithSignature()를 사용하여 생성할 수 있습니다.

4.2. call()의 value 옵션

처음 살펴본 트랜잭션의 Payload에는 value와 data 필드가 존재합니다.

Solidity의 call()에서도 value 옵션을 사용하여 함수 호출과 함께 ETH를 전달할 수 있습니다.

(bool success, bytes memory data) = _addr.call{
    value: msg.value
}(
    abi.encodeWithSignature(
        "withdraw(uint256)",
        0.01 ether
    )
);

여기서 value: msg.value는 현재 함수가 전달받은 ETH의 양을 호출 대상 컨트랙트에 함께 전달하도록 지정합니다.

단, ETH를 전달받는 호출 대상 함수는 payable로 선언되어 있어야 합니다. fallback() 또한 ETH를 수신하려면 payable 선언이 필요합니다.

4.3. call()의 gas 옵션

call()은 gas 옵션을 사용하여 호출 대상 컨트랙트에 전달할 가스량을 지정할 수 있습니다.

// 가스량을 명시한 경우
(bool success, bytes memory data) = _addr.call{
    value: msg.value,
    gas: 5000
}(
    abi.encodeWithSignature(
        "withdraw(uint256)",
        0.01 ether
    )
);

// 가스량을 별도로 명시하지 않은 경우
(bool success, bytes memory data) = _addr.call{
    value: msg.value
}(
    abi.encodeWithSignature(
        "withdraw(uint256)",
        0.01 ether
    )
);

gas: 5000은 호출 대상의 실행에 최대 5,000가스까지 사용하도록 제한하는 옵션입니다.

호출 대상 함수가 실행을 완료하는 데 필요한 가스가 부족하면 해당 저수준 호출이 실패하여 success가 false로 반환될 수 있습니다.

반면 가스량을 별도로 명시하지 않으면 EVM의 가스 전달 규칙에 따라 사용 가능한 가스가 전달됩니다.

여기서 gas 옵션은 트랜잭션 전체의 가스 한도를 지정하는 것이 아니라, 외부 컨트랙트를 호출할 때 전달하는 가스를 제한하는 옵션이라는 점을 구분해야 합니다.

Reference