[Solidity] ABI, Keccak256, Verifying Signature

jhcha·2023년 8월 6일
2

Solidity

목록 보기
12/17
post-thumbnail

ABI Encode

url: https://solidity-by-example.org/abi-encode/

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

interface IERC20 {
    function transfer(address, uint) external;
}

contract Token {
    function transfer(address, uint) external {}
}

contract AbiEncode {
    function test(address _contract, bytes calldata data) external {
        (bool ok, ) = _contract.call(data);
        require(ok, "call failed");
    }

    function encodeWithSignature(
        address to,
        uint amount
    ) external pure returns (bytes memory) {
        // Typo is not checked - "transfer(address, uint)"
        return abi.encodeWithSignature("transfer(address,uint256)", to, amount);
    }

    function encodeWithSelector(
        address to,
        uint amount
    ) external pure returns (bytes memory) {
        // Type is not checked - (IERC20.transfer.selector, true, amount)
        return abi.encodeWithSelector(IERC20.transfer.selector, to, amount);
    }

    function encodeCall(address to, uint amount) external pure returns (bytes memory) {
        // Typo and type errors will not compile
        return abi.encodeCall(IERC20.transfer, (to, amount));
    }
}

ABI (Application Binary Interface)는 EVM의 Contract와 상호작용하는 표준 방법이다.
스마트 컨트랙트의 함수와 파라미터에 대한 메타 데이터를 정의하여 컨트랙트 객체를 만들 수 있고, 컨트랙트의 함수를 호출할 수 있는 표준 방법이다.
Solidity는 .sol 파일을 컴파일하면 json 형태의 ABI 데이터를 얻을 수 있다. 하지만, low-level 언어를 사용하는 블록체인 네트워크는 abi.json 형태의 데이터를 해석할 수 없다. 따라서, 블록체인 네트워크가 이해할 수 있는 형태로 인코딩이 필요하다.
EVM은 ABI를 32바이트로 변환 후 네트워크로 전달하는 방법을 정의한다. 이 때, address, uint256 또는 bytes32 같은 정적 타입은 bytes32로 변환되어 사용하지만, string 또는 array와 같은 동적 타입은 solidity에서 지정한 특정 방식으로 인코딩 된다.
solidity 내장 함수인 ABI.encode()는 EVM가 직접 해석할 수 있는 바이트 형태로 인코딩 할 수 있다.

abi.encodeWithSignature은 아래와 같이 동작한다.

abi.encodeWithSignature(call signature, address, value)

encodeWithSignature(address(Token), 100)으로 호출한 결과 다음과 같다.

    function encodeWithSignature(
        address to,
        uint amount
    ) external pure returns (bytes memory) {
        // Typo is not checked - "transfer(address, uint)"
        return abi.encodeWithSignature("transfer(address,uint256)", to, amount);
    }
// bytes: 0xa9059cbb0000000000000000000000000fc5025c764ce34df352757e82f7b5c4df39a8360000000000000000000000000000000000000000000000000000000000000064

해당 68바이트는 4바이트 (function selector), 32바이트 (to address 값을 ABI 인코딩한 값), 32바이트 (amount 변수에 입력된 uint 값을 ABI 인코딩한 값)로 구성되어 있다.
응답 값의 첫 4바이트 (0xa9059cbb)는 function selector를 의미한다.

bytes4(keccak256(bytes("transfer(address,uint256)")));
// returns 0xa9059cbb

이후 32바이트는 address 값을 나타낸다. 이더리움의 주소는 20바이트 크기를 가지며 나머지 값은 패딩으로 채워진다.
0xfc5025c764ce34df352757e82f7b5c4df39a836

마지막 32바이트는 amount 변수에 입력된 값으로 64(16), 입력한 100을 의미한다.
따라서, abi.encodeWithSignature 반환 값을 통해 call을 호출할 수 있다.

call 함수는 페이로드를 매개변수로 받아 동작한다.

call{value: 전달할 이더 값, gas: 최대 가스 명시 (Optional)}(data) 
// 두 호출은 서로 동일함
_contract.call(0xa9059cbb0000000000000000000000000fc5025c764ce34df352757e82f7b5c4df39a8360000000000000000000000000000000000000000000000000000000000000064);

