마스터마인드처럼 계약을 검증하는 방법

발행: (2025년 12월 20일 오후 08:46 GMT+9)
23 min read
원문: Dev.to

I’m happy to translate the article for you, but I need the text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source line, formatting, markdown, and any code blocks or URLs exactly as they are.

Abstract

스마트 계약 검증은 DeFi 생태계에서 신원에 대한 최종적인 증명으로, 불투명한 바이트코드를 신뢰할 수 있는 로직으로 변환합니다. 그러나 이 과정은 종종 오해를 낳아, 컴파일러의 **“Deterministic Black Box”**가 일치하지 않는 지문을 생성할 때 좌절감을 초래합니다. 본 기사에서는 검증을 **“거울 메커니즘”**으로 시각화하여, 로컬 컴파일 환경이 배포 조건을 정확히 재현해야 함을 설명합니다.

우리는 수동 웹 업로드를 넘어, CLI 도구와 Standard JSON Input을 활용한 견고하고 자동화된 워크플로를 구축합니다—이는 모호한 검증 오류에 맞서는 궁극적인 무기입니다. 마지막으로, 공격적인 viaIR 가스 최적화와 검증 복잡성 사이의 중요한 트레이드‑오프를 분석하여, 회복력 있고 투명한 프로토콜을 설계하기 위한 전략적 프레임워크를 제공합니다.

소개

스마트 계약 검증은 Etherscan에서 초록색 체크 표시를 얻는 것만을 의미하지 않습니다; 이는 코드의 정체성을 입증하는 확정적인 증거입니다. 배포된 후 계약은 원시 바이트코드로 축소되어 그 출처가 사실상 사라집니다. 신뢰할 수 없는 환경에서 소스를 증명하고 소유권을 확립하려면 검증이 필수적입니다. 이는 DeFi 생태계에서 투명성, 보안 및 조합성을 위한 기본 요구 사항입니다.

이 검증이 없으면 계약은 불투명한 16진수 바이트코드 덩어리로 남아, 사용자에게는 읽을 수 없고 다른 개발자에게는 활용할 수 없습니다.

The Mirror Mechanism

검증 오류를 극복하려면 먼저 “Verify” 버튼을 눌렀을 때 실제로 무슨 일이 일어나는지 이해해야 합니다. 이 과정은 겉보기와는 달리 단순합니다. 블록 탐색기(예: Etherscan)는 제공된 소스 코드가 체인에 배포된 바이트코드와 정확히 동일한 바이트코드를 생성한다는 것을 증명하기 위해 여러분의 컴파일 환경을 그대로 재현해야 합니다.

Figure 1에 표시된 바와 같이, 이 과정은 “Mirror Mechanism(거울 메커니즘)” 으로 작동합니다. 검증자는 여러분의 소스 코드를 독립적으로 컴파일하고, 그 결과 바이트코드를 온‑체인 데이터와 바이트 단위로 비교합니다.

하나라도 바이트가 다르면, 검증은 실패합니다. 이것이 모든 Solidity 개발자가 겪는 핵심적인 고민입니다.

결정론적 블랙 박스

이론적으로는 “바이트‑완벽” 매칭이 쉬워 보입니다. 실제로는 악몽이 시작되는 지점이죠. 개발자는 로컬 테스트를 100 % 통과하는 완벽하게 동작하는 dApp을 가지고 있을지라도, 검증 단계에서 꼼짝 못하는 상황에 처할 수 있습니다.

왜일까요? Solidity 컴파일러가 Deterministic Black Box이기 때문입니다. Figure 2에 표시된 바와 같이, 출력 바이트코드는 소스 코드만으로는 결정되지 않습니다. 이는 수십 개의 보이지 않는 변수들의 결과물입니다: 컴파일러 버전, 최적화 실행 횟수, 메타데이터 해시, 그리고 심지어 특정 EVM 버전까지 포함됩니다.

