본문 바로가기
솔리디티

openzzeplin 5V => upgrade contract 분석하기

by gun_poo 2024. 1. 20.

오랜만에 블로그다. 이직을 해서 정신없이 프로젝트를 쳐내다가 정리를 해가며 해야할거 같아서 

오랜만에 글을 써내려가겠다.

 

현재 내가 분석중인것은 업그레이더블 컨트랙트이다.

함수호출로 업그레이드 하는 방법을 분석중인데 그전에 하드햇에서 지원하는 라이브러리가 있어 그것을 파해쳐보겠다.

 

우선 transparent 패턴을 분석중이라 그것 부터 정리를 한다.

 

분석해보니 하드햇에서 지원하는 방법은 크게 두가지가 있는것 같다.

1. 프록시 컨트랙트 없이 라이브러리로 업그레이드 진행.

2. 내가 원하는 특정 프록시 컨트랙트 패턴을 이용한 업그레이드 진행.

 

2번 부터 정리한다.

 

필요 컨트렉트

1. v1 logic contract

2. v2 logic contract

끝!

 

배포 및 업그레이드 방법

전체적인 로직은 제일 마지막에 정리한다.

우선 로직 버전 1을 배포한다.

샘플 컨트렉트

pragma solidity ^0.8.0;

contract Greeter {

    string greeting;

    function initialize(string memory _greeting) public {
        greeting = _greeting;
    }

    function greet() public view returns (string memory) {
        return greeting;
    }

    function setGreeting(string memory _greeting) public {
        greeting = _greeting;
    }

}

v1 배포!

const [account1] = await ethers.getSigners();
  console.log("adderss : ", account1.address);
  const ownerAdd = account1.address;

  const GreeterV1 = await ethers.getContractFactory("Greeter");
  const impl = await GreeterV1.deploy();
  await impl.waitForDeployment();
  const implAdd = await impl.getAddress();
  console.log(implAdd);

 

 

2. TransparentUpgradeableProxy 배포

 

  const proxy = await ethers.deployContract("TransparentUpgradeableProxy", [
    implAdd,
    ownerAdd,
    getInitializerData(GreeterV1.interface, ["Hello, Hardhat!"]),
  ]);
  await proxy.waitForDeployment();
  const proxyAdd = await proxy.getAddress();
  console.log(proxyAdd);

 

function getInitializerData(contractInterface, args) {
  const initializer = "initialize";
  const fragment = contractInterface.getFunction(initializer);
  return contractInterface.encodeFunctionData(fragment, args);
}

deployContract를 통해 오픈제플린 TransparentUpgradeableProxy를 배포한다.

 

3. forceImport

 const greeter = await upgrades.forceImport(proxyAdd, GreeterV1);

forceImport가 하는 역할을 알아보자

upgrades.forceImport는 OpenZeppelin Upgrades 플러그인에서 제공하는 함수로, 

이미 배포된 프록시 컨트랙트를 OpenZeppelin Upgrades 플러그인의 매니페스트에 추가하는 역할을 한다

 이는 프록시 컨트랙트가 OpenZeppelin Upgrades 플러그인 외부에서 배포된 경우(예: 수동으로 또는 다른 도구를 사용하여) 그 프록시를 플러그인을 사용하여 관리하려는 경우에 유용하다

 

forceImport 함수는 OpenZeppelin Hardhat Upgrades 플러그인의 force-import.ts 파일에 정의되어 있으며, 두 개의 인자를 받는다

 

1. proxyAddress: 이미 배포된 프록시 컨트랙트의 주소

2. ImplFactory: 프록시가 가리키는 구현 컨트랙트의 팩토리

 

이 함수의 작동 방식은 다음과 같다

  1. @openzeppelin/upgrades-core 패키지의 getImplementationAddressFromProxy 함수를 사용하여 프록시 컨트랙트에서 구현 주소를 가져온다
  2.  구현 주소가 발견되면, importProxyToManifest 함수를 사용하여 프록시를 매니페스트에 가져온다. 이 함수는 구현을 매니페스트에 추가한 다음 프록시를 매니페스트에 추가한다.
  3. 구현 주소가 발견되지 않으면, 주소가 비콘인지 isBeacon 함수를 사용하여 확인한다. 
  4. 비콘이라면, getImplementationAddressFromBeacon 함수를 사용하여 비콘에서 구현 주소를 가져오고 구현을 매니페스트에 추가한다.
  5. 주소가 비콘이 아니라면, 주소에 코드가 있는지 hasCode 함수를 사용하여 확인한다. 코드가 있다면, 구현을 매니페스트에 추가한다
  6. 주소에 코드가 없다면, NoContractImportError를 발생시킨다