_contract.call(abi.encodeWithSignature("transfer(address,uint256)", 0xfc5025c764ce34df352757e82f7b5c4df39a836, 100);
contract Token {
    event Log(string, uint);
    function transfer(address, uint a) external {
        emit Log("transfer", a);
    }
}

contract AbiEncode {
    function test(address _contract, bytes calldata data) external {
        (bool ok, ) = _contract.call(data);
        require(ok, "call failed");
    }
    function encodeWithSignature(
        address to,
        uint amount
    ) external pure returns (bytes memory) {
        // Typo is not checked - "transfer(address, uint)"
        return abi.encodeWithSignature("transfer(address,uint256)", to, amount);
    }
}

Token 컨트랙트에 이벤트를 추가해서 Token.transfer 함수가 정상적으로 값을 받아서 호출되는지 확인한다.

위의 설명과 같이, AbiEncode.encodeWithSignature 함수를 실행시켜 abi.encodeWithSignature("transfer(address,uint256)", to, amount) 결과값을 AbiEncode.test의 data로 입력한다.

event Log를 통해 입력한 값이 정상적으로 호출된 것을 확인할 수 있다.

    function encodeWithSelector(
        address to,
        uint amount
    ) external pure returns (bytes memory) {
        // Type is not checked - (IERC20.transfer.selector, true, amount)
        return abi.encodeWithSelector(IERC20.transfer.selector, to, amount);
    }

abi.encodeWithSelector는 encodeWithSignature와 다르게 첫 번째 인자를 [contract].[functionName].selector 방식으로 입력한다. contract, functionName이 일치하지 않으면 컴파일 단계에서 에러가 발생한다. 하지만, 2, 3번째 인자에 대한 타입을 체크하지 않는다.

//abi.encodeWithSignature 함수 원형
function abi.encodeWithSignature(
	// string으로 입력하기 때문에 typo check 불가능
    string memory signature,
    // 가변인자
    ...
) internal pure returns (bytes memory)

마찬가지로, 위와 같이 encodeWithSignature 함수의 첫 번째 인자가 string 형태로 입력받기 때문에 입력한 값의 typo 에러를 감지하지 못한다.

    function encodeCall(address to, uint amount) external pure returns (bytes memory) {
        // Typo and type errors will not compile
        return abi.encodeCall(IERC20.transfer, (to, amount));
    }

encodeCall은 첫 번째 인자로 함수를 지정하고, 함수의 인자 값을 튜플 형태로 묶어서 호출한다. 따라서, 컴파일 단계에서 type, typo 에러가 발생할 수 있다.

abi.encodeCall(function, argumentsTuple)

ABI Decode

url: https://solidity-by-example.org/abi-decode/

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

contract AbiDecode {
    struct MyStruct {
        string name;
        uint[2] nums;
    }

    function encode(
        uint x,
        address addr,
        uint[] calldata arr,
        MyStruct calldata myStruct
    ) external pure returns (bytes memory) {
        return abi.encode(x, addr, arr, myStruct);
    }

    function decode(
        bytes calldata data
    )
        external
        pure
        returns (uint x, address addr, uint[] memory arr, MyStruct memory myStruct)
    {
        // (uint x, address addr, uint[] memory arr, MyStruct myStruct) = ...
        (x, addr, arr, myStruct) = abi.decode(data, (uint, address, uint[], MyStruct));
    }
}

abi.encode는 데이터를 bytes로 인코드하고, abi.decode는 bytes를 다시 데이터로 디코드한다.
문제는 remix에서 encode 함수를 호출하는데 문제가 발생했다.

myStruct에 tuple(string name, uint[2] nums) 방식으로 값을 전달하는 방법을 몰라서 struct를 없애고 테스트했다.
향후 remix에서 tuple 값을 어떤 방식으로 입력해서 호출하는지 방법을 찾아서 수정할 예정이다.

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

contract AbiDecode {
    function encode(
        uint x,
        address addr,
        uint[] calldata arr
    ) external pure returns (bytes memory) {
        return abi.encode(x, addr, arr);
    }

    function decode(
        bytes calldata data
    )
        external
        pure
        returns (uint x, address addr, uint[] memory arr)
    {
        (x, addr, arr) = abi.decode(data, (uint, address, uint[]));
    }
}

테스트를 위해 다시 작성된 코드
그리고, encode에 값을 입력해서 얻은 encoded bytes 값을 다시 decode를 통해 데이터로 변환했다.

Keccak256

url: https://solidity-by-example.org/hashing/

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

contract HashFunction {
    function hash(
        string memory _text,
        uint _num,
        address _addr
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_text, _num, _addr));
    }

    // Example of hash collision
    // Hash collision can occur when you pass more than one dynamic data type
    // to abi.encodePacked. In such case, you should use abi.encode instead.
    function collision(
        string memory _text,
        string memory _anotherText
    ) public pure returns (bytes32) {
        // encodePacked(AAA, BBB) -> AAABBB
        // encodePacked(AA, ABBB) -> AAABBB
        return keccak256(abi.encodePacked(_text, _anotherText));
    }
}