hardhat.config.ts와 Etherscan이 가정하는 설정 사이에 사소한 차이—예를 들어 viaIR 설정이 다르거나 프록시 구성이 누락된 경우—는 완전히 다른 바이트코드 해시(Bytecode B)를 만들어 내며, 이로 인해 무시무시한 “Bytecode Mismatch” 오류가 발생합니다.

이 가이드는 검증이 *“되길 바란다”*는 개발자에서 블랙 박스를 직접 제어하는 마스터마인드로 변모시키는 것을 목표로 합니다. 표준 CLI 흐름, 수동 오버라이드 방법을 살펴보고, 마지막으로 고급 최적화가 이 취약한 과정에 어떤 영향을 미치는지 데이터 기반 인사이트를 제공할 것입니다.

Source:

CLI 접근법 – 정밀함과 자동화

이전 섹션에서는 검증 과정을 “거울 메커니즘”(Figure 1)으로 시각화했습니다. 목표는 로컬 컴파일 결과가 원격 환경과 완벽히 일치하도록 하는 것입니다. 웹 UI를 통해 수동으로 수행하면 오류가 발생하기 쉬운데, 컴파일러 버전 드롭다운을 한 번만 잘못 클릭해도 해시가 깨질 수 있습니다.

이때 명령줄 인터페이스 (CLI) 도구가 빛을 발합니다. 배포와 검증에 동일한 설정 파일(hardhat.config.ts 또는 foundry.toml)을 사용함으로써 CLI 도구는 일관성을 강제하고, 결정론적 블랙 박스(Figure 2)를 관리 가능한 파이프라인으로 축소합니다.

Hardhat 검증

대부분의 개발자에게 hardhat-verify 플러그인은 첫 번째 방어선입니다. 이 플러그인은 빌드 아티팩트를 자동으로 추출하고 Etherscan API와 직접 통신합니다.

플러그인 활성화hardhat.config.ts에 Etherscan 설정이 포함되어 있는지 확인하면 됩니다. 여기서 가장 흔히 발생하는 실패 지점이 네트워크 불일치입니다.

// hardhat.config.ts
import "@nomicfoundation/hardhat-verify";

module.exports = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true, // 중요: 배포 시와 동일해야 함!
        runs: 200,
      },
      viaIR: true, // 자주 간과되며, 큰 바이트코드 차이를 유발함
    },
  },
  etherscan: {
    apiKey: {
      // 체인별로 다른 키를 사용해 레이트 제한을 피하세요
      mainnet: "YOUR_ETHERSCAN_API_KEY",
      sepolia: "YOUR_ETHERSCAN_API_KEY",
    },
  },
};

명령어

설정이 완료되면 검증 명령은 매우 간단합니다. 로컬에서 계약을 다시 컴파일해 아티팩트를 생성한 뒤, 소스 코드를 Etherscan에 제출합니다.

전문가 팁: 검증 전에 항상 npx hardhat clean을 실행하세요. 이전 컴파일에서 다른 설정으로 생성된 오래된 아티팩트(캐시된 바이트코드)는 검증을 조용히 방해하는 주요 원인입니다.

npx hardhat verify --network sepolia

생성자 인자 함정

계약에 생성자가 있는 경우 검증이 크게 어려워집니다. CLI는 배포 시 전달한 정확한 값을 알아야 생성 코드 서명을 재현할 수 있습니다.

스크립트를 사용해 배포했다면, 단일 진실의 원천을 유지하기 위해 별도의 인자 파일(예: arguments.ts)을 만들세요.

// arguments.ts
module.exports = [
  "0x123...TokenAddress", // _token
  "My DAO Name",          // _name
  1000000n                // _initialSupply (uint256용 BigInt 사용)
];

왜 중요한가: 흔히 발생하는 오류는 1000000(숫자) 대신 "1000000"(문자열)이나 1000000n(BigInt)을 전달하지 않는 것입니다. CLI 도구는 이를 ABI 헥스로 인코딩하는 방식이 다르기 때문에, 인코딩이 한 비트라도 다르면 바이트코드 서명이 바뀌고 Figure 1의 “비교” 단계에서 불일치가 발생합니다.