매니페스트는 OpenZeppelin Upgrades 플러그인이 관리하는 모든 프록시 컨트랙트와 그들의 구현에 대한 기록이다.

이는 프로젝트의 .openzeppelin 디렉토리에 저장된다.

매니페스트는 플러그인이 컨트랙트를 추적하고 안전하게 업그레이드할 수 있도록 하는 데 사용된다. 

이는 @openzeppelin/upgrades-core 패키지의 manifest.ts 파일에 있는 Manifest 클래스에 의해 처리된다

export function makeForceImport(hre: HardhatRuntimeEnvironment): ForceImportFunction {
  return async function forceImport(
    addressOrInstance: ContractAddressOrInstance,
    ImplFactory: ContractFactory,
    opts: ForceImportOptions = {},
  ) {
    const { provider } = hre.network;
    const manifest = await Manifest.forNetwork(provider);

    const address = await getContractAddress(addressOrInstance);

    const implAddress = await getImplementationAddressFromProxy(provider, address);
    if (implAddress !== undefined) {
      await importProxyToManifest(provider, hre, address, implAddress, ImplFactory, opts, manifest);

      return attach(ImplFactory, address);
    } else if (await isBeacon(provider, address)) {
      const beaconImplAddress = await getImplementationAddressFromBeacon(provider, address);
      await addImplToManifest(hre, beaconImplAddress, ImplFactory, opts);

      const UpgradeableBeaconFactory = await getUpgradeableBeaconFactory(hre, getSigner(ImplFactory.runner));
      return attach(UpgradeableBeaconFactory, address);
    } else {
      if (!(await hasCode(provider, address))) {
        throw new NoContractImportError(address);
      }
      await addImplToManifest(hre, address, ImplFactory, opts);
      return attach(ImplFactory, address);
    }
  };
}

결론적으로

attach(ImplFactory, address);

를 반환한다.

attach 역할


attach 함수 작 흐 다 같다:
1ContractFactory 인 연 컨 주 인 받는.
2ContractFactory attach 메 호출하여, 주 주 이미 배 컨 대 참 생성한다.
3. 이 생성 참 Contract 타으로 캐 반환된. 이 ethers 라에서 attach BaseContract 반환하지, 실 사용 시 Contract 인 필하기 때
 과 통, 개 이미 배 컨 함수 호출, 컨 상 조회하는 등 작 할 수 있 된다

attach 함수 기 배 컨 상 위 인페이 제하는 것 핵심이

 

4. upgradeProxy

const greeter2 = await upgrades.upgradeProxy(greeter, GreeterV2);

 

forceImport를 한뒤 upgrades.upgradeProxy를 호출한다.

이때 logic V2는 배포를 하지 않고 팩토리를 가져온뒤 함수 내부적으로 배포를 처리하는것으로 보인다.

함수를 까본다.

return async function upgradeProxy(proxy, ImplFactory, opts: UpgradeProxyOptions = {}) {
    disableDefender(hre, defenderModule, opts, upgradeProxy.name);

    const proxyAddress = await getContractAddress(proxy);

    const { impl: nextImpl } = await deployProxyImpl(hre, ImplFactory, opts, proxyAddress);
    // upgrade kind is inferred above
    const upgradeTo = await getUpgrader(proxyAddress, opts, getSigner(ImplFactory.runner));
    const call = encodeCall(ImplFactory, opts.call);
    const upgradeTx = await upgradeTo(nextImpl, call);

    const inst = attach(ImplFactory, proxyAddress);
    // @ts-ignore Won't be readonly because inst was created through attach.
    inst.deployTransaction = upgradeTx;
    return inst;
  };

체크 할 함수 

1. deployProxyImpl

2. getUpgrader

3. encodeCall

4. upgradeTo

1. deployProxyImpl

 const { impl: nextImpl } = await deployProxyImpl(hre, ImplFactory, opts, proxyAddress);

업그레이드 할 logic2의 팩토리를 인자값으로 가져간다.

export async function deployProxyImpl(
  hre: HardhatRuntimeEnvironment,
  ImplFactory: ContractFactory,
  opts: UpgradeOptions,
  proxyAddress?: string,
): Promise<DeployedProxyImpl> {
  const deployData = await getDeployData(hre, ImplFactory, opts);
  await validateProxyImpl(deployData, opts, proxyAddress);
  if (opts.kind === undefined) {
    throw new Error('Broken invariant: Proxy kind is undefined');
  }
  return {
    ...(await deployImpl(hre, deployData, ImplFactory, opts)),
    kind: opts.kind,
  };
}

getDeployData 함수를 까보면 배포할 컨트렉트의 데이터들을 가져온다.

그리고 validateProxyImpl에 logic2의 데이터와 proxyAddress를 넘겨준다.