contract GuessTheMagicWord {
    bytes32 public answer =
        0x60298f78cc0b47170ba79c10aa3851d7648bd96f2f8e46a19dbc777c36fb0c00;

    // Magic word is "Solidity"
    function guess(string memory _word) public view returns (bool) {
        return keccak256(abi.encodePacked(_word)) == answer;
    }
}

keccak256은 입력받은 값을 해시 결과 값으로 계산하는 해시 알고리즘이다.
abi.encodePacked는 abi.encode 함수와 다르게 인자로 받은 값들을 묶어서 단순히 연속적으로 나열한 데이터를 생성한다. 데이터의 크기나 형식에 대한 메타 정보가 없기 때문에 "AA", "ABBB" 입력 값과 "AAA", "BBB" 의 입력값에 대한 해시 결과 값이 같아 Hash Collision이 발생한다.
따라서, 예시에서는 이와 같은 해시 충돌을 피하기 위해 abi.encode 함수를 사용할 수 있다고 설명한다.

Verifying Signature

url: https://solidity-by-example.org/signature/

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

/* Signature Verification

How to Sign and Verify
# Signing
1. Create message to sign
2. Hash the message
3. Sign the hash (off chain, keep your private key secret)

# Verify
1. Recreate hash from the original message
2. Recover signer from signature and hash
3. Compare recovered signer to claimed signer
*/

contract VerifySignature {
    /* 1. Unlock MetaMask account
    ethereum.enable()
    */

    /* 2. Get message hash to sign
    getMessageHash(
        0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C,
        123,
        "coffee and donuts",
        1
    )

    hash = "0xcf36ac4f97dc10d91fc2cbb20d718e94a8cbfe0f82eaedc6a4aa38946fb797cd"
    */
    function getMessageHash(
        address _to,
        uint _amount,
        string memory _message,
        uint _nonce
    ) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(_to, _amount, _message, _nonce));
    }

    /* 3. Sign message hash
    # using browser
    account = "copy paste account of signer here"
    ethereum.request({ method: "personal_sign", params: [account, hash]}).then(console.log)

    # using web3
    web3.personal.sign(hash, web3.eth.defaultAccount, console.log)

    Signature will be different for different accounts
    0x993dab3dd91f5c6dc28e17439be475478f5635c92a56e17e82349d3fb2f166196f466c0b4e0c146f285204f0dcb13e5ae67bc33f4b888ec32dfe0a063e8f3f781b
    */
    function getEthSignedMessageHash(
        bytes32 _messageHash
    ) public pure returns (bytes32) {
        /*
        Signature is produced by signing a keccak256 hash with the following format:
        "\x19Ethereum Signed Message\n" + len(msg) + msg
        */
        return
            keccak256(
                abi.encodePacked("\x19Ethereum Signed Message:\n32", _messageHash)
            );
    }

    /* 4. Verify signature
    signer = 0xB273216C05A8c0D4F0a4Dd0d7Bae1D2EfFE636dd
    to = 0x14723A09ACff6D2A60DcdF7aA4AFf308FDDC160C
    amount = 123
    message = "coffee and donuts"
    nonce = 1
    signature =
        0x993dab3dd91f5c6dc28e17439be475478f5635c92a56e17e82349d3fb2f166196f466c0b4e0c146f285204f0dcb13e5ae67bc33f4b888ec32dfe0a063e8f3f781b
    */
    function verify(
        address _signer,
        address _to,
        uint _amount,
        string memory _message,
        uint _nonce,
        bytes memory signature
    ) public pure returns (bool) {
        bytes32 messageHash = getMessageHash(_to, _amount, _message, _nonce);
        bytes32 ethSignedMessageHash = getEthSignedMessageHash(messageHash);

        return recoverSigner(ethSignedMessageHash, signature) == _signer;
    }

    function recoverSigner(
        bytes32 _ethSignedMessageHash,
        bytes memory _signature
    ) public pure returns (address) {
        (bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);

        return ecrecover(_ethSignedMessageHash, v, r, s);
    }

    function splitSignature(
        bytes memory sig
    ) public pure returns (bytes32 r, bytes32 s, uint8 v) {
        require(sig.length == 65, "invalid signature length");

        assembly {
            /*
            First 32 bytes stores the length of the signature

            add(sig, 32) = pointer of sig + 32
            effectively, skips first 32 bytes of signature

            mload(p) loads next 32 bytes starting at the memory address p into memory
            */

            // first 32 bytes, after the length prefix
            r := mload(add(sig, 32))
            // second 32 bytes
            s := mload(add(sig, 64))
            // final byte (first byte of the next 32 bytes)
            v := byte(0, mload(add(sig, 96)))
        }

        // implicitly return (r, s, v)
    }
}