Foundry 검증

Foundry를 선호하는 사람들을 위해 검증 워크플로우도 비슷한 철학을 따릅니다: 배포와 검증에 동일한 foundry.toml을 사용하고 forge verify-contract 명령을 활용합니다.

Foundry 툴체인 사용하기

Verification은 매우 빠르고 Forge에 기본으로 내장되어 있습니다. 플러그인이 필요한 Hardhat과 달리, Foundry는 별도 설정 없이 검증을 처리합니다.

forge verify-contract \
  --chain-id 11155111 \
  --num-of-optimizations 200 \
  --watch \
  src/MyContract.sol:MyContract

--watch의 강점

--watch 플래그는 “verbose mode”와 비슷하게 동작하여 Etherscan에서 검증 상태를 지속적으로 폴링합니다. 제출이 승인되었는지 혹은 실패했는지(예: “Bytecode Mismatch”)에 대한 즉각적인 피드백을 제공해 브라우저 창을 계속 새로 고치는 번거로움을 없애줍니다.

일반적인 검증 함정

완벽한 설정을 했음에도 불구하고 AggregateError 또는 “Fail – Unable to verify.”와 같은 불투명한 오류가 발생할 수 있습니다. 이는 주로 다음과 같은 경우에 일어납니다:

  • 체인형 import – 계약이 50 개 이상의 파일을 import 하고, Etherscan API가 방대한 JSON 페이로드를 처리하는 동안 타임아웃이 발생합니다.
  • 라이브러리 링크 – 계약이 아직 검증되지 않은 외부 라이브러리에 의존하고 있습니다.

이러한 “Code Red” 상황에서는 CLI가 한계에 도달합니다. 자동 스크립트를 포기하고 Standard JSON Input 방식을 사용해 수동으로 검증해야 합니다.

Standard JSON Input

hardhat‑verify가 불투명한 AggregateError를 발생시키거나 네트워크가 느려서 타임아웃이 발생하면, 많은 개발자들이 당황해 “플래튼” 플러그인을 사용해 수십 개의 파일을 하나의 거대한 .sol 파일로 합치려 합니다.

컨트랙트를 플래튼하지 마세요. 플래튼은 프로젝트 구조를 파괴하고, import를 깨뜨리며, 종종 라이선스 식별자를 손상시켜 검증 오류를 더 많이 발생시킵니다.

왜 Standard JSON이 전문가용 대안인지

Solidity 컴파일러(solc)를 기계라고 생각하세요. VS Code 설정, node_modules 폴더, 혹은 remapping은 전혀 신경 쓰지 않습니다. 오직 한 가지만 신경 씁니다: 소스 코드와 컴파일 설정을 담은 특정 JSON 객체.

Standard JSON은 검증의 공통 언어입니다 — 다음을 감싸는 단일 JSON 파일입니다:

FieldWhat it contains
language"Solidity"
settingsOptimizer runs, EVM version, viaIR, remappings, etc.
sources모든 파일(오픈제플린 의존성 포함)의 사전이며, 파일 내용이 문자열로 삽입되어 있습니다.

Standard JSON을 사용하면 파일 시스템을 방정식에서 제외하고, 컴파일러가 필요로 하는 정확한 원시 데이터 페이로드를 Etherscan에 전달할 수 있습니다.

Hardhat에서 “골든 티켓” 추출하기

이 JSON을 직접 작성할 필요는 없습니다. Hardhat은 컴파일할 때마다 자동으로 생성하지만 artifacts 폴더 깊숙이 숨겨 둡니다.