export async function validateProxyImpl(
  deployData: DeployData,
  opts: ValidationOptions,
  proxyAddress?: string,
): Promise<void> {
  const currentImplAddress = await processProxyImpl(deployData, proxyAddress, opts);
  return validateImpl(deployData, opts, currentImplAddress);
}
/**
 * Processes the proxy kind and returns the implementation address if proxyAddress is provided.
 */
async function processProxyImpl(deployData: DeployData, proxyAddress: string | undefined, opts: ValidationOptions) {
  await processProxyKind(deployData.provider, proxyAddress, opts, deployData.validations, deployData.version);

  let currentImplAddress: string | undefined;
  if (proxyAddress !== undefined) {
    // upgrade scenario
    currentImplAddress = await getImplementationAddress(deployData.provider, proxyAddress);
  }
  return currentImplAddress;
}

processProxyImpl 함수를 호출하여 프록시 컨트랙트의 종류를 처리하고, 프록시 컨트랙트의 현재 구현체 주소를 가져온다.  함수는 프록시 컨트랙트의 종류를 확인하고, 프록시 컨트랙트가 가리키는 현재 구현체의 주소를 반환한다.

export async function getImplementationAddress(provider: EthereumProvider, address: string): Promise<string> {
  const storage = await getStorageFallback(
    provider,
    address,
    toEip1967Hash('eip1967.proxy.implementation'),
    toFallbackEip1967Hash('org.zeppelinos.proxy.implementation'),
  );

  if (isEmptySlot(storage)) {
    throw new EIP1967ImplementationNotFound(
      `Contract at ${address} doesn't look like an ERC 1967 proxy with a logic contract address`,
    );
  }

  return parseAddressFromStorage(storage);
}

 

getImplementationAddress 함수를 호출하여 프록시 컨트랙트의 현재 구현체 주소를 가져온다.  함수는 프록시 컨트랙트의 주소를 인자로 받아, 해당 프록시 컨트랙트가 가리키는 구현체의 주소를 반환한다

getStorageFallback 함수를 살펴보자

async function getStorageFallback(provider: EthereumProvider, address: string, ...slots: string[]): Promise<string> {
  let storage = '0x0000000000000000000000000000000000000000000000000000000000000000'; // default: empty slot

  for (const slot of slots) {
    storage = await getStorageAt(provider, address, slot);
    if (!isEmptySlot(storage)) {
      break;
    }
  }

  return storage;
}
export async function getStorageAt(
  provider: EthereumProvider,
  address: string,
  position: string,
  block = 'latest',
): Promise<string> {
  const storage = await provider.send('eth_getStorageAt', [address, position, block]);
  const padded = storage.replace(/^0x/, '').padStart(64, '0');
  return '0x' + padded;
}

1. provider, address, slots를 인자로 받는다. 여기서 provider는 Ethereum 프로바이더를, address는 스토리지를 조회할 컨트랙트의 주소를, slots는 조회할 스토리지 슬롯의 목록을 나타낸다.

2. 기본적으로 스토리지 값은 비어있는 슬롯(0x0000000000000000000000000000000000000000000000000000000000000000)으로 설정된다.

3. slots 배열의 각 슬롯에 대해 다음을 수행한다:

- getStorageAt 함수를 호출하여 해당 슬롯의 스토리지 값을 가져온다.

- 가져온 스토리지 값이 비어있지 않다면(isEmptySlot 함수를 사용하여 확인), 루프를 중단하고 해당 값을 반환한다.

4. 모든 슬롯이 비어있다면, 기본적으로 설정된 비어있는 슬롯 값을 반환한다.

 

 함수는 Ethereum 스토리지의 특정 슬롯에 저장된 값을 조회하는  사용되며, 여러 슬롯을 순회하면서 비어있지 않은  번째 값을 찾는 기능을 제공한다. 이는 컨트랙트의 상태를 조회하거나, 특정 조건에 따라 다른 슬롯을 조회해야 하는 경우에 유용하다.

 

스토리지 슬롯에 저장된 data를 

parseAddressFromStorage 함수에 위에서 도출된 데이터를 인자값으로 넣어준다

function parseAddressFromStorage(storage: string): string {
  const address = parseAddress(storage);
  if (address === undefined) {
    throw new Error(`Value in storage is not an address (${storage})`);
  }
  return address;
}

 

export function parseAddress(addressString: string): string | undefined {
  const buf = Buffer.from(addressString.replace(/^0x/, ''), 'hex');
  if (!buf.slice(0, 12).equals(Buffer.alloc(12, 0))) {
    return undefined;
  }
  const address = '0x' + buf.toString('hex', 12, 32); // grab the last 20 bytes
  return toChecksumAddress(address);
}