서명과 검증 방법은 다음과 같다.

Signing

  • 서명할 메세지 작성
  • 메세지 해싱
  • 해시 서명 (오프체인, 개인 키 비밀 유지)

Verifying

  • 원본 메세지를 통해 해시 생성
  • 서명 및 해시에서 서명자 복구
  • 복구된 서명자와 요청된 서명자 비교

블록체인은 기존의 신뢰할 수 있는 제 3자를 통한 중앙집중형 시스템 환경을 P2P 환경으로 옮겨온 기술이다. 따라서, 블록체인은 기본적으로 신뢰할 수 있는 제 3자가 없기 때문에 트랜잭션을 발생할 때 해당 블록체인 네트워크의 사용자임을 인증할 수 있는 인증 체계가 필요하다. 이 때 블록체인에서 일반적으로 사용되는 것이 디지털 서명이다. 디지털 서명은 아래 그림을 참고하여 설명할 수 있다. 먼저, Signing은 데이터 메시지를 해시 함수로 해싱하고, 해시 결과값을 서명자의 개인키로 암호화 한다. 이러한 과정을 통해 생성된 디지털 서명은 검증자에 의해 검증된다. 검증자는 서명 데이터에 메세지를 해싱하고, 암호화된 시그니처를 서명자의 공개키로 복호화한다. 그 후 해싱 메세지의 해시 결과 값과 공개키로 복호화한 값이 일치하는지 확인한다.

참고 자료: http://wiki.hash.kr/index.php/%EC%84%9C%EB%AA%85

Get message hash to sign

getMessageHash(
	0xA4FED33f3c03B265eaACa4eE051C23A5bfee0C2D,
    123,
    "coffee and donuts",
    1
)
// returns 0x2643887eca801fab6b11ee98a877fc4dd9dd9114f0e4f8778ab90872b237ee14

getEthSignedMessageHash(
	0x2643887eca801fab6b11ee98a877fc4dd9dd9114f0e4f8778ab90872b237ee14
)
// returns 0x4d88e0e05fac49ed0580c8803371c5687534593fd928d162366b343134368952

Sign message hash

ethereum.request 함수는 Ethereum 클라이언트와 상호작용하는 데 사용되며, JSON-RPC 요청을 보내고 응답을 받을 수 있는 인터페이스를 제공한다. account 주소를 입력하고 ethereum.request를 실행시키면 연결된 메타마스크에서 서명 요청을 확인할 수 있다.

서명에 대한 서명 값은 다음과 같다.

Signature: 0xeabbe6cc43c9fd260ea1cb802fb12efce76fa4a16766aaf2229dcc76ceaab743204bf5e3b0fd0039fa1d7ef55143bb2afcdf81930d4cddc37480ae2b9e9776531b

하지만, verify를 호출한 결과 다음과 같이 false를 반환했다.

문제를 확인하기 위해 순차적으로 검증 작업을 한 결과,

function verify(...){
	...
	return recoverSigner(ethSignedMessageHash, signature) == _signer;
}

function recoverSigner(
        bytes32 _ethSignedMessageHash,
        bytes memory _signature
    ) public pure returns (address) {
        (bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);
        return ecrecover(_ethSignedMessageHash, v, r, s);
}

verify는 최종적으로 메세지를 다시 해싱하고, signature와 해시를 통해 서명자를 찾고, recoverSigner 함수 결과 값이 서명자와 일치하는지 확인한다.
이 과정에서 다음과 같이 recoverSigner 함수에서 잘못된 signer의 주소 값을 반환하는 것을 확인할 수 있다.
입력 값을 임의로 변경하면 0x00과 같이 패딩 값으로 이루어져있는 값을 반환하게 되는데, 특정 주소 값이 나온다.
이 문제에 대해서 여러가지 알아보고 있지만 아직 이유를 못찾았다.

++ 08.08 추가: hash 값에 getEthSignedMessageHash 해쉬 값을 넣어서 정상적인 서명이 이루어지지 않았다. hash 값에 getMessageHash 해시 결과 값을 입력해서 정상적으로 작동하는 것을 확인했다.
추가적으로 해당 예제 코드의 동작 과정을 그림으로 정리해서 추가했다.

0개의 댓글