“긴급 상황 시 파손” 절차

  1. npx hardhat compile을 실행합니다.
  2. artifacts/build-info/ 디렉터리로 이동합니다.
  3. 해시 이름을 가진 JSON 파일을 찾습니다 (예: a1b2c3...json).
  4. 파일을 열고 최상위 input 객체를 찾습니다.
  5. 전체 input 객체를 복사하여 verify.json으로 저장합니다.

마스터마인드 팁: verify.json진실의 근원입니다. 여기에는 계약의 원시 텍스트와 컴파일에 사용된 정확한 설정이 들어 있습니다. 이 파일이 로컬에서 바이트코드를 재현한다면 Etherscan에서도 정상적으로 작동합니다.

빌드‑info를 찾을 수 없거나 비표준 환경에서 작업 중이라면, 짧은 TypeScript 스크립트를 사용해 Standard JSON Input을 직접 생성할 수 있습니다.

스크립트: generate-verify-json.ts

// scripts/generate-verify-json.ts
import * as fs from "fs";
import * as path from "path";

/* 1️⃣ Define the Standard JSON interface for type safety */
interface StandardJsonInput {
  language: string;
  sources: { [key: string]: { content: string } };
  settings: {
    optimizer: { enabled: boolean; runs: number };
    evmVersion: string;
    viaIR?: boolean; // optional but crucial if used
    outputSelection: {
      [file: string]: {
        [contract: string]: string[];
      };
    };
  };
}

/* 2️⃣ Strict configuration */
const config: StandardJsonInput = {
  language: "Solidity",
  sources: {},
  settings: {
    optimizer: { enabled: true, runs: 200 },
    evmVersion: "paris", // ⚠️ Must match deployment!
    viaIR: true,         // Include if you used it
    outputSelection: {
      "*": {
        "*": ["abi", "evm.bytecode", "evm.deployedBytecode", "metadata"],
      },
    },
  },
};

/* 3️⃣ Load your contract and its dependencies manually */
const files: string[] = [
  "contracts/MyToken.sol",
  "node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol",
  "node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol",
  // ... list all dependencies here
];

files.forEach((filePath) => {
  // Etherscan expects the key to match the import statement in Solidity
  const importPath = filePath.includes("node_modules/")
    ? filePath.replace("node_modules/", "")
    : filePath;

  if (fs.existsSync(filePath)) {
    config.sources[importPath] = {
      content: fs.readFileSync(filePath, "utf8"),
    };
  } else {
    console.error(`❌ File not found: ${filePath}`);
    process.exit(1);
  }
});

/* 4️⃣ Write the Golden Ticket */
const outputPath = path.resolve(__dirname, "../verify.json");
fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
console.log(`✅ Standard JSON generated at: ${outputPath}`);

왜 이것이 항상 작동하는가

  • 메타데이터 해시 보존 – Standard JSON은 컴파일러가 본 다중 파일 구조를 그대로 유지하므로 메타데이터 해시가 배포된 바이트코드와 일치합니다.
  • 소스 코드 변형 없음 – 플래튼(flattening)은 import와 라인 순서를 재작성하여 메타데이터 지문을 변경시킬 수 있으며, 이로 인해 불일치가 발생할 수 있습니다.
  • 결정론적 – Standard JSON으로 검증이 실패한다면 문제는 100 % 설정(옵티마이저 실행 횟수, EVM 버전, viaIR 등)에 있으며, 소스 코드에 있지 않습니다.

viaIR 트레이드‑오프

IR 파이프라인(viaIR: true)으로 컴파일하면 생성된 바이트코드가 클래식 파이프라인과 다를 수 있습니다. 배포 시 사용한 플래그와 Standard JSON에 지정한 viaIR 플래그가 일치하도록 하세요. 그렇지 않으면 Etherscan이 바이트코드 불일치를 보고합니다.

검증을 즐기세요!

Source:

마무리하기 전에, 우리는 방 안의 코끼리를 다루어야 합니다: viaIR