이 코드는 Ethereum 스토리지에서 가져온 값을 Ethereum 주소로 파싱하는 역할을 한다

 

parseAddressFromStorage 함수는 스토리지에서 가져온 값을 인자로 받아 parseAddress 함수를 호출한다.

 parseAddress 함수는 주어진 문자열이 유효한 Ethereum 주소인지 확인하고, 유효하다면 해당 주소를 반환한다

 만약 유효하지 않다면 undefined를 반환한다

 

parseAddress 함수의 작동 방식은 다음과 같다:

 

1. 주어진 문자열에서 0x 접두사를 제거하고, 나머지 부분을 16진수(hex)로 해석하여 바이트 버퍼(Buffer)를 생성

2. 생성된 버퍼의 처음 12바이트가 모두 0인지 확인. Ethereum 주소는 20바이트 길이를 가지므로, 스토리지에서 가져온 32바이트 값의 앞 12바이트는 0이어야 한다 

만약 이 조건을 만족하지 않는다면, 주어진 값은 유효한 Ethereum 주소가 아니므로 undefined를 반환.

3. 버퍼의 마지막 20바이트를 가져와 Ethereum 주소를 생성.

4. 생성된 주소를 체크섬 주소 형식으로 변환하여 반환. 체크섬 주소는 대소문자를 혼용하여 주소의 올바름을 검증할 수 있는 형식(toChecksumAddress 함수를 사용).

 

따라서, 이 코드는 Ethereum 스토리지에서 가져온 값을 유효한 Ethereum 주소로 파싱하는 역할을 수행한다

 만약 주어진 값이 유효한 주소가 아니라면, parseAddressFromStorage 함수는 에러를 발생시킨다

 

간략히하자면 슬롯의 데이터를 주소로 변환해주는 역할을 한다. 이렇게 implementionAddress를 받아온다.

processProxyImpl함수=>implementionAddress반환

 

 

export async function validateImpl(
  deployData: DeployData,
  opts: ValidationOptions,
  currentImplAddress?: string,
): Promise<void> {
  assertUpgradeSafe(deployData.validations, deployData.version, deployData.fullOpts);

  if (currentImplAddress !== undefined) {
    const manifest = await Manifest.forNetwork(deployData.provider);
    const currentLayout = await getStorageLayoutForAddress(manifest, deployData.validations, currentImplAddress);
    if (opts.unsafeSkipStorageCheck !== true) {
      assertStorageUpgradeSafe(currentLayout, deployData.layout, deployData.fullOpts);
    }
  }
}

 

validateImpl 함수는 스마트 컨트랙트의 구현체가 업그레이드를 안전하게 수행 할 수 있는지 검증하는 역할을 한다.

1. assertUpgradeSafe: 제공된 deployData.validations, deployData.version, 그리고 deployData.fullOpts를 사용하여 구현체가 업그레이드에 안전한지 확인. 이는 구현체의 바이트코드와 ABI가 업그레이드를 위한 요구사항을 충족하는지 검증.

2. currentImplAddress가 제공되었는지 확인. 이 주소가 제공되면, 현재 구현체와 관련된 추가 검증이 수행.

3. Manifest.forNetwork: 현재 네트워크에 대한 매니페스트(Manifest)를 가져온다. 매니페스트는 배포된 컨트랙트와 관련된 메타데이터를 저장하는 객체이다.

4. getStorageLayoutForAddress: 현재 구현체의 스토리지 레이아웃을 가져온다. 이는 업그레이드 전후의 스토리지 호환성을 확인하는 데 사용된다.

5. opts.unsafeSkipStorageCheck가 true로 설정되지 않았는지 확인한다. 이 옵션이 true로 설정되면, 스토리지 호환성 검증을 건너뛴다.

6. assertStorageUpgradeSafe: 현재 구현체의 스토리지 레이아웃과 새로운 구현체의 스토리지 레이아웃이 호환되는지 확인한다. 이는 스토리지 충돌이나 데이터 손실 없이 업그레이드를 수행할 수 있는지 검증한다.

 함수는 스마트 컨트랙트를 업그레이드하기 전에 필수적인 검증 단계를 수행하여, 업그레이드가 안전하게 이루어질  있도록 한다.

validateProxyImpl은 주어진 구현체가 업그레이드에 안전한지 검증하고 문제가 발생되면 에러를 발생시키는 용도이다.

 

이제  다음장에  getUpgrader를 분석하자

'솔리디티' 카테고리의 다른 글

delegatecall : immutable, constant 예제  (0) 2024.02.03
업그레이더블 컨트랙트 정리  (0) 2023.07.19
ERC-4337 , Account Abstraction  (0) 2023.04.04
EIP-4337 이해하기  (0) 2023.03.22
call vs delegate call 알아보기  (0) 2023.01.04

댓글