안녕하세요. 스마트컨트렉트 개발자 개발의 체질 최원혁입니다.
이번 게시글에서는 EVM 기반의 블록체인 네트워크에서 스마트컨트렉트의 함수가 호출되는 방법을 알아보고
Solidity의 Call() 함수에 를 활용하여 실습을 해보겠습니다.
# 1. 트랜잭션의 데이터 페이로드 전달
트랜잭션에는 값(value)과 데이터(data)라는 2개의 필드를 포함하며 이를 페이로드(payload)라고 합니다.
- 값만 있는 경우 : 이더 지급(payment)
- 데이터만 있는 경우 : 스마트컨트렉트 호출
- 둘다 있는 경우 : 이더 지급 + 스마트컨트렉트 호출
트랜잭션은 크게 페이로드의 데이터 유무에 따라 위 3가지 형태의 목적을 갖게 됩니다. 이번 시간은 페이로드에 데이터를 담아 스마트컨트렉트의 함수를 호출하는 과정에 대해 알아보겠습니다.
# 2. 페이로드의 데이터(data)
페이로드의 데이터 필드에는 이더리움의 ABI 호환 인터페이스에 규정된 내용에 따라 16진수로 시리얼라이즈된 인코딩 값이 포함됩니다.
그럼 스마트컨트렉트의 함수를 호출하기 위한 ABI 호환 인터페이스에 규정된 내용에 대해 알아보겠습니다.
2.2 함수 선택기(function selector)
함수 선택기는 *함수 프로토타입의 Keccak-256 해시의 처음 4bytes입니다. 컨트렉트에서 호출할 함수를 정확하게 실별하기 위함으로 의도되었습니다.
*함수 프로토타입 : 함수 이름을 포함하는 문자열 + (각 인수(argument)의 데이터 유형)
ex : function withdraw(uint amount) public {} => "withdraw(uint256)"
2.3 함수 인수(function argument)
ABI 사양에 정의된 기본 유형 규칙에 따라 인코딩
ex : uint amount의 값을 0.01 이더로 넣을 경우 => 0x2386f26fc10000 (wei단위를 16진수로 시리얼라이즈된 부호없는 값)
2.4 페이로드 데이터 인코딩 예시
function withdraw(uint amount) public { }
위와 같은 스마트컨트렉트의 함수를 호출한다고 가정해보겠습니다. 여기서 amount로 0.01 ether를 인수로 담아서 전달하겠습니다.
| 함수 선택기 :
const {ethers} = require('ethers');
const {keccak256,toUtf8Bytes} = ethers
const functionSelector = keccak256(toUtf8Bytes("withdraw(uint256)"))
console.log("0x"+functionSelector.substring(2,10));
// 0x2e1a7d4d
function withdraw의 프로토타입을 keccak256으로 해시한 값의 4bytes, 즉 앞에서 8글자가 함수 선택기 값 0x2e1a7d4d가 됩니다.
| 함수 인수(function argument) :
const {ethers} = require('ethers');
let amount = "0.01"
amount = ethers.parseUnits(amount).toString()
amount = ethers.toBeHex(amount);
console.log(amount);
// 0x2386f26fc10000
함수 인수는 데이터를 16진수로 시리얼라이즈된 256비트로 인코딩한 값입니다. 이더인 경우, wei단위로 바꿔서 사용해야 합니다.
uint256 amount에 0.01 이더를 인수에 넣는다면 함수 인수값은 0x2386f26fc10000가 됩니다.
| 페이로드 데이터(최종 값) :
const {ethers} = require('ethers');
const {keccak256,toUtf8Bytes} = ethers
const functionSelector = keccak256(toUtf8Bytes("withdraw(uint256)"))
let amount = "0.01"
amount = ethers.parseUnits(amount).toString()
amount = ethers.toBeHex(amount);
const padding = 32*4- amount.substring(2).length
const payloadData = functionSelector + "0".repeat(padding) + amount.substring(2).length;
console.log(payloadData);
// 0x2e1a7d4d13322e7b96f9a57413e1525c250fb7a9021cf91d1540d5b69f16a49f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002386f26fc10000
함수 선택기와 함수 인수를 이어 붙히면 최종적인 페이로드의 데이터 값이 됩니다. 이때 인수는 256비트 만큼의 공간을 갖기에, 나머지 부분은 padding(=0으로 채우는 작업)으로 채워줍니다.
이렇게 만들어진 값은 트랜잭션의 페이로드 데이터 필드에 넣어서 트랜잭션을 전달합니다.
# 3. Solidity Call()
Solidity에는 Call()함수를 지원하며, 인수로 페이로드 데이터를 넣어주면 됩니다. 아래에 코드를 보며 실습을 해보겠습니다.
// 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, gas: 5000}(
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);
}
}
Contract Caller에서 functiob testCallFoo() 함수를 통해 Contract Receiver의 functiob withdraw() 함수를 호출해보겠습니다.
그리고 Call() 함수를 통해 호출되는 리턴값을 event Response에 넣어서 확인해보겠습니다.
| Call() 성공했을 경우 :
Contract Receiver의 functiob withdraw() 함수는 전달받은 amount값을 return하도록 만들었습니다. 그래서인지 Call()의 리턴의 data에 해당 숫자의 16진수로 인코딩된 값이 리턴되었습니다.
| Call() 실패했을 경우 :
사실 이 함수는 실패하는게 맞습니다. 하지만 Contract Receiver에 fallback()함수를 넣어놓고, Call() 함수에 value를 넣어 이더를 전달하였기에, 트랜잭션은 성공한걸로 나옵니다. 하지만 Call()의 data 리턴에는 0x로 비어있는 값이 리턴된걸 확인할 수 있습니다.
3.1 Call() 추가 설명 :
| abi.encodeWithSignature :
Solidity의 ABI 인코딩 함수중 하나로, Payload 데이터를 만들어주는 기능입니다. 2.4에서 ethers.js로 만든 페이로드 데이터 값을 Solidity에서는 abi.encodeWithSignature을 활용하여 만들 수 있습니다.
| 페이로드 value + data
트랜잭션에는 값(value)과 데이터(data)라는 2개의 필드를 포함하며 이를 페이로드(payload)라고 합니다. \
우리는 # 1. 에서 트랜잭션의 종류에 대해 알아봤습니다. 거기서 value를 넣을수 있다고 했습니다. Solidity의 Call()에서도 value를 넣을 수 있습니다.
// 가스를 명시한 경우
(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)
);
call()옆에 {value: 전달할 이더 값 } 양식으로 값(value)를 트랜잭션에 담아 전달할 수 있습니다.
가스를 명시할 경우, 해당 가스만큼 소비하여 트랜잭션을 전달합니다. 하지만 트랜잭션의 가스비가 명시한 가스량보다 높게 측정되면 트랜잭션은 실패(false)하게 됩니다.
# 전체 코드(Github) :
댓글