현대 Solidity 개발(특히 v0.8.20 이상)에서는 viaIR를 활성화하는 것이 최소 가스 비용을 달성하기 위한 표준이 되었지만, 검증 복잡성이라는 높은 대가를 수반합니다.

파이프라인 전환

왜 단순한 true/false 플래그가 이렇게 큰 혼란을 일으킬까요?
그 이유는 컴파일 경로를 근본적으로 바꾸기 때문입니다.

파이프라인설명
Legacy PipelineSolidity를 직접 opcode로 변환합니다. 구조가 대부분 코드와 일치합니다.
IR PipelineSolidity를 먼저 Yul(Intermediate Representation)로 변환합니다. 옵티마이저가 이 Yul 코드를 공격적으로 재작성하여 함수 인라인 및 스택 연산 재배열을 수행한 뒤 바이트코드를 생성합니다.

Figure 3에 표시된 바와 같이, Bytecode B는 Bytecode A와 구조적으로 다릅니다. IR 파이프라인으로 배포된 계약을 레거시 설정으로 검증할 수 없습니다. 이는 바이너리 커밋입니다.

가스 효율성 vs. 검증 가능성

viaIR를 활성화하는 결정은 이더리움 개발 비용 구조의 근본적인 변화를 의미합니다. 이는 단순히 컴파일러 플래그가 아니라 실행 효율성과 컴파일 안정성 사이의 트레이드‑오프입니다.

  • Legacy pipeline – 컴파일러는 주로 번역기 역할을 하여 Solidity 문장을 로컬 피프홀 최적화와 함께 opcode로 변환합니다. 결과 바이트코드는 예측 가능하고 소스 코드의 구문 구조를 밀접하게 반영합니다. 그러나 이 접근 방식은 한계에 부딪힙니다: 복잡한 DeFi 프로토콜은 종종 “Stack Too Deep” 오류를 겪으며, 함수 간 최적화를 수행하지 못해 사용자는 비효율적인 스택 관리에 대한 비용을 지불하게 됩니다.

  • IR pipeline – 전체 계약을 Yul에서 하나의 전체론적 수학 객체로 취급합니다. 함수 인라인, 메모리 슬롯 재배열, 전체 코드베이스에 걸친 중복 스택 연산 제거를 공격적으로 수행합니다. 이는 최종 사용자에게 훨씬 저렴한 트랜잭션 비용을 제공합니다.

하지만 이러한 최적화는 개발자에게 큰 대가를 요구합니다. 소스 코드와 머신 코드 사이의 “거리”가 크게 확대되어 검증에 두 가지 주요 과제가 발생합니다:

  1. 구조적 차이 – 옵티마이저가 가스 절감을 위해 로직 흐름을 재작성하기 때문에, 결과 바이트코드는 소스와 구조적으로 인식하기 어렵습니다. 의미적으로 동등한 두 함수도 계약 내 다른 위치에서 호출되는 방식에 따라 전혀 다른 바이트코드 시퀀스로 컴파일될 수 있습니다.

  2. “버터플라이 효과” – IR 파이프라인에서는 전역 설정의 사소한 변경(예: runs를 200에서 201로 변경)도 전체 Yul 최적화 트리를 통해 전파됩니다. 몇 바이트만 바뀌는 것이 아니라 계약 전체의 지문을 재구성할 수 있습니다.

따라서 viaIR를 활성화하는 것은 부담의 이전(transf er)입니다. 우리는 개발자에게(긴 컴파일 시간, 취약한 검증, 엄격한 설정 관리) 부담을 자발적으로 늘리고, 사용자에게(낮은 가스 수수료) 부담을 줄입니다. 마스터마인드 엔지니어로서 여러분은 이 트레이드‑오프를 수용해야 하며, 검증 과정에 도입되는 취약성을 존중해야 합니다.

결론

DeFi의 어두운 숲에서, 코드는 법이지만 검증된 코드는 정체성이다.

우리는 검증 과정을 마법 버튼이 아니라 “

Back to Blog

관련 글

더 보기 »