Solidity - Create, Create2

김윤수·2023년 3월 13일

Solidity

목록 보기
2/3

팩토리 패턴에 대한 글에서 new라는 키워드가 CREATE opcode를 호출한다는 얘기를 했었다.
자식 컨트랙트를 생성하는 opcode에는 이외에도 CREATE2가 있다.
각자가 어떻게 다른 지 알아보자.

Create

0xF0에 해당하는 opcode로 creates a child contract의 설명처럼 자식 컨트랙트를 생성할 때 사용한다.

msg.sender의 주소와 nonce를 사용해서 컨트랙트 주소를 만든다.
호출자의 nonce는 계속 증가하므로 매번 새로운 컨트랙트 주소가 생성된다.
따라서 주소가 충돌하지 않는다.

go-ethereum/core/vm/evm.go

// Create creates a new contract using code as deployment code.
func (evm *EVM) Create(caller ContractRef, code []byte, gas uint64, value *big.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {
	contractAddr = crypto.CreateAddress(caller.Address(), evm.StateDB.GetNonce(caller.Address()))
	return evm.create(caller, &codeAndHash{code: code}, gas, value, contractAddr, CREATE)
}

Create2

콘스탄티노플 업그레이드의 EIP-1014의 opcode로 0xF5로 추가되었다.
creates a child contract with a deterministic address라는 설명에서 알 수 있듯이, 주소를 특정하여 자식 컨트랙트를 생성할 수 있다.

이는 Create와 달리 nonce를 쓰지 않고 0xff, salt, init_code를 사용하기에 가능하다.

go-ethereum/core/vm/evm.go

// Create2 creates a new contract using code as deployment code.
//
// The different between Create2 with Create is Create2 uses keccak256(0xff ++ msg.sender ++ salt ++ keccak256(init_code))[12:]
// instead of the usual sender-and-nonce-hash as the address where the contract is initialized at.
func (evm *EVM) Create2(caller ContractRef, code []byte, gas uint64, endowment *big.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {
	codeAndHash := &codeAndHash{code: code}
	contractAddr = crypto.CreateAddress2(caller.Address(), salt.Bytes32(), codeAndHash.Hash().Bytes())
	return evm.create(caller, codeAndHash, gas, endowment, contractAddr, CREATE2)
}

그러면

기존에 생성한 컨트랙트의 주소와 충돌 문제가 생길 수 있다.

Create에 의해 생성된 컨트랙트 주소는 20 바이트의 address와 8 바이트의 nonce로 이뤄진 배열이 RLP 인코딩을 거쳐 나온다.
이 값은 0xff의 prefix를 가질 수 없다.

go-ethereum/crypto/crypto.go

// CreateAddress creates an ethereum address given the bytes and the nonce
func CreateAddress(b common.Address, nonce uint64) common.Address {
	data, _ := rlp.EncodeToBytes([]interface{}{b, nonce})
	return common.BytesToAddress(Keccak256(data)[12:])
}

반면, Create2에 의해 생성된 컨트랙트 주소는 0xff의 prefix를 가진다.

go-ethereum/crypto/crypto.go

// CreateAddress2 creates an ethereum address given the address bytes, initial
// contract code hash and a salt.
func CreateAddress2(b common.Address, salt [32]byte, inithash []byte) common.Address {
	return common.BytesToAddress(Keccak256([]byte{0xff}, b.Bytes(), salt[:], inithash)[12:])
}

따라서 Create로 생성된 주소와 Create2로 생성된 주소는 충돌하지 않는다.

그렇다면 이미 존재하는 주소로 재배포하는 경우에는 어떻게 될까?
이 경우, nonce가 0이 아니라면(=이미 존재하는 주소라면) 에러를 반환한다.

func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {
    ...
	// Ensure there's no existing contract already at the designated address
	contractHash := evm.StateDB.GetCodeHash(address)
	if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != emptyCodeHash) {
		return nil, common.Address{}, 0, ErrContractAddressCollision
	}
    ... 
}

따라서 동일한 주소로 재배포를 할 경우에는 selfdestruct를 호출해서 컨트랙트를 체인에서 삭제한 후 생성하면 되겠다.

예시

Contract

Zombie.sol

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

contract Zombie {
  string public name;
  uint public dna;

  constructor(string memory _name, uint _dna) {
    name = _name;
    dna = _dna;
  }

  function destroy(address payable recipient) public {
    selfdestruct(recipient);
  }
}

팩토리 패턴에서 destory만 추가되었다.
찾다보니 안 사실인데 EIP-4758, EIP-6049에서 selfdesturctsendall로 대체된다거나, 사용되지 않는다는 말이 있다.
이로 인해 Solidity 0.8.18부터는 사용되지 않고, compile시 경고가 뜬다.

ZombieFactory.sol

  function _createZombieV2(string memory _name, uint _dna) private {
    address zombie;
    bytes32 salt = keccak256(abi.encodePacked(_name));
    bytes memory bytecode = abi.encodePacked(
      type(Zombie).creationCode,
      abi.encode(_name, _dna)
    );
    assembly {
      zombie := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
      if iszero(extcodesize(zombie)) {
        revert(0, 0)
      }
    }
    zombies.push(Zombie(zombie));

    emit Created(zombie, _dna);
  }

  function createRandomZombieV2(string memory _name) public {
    uint randDna = _generateRandomDna(_name);
    _createZombieV2(_name, randDna);
  }

salt의 타입은 bytes32고, 예시에서는 name을 사용했다.
생성자에 필요한 인자를 넣기 위해서 create2전에 abi-encode를 해야한다.

Test

test.js

contract("ZombieFactory", async (accounts) => {
  const {
    bytecode: zombieBytecode,
  } = require("../build/contracts/Zombie.json");
  const name1 = "Gildong";
  const name2 = "Chulsoo";

  before(async () => {
    zombieFactory = await Factory.new({ from: accounts[0] });
    console.log("Deployed Factory: ", zombieFactory.address);
  });
  
...

});

bytecode은 off-chain에서 주소를 생성하기 위해 필요하다.
좀비의 이름은 salt로 쓰인다.
before 함수를 통해 테스트 진행 전에 팩토리 컨트랙트를 배포할 수 있다.

create

create를 통해 생성시 자식 컨트랙트의 주소는 매번 달라져야 한다.

describe("Zombie generated by 'create'", () => {
  it("두 좀비의 주소가 달라야 합니다.", async () => {
    let tx = await zombieFactory.createRandomZombie(name1);
    var { zombie, dna } = tx.logs[0].args;
    const zombie1Addr = zombie;
    const zombie1 = await Zombie.at(zombie1Addr);
    let name = await zombie1.name();
    console.log("Zombie generated:", zombie1Addr, name, +dna);

    tx = await zombieFactory.createRandomZombie(name2);
    var { zombie, dna } = tx.logs[0].args;
    const zombie2Addr = zombie;
    const zombie2 = await Zombie.at(zombie2Addr);
    name = await zombie2.name();
    console.log("Zombie generated:", zombie2Addr, name, +dna);

    await assert.notStrictEqual(zombie1Addr, zombie2Addr);
  });
});

create2

describe("Zombie generated by 'create2'", () => {
  let tx, zombie1Addr, name, zombieDNA, zombie1;
  beforeEach(async () => {
    tx = await zombieFactory.createRandomZombieV2(name1);
    var { zombie, dna } = tx.logs[0].args;
    zombie1Addr = zombie;
    zombie1 = await Zombie.at(zombie1Addr);
    name = await zombie1.name();
    zombieDNA = +dna;
    console.log("Zombie generated:", zombie1Addr, name, zombieDNA);
  });
  afterEach(async () => {
    zombie1 = await Zombie.at(zombie1Addr);
    truffleAssert.passes(await zombie1.destroy(accounts[0]));
  });

beforeEach는 테스트 케이스를 실행할 때마다 선행되는 함수로, 여기서는 테스트 케이스마다 Gildong이라는 이름의 좀비를 create2로 생성하게 된다.
매 케이스마다 같은 주소의 컨트랙트를 생성하지 않도록 afterEach를 통해 기존의 함수를 없앤다.

  • salt가 같아 기존의 주소와 충돌하는 것을 확인하기 위한 테스트
it("이름이 같은 좀비를 생성시 실패해야 합니다.", async () => {
  await catchRevert(zombieFactory.createRandomZombieV2(name1));
});
  • 자식 컨트랙트의 주소가 off-chain에서 같은 조건으로 만든 주소와 같은지 확인하는 테스트
it("좀비의 주소가 오프체인에서 특정한 주소와 같아야 합니다.", async () => {
  zombie1 = await Zombie.at(zombie1Addr);
  dna = +(await zombie1.dna());
  dnaString = dna.toString();
  name = await zombie1.name();

  const params = encodeParams(["string", "uint"], [name, dnaString]);
  const bytecode = `${zombieBytecode}${params.slice(2)}`;
  assert.equal(await zombieFactory.getBytecode(name, dna), bytecode);

  const salt = nameToSalt(name1);
  assert.equal(await zombieFactory.getSalt(name), salt);

  const computeAddr = createAddress2(zombieFactory.address, name, bytecode);
  assert.equal(zombie1Addr.toLowerCase(), computeAddr);
});
  • 이름이 다르면(=salt가 달라지면) 다른 주소를 만드는지 확인하는 테스트
it("이름이 다른 두 좀비의 주소가 달라야 합니다.", async () => {
  tx = await zombieFactory.createRandomZombieV2(name2);
  var { zombie, dna } = tx.logs[0].args;
  zombie2Addr = zombie;
  zombie2 = await Zombie.at(zombie2Addr);
  name = await zombie2.name();
  assert.strictEqual(name, name2);
  assert.notStrictEqual(zombie1Addr, zombie2Addr);
});

결과

참고

https://eips.ethereum.org/EIPS/eip-1014
https://ethervm.io
https://github.com/ethereum/go-ethereum
https://ethereum.stackexchange.com/questions/101336/what-is-the-benefit-of-using-create2-to-create-a-smart-contract
https://ethereum.stackexchange.com/questions/78738/passing-constructor-arguments-to-the-create-assembly-instruction-in-solidity

2개의 댓글

comment-user-thumbnail
2023년 5월 25일

안녕하세요 글 잘봤습니다. 이더리움 코어는 따로 자료를 찾아서 공부하신건가요!?

1개의 답글