go-ethereum: gas estimate

Smart contract gas fee

우리가 스마트 컨트랙트의 코드를 실행할 때, 해당 코드는 EVM에서 실행되고 gas estimate를 거친 뒤, 특정 가스비가 소모된다.

geth 클라이언트는 몇 차례 써본 적이 있으나 사실 가스비가 정확하게 어떻게 책정되고 어떻게 소모되는지에 대해서는 모른다.

그러나 분명히 어딘가에 코드로 해당 비즈니스 로직을 구현한 곳이 있을 것이다.

이에 대해서 go-ethereum 코드를 분석하면서 알아보려고 한다.

코드가 워낙 방대해서 시행착오가 꽤 많았다.

우선 geth 패키지를 이용해서 gas estimate를 하는 부분에 대해서 찾아봤다.

gas estimate를 하는 함수는 다음과 같다.

var conn *ethclient.Client
var err error

// Dial rpc endpoint
if conn, err = ethclient.Dial(rpcEndpoint); err != nil {
    return nil, err
}

conn.EstimateGas(context.Background(), ethereum.CallMsg{
    ... 
})

EstimatGas 함수의 definition을 찾아보면 아래와 같다.

package ethclient

// EstimateGas tries to estimate the gas needed to execute a specific transaction based on
// the current pending state of the backend blockchain. There is no guarantee that this is
// the true gas limit requirement as other transactions may be added or removed by miners,
// but it should provide a basis for setting a reasonable default.
func (ec *Client) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) {
  var hex hexutil.Uint64
  err := ec.c.CallContext(ctx, &hex, "eth_estimateGas", toCallArg(msg))
  if err != nil {
    return 0, err
  }
  return uint64(hex), nil
}

봐야할 부분이 굉장히 많다. 천리 길도 한 걸음 부터라고 하니까 하나하나 뜯어보자.

hexutil은 단순히 16진수를 위해 go-ethereum에서 사용하는 패키지이다.

Client 객체 자체는 아래와 같이 생겼다. ec.c 부분의 c는 rpc client를 말한다.

package ethclient

// Client defines typed wrappers for the Ethereum RPC API.
type Client struct {
  c *rpc.Client
}

rpc client를 통해서 CallContext라는 함수를 실행한다.

해당 메소드를 보자.

package rpc

// CallContext performs a JSON-RPC call with the given arguments. If the context is
// canceled before the call has successfully returned, CallContext returns immediately.
//
// The result must be a pointer so that package json can unmarshal into it. You
// can also pass nil, in which case the result is ignored.
func (c *Client) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error {
  if result != nil && reflect.TypeOf(result).Kind() != reflect.Ptr {
    return fmt.Errorf("call result parameter must be pointer or nil interface: %v", result)
  }
  msg, err := c.newMessage(method, args...)
  if err != nil {
    return err
  }
  op := &requestOp{ids: []json.RawMessage{msg.ID}, resp: make(chan *jsonrpcMessage, 1)}

  if c.isHTTP {
    err = c.sendHTTP(ctx, op, msg)
  } else {
    err = c.send(ctx, op, msg)
  }
  if err != nil {
    return err
  }

  // dispatch has accepted the request and will close the channel when it quits.
  switch resp, err := op.wait(ctx, c); {
  case err != nil:
    return err
  case resp.Error != nil:
    return resp.Error
  case len(resp.Result) == 0:
    return ErrNoResult
  default:
    if result == nil {
      return nil
    }
    return json.Unmarshal(resp.Result, result)
  }
}

여기서의 Client는 rpc.Client이다.

컨텍스트와 결과값, 메소드, 인자를 받아서 rpc 호출을 시도한다.

좀 전에 EstimateGas 에서 CallContext를 호출할 때 result로 hex 변수의 주소값을 넘겼다.

따라서 result에 결과값을 담는 방식으로 진행이 될 것이다.

여기서 중요한 것은 message를 통해서 결국 “eth_estimateGas” 라는 메소드를 실행한다는 것인데, 결국 가스비를 estimate하는 로직은 해당 메소드에 있을거라는 것이다.

따라서 우리가 찾아야하는 것은 “eth_estimateGas” 의 구현체이다.

go-ethereum의 ethapi 패키지를 찾아보면, 다음 메소드를 찾을 수 있다.

package ethapi
// EstimateGas returns the lowest possible gas limit that allows the transaction to run
// successfully at block `blockNrOrHash`, or the latest block if `blockNrOrHash` is unspecified. It
// returns error if the transaction would revert or if there are unexpected failures. The returned
// value is capped by both `args.Gas` (if non-nil & non-zero) and the backend's RPCGasCap
// configuration (if non-zero).
func (s *BlockChainAPI) EstimateGas(ctx context.Context, args TransactionArgs, blockNrOrHash *rpc.BlockNumberOrHash, overrides *StateOverride) (hexutil.Uint64, error) {
	bNrOrHash := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber)
	if blockNrOrHash != nil {
		bNrOrHash = *blockNrOrHash
	}
	return DoEstimateGas(ctx, s.b, args, bNrOrHash, overrides, s.b.RPCGasCap())
}

블록 넘버/해시를 받아서 DoEstimateGas 함수를 실행한다.

DoEstimatsGas 역시 같은 파일에 위치하는데, 함수가 좀 길다.

package ethapi

// DoEstimateGas returns the lowest possible gas limit that allows the transaction to run
// successfully at block `blockNrOrHash`. It returns error if the transaction would revert, or if
// there are unexpected failures. The gas limit is capped by both `args.Gas` (if non-nil &
// non-zero) and `gasCap` (if non-zero).
func DoEstimateGas(ctx context.Context, b Backend, args TransactionArgs, blockNrOrHash rpc.BlockNumberOrHash, overrides *StateOverride, gasCap uint64) (hexutil.Uint64, error) {
  // Binary search the gas limit, as it may need to be higher than the amount used
  var (
    lo uint64 // lowest-known gas limit where tx execution fails
    hi uint64 // lowest-known gas limit where tx execution succeeds
  )
  // Use zero address if sender unspecified.
  if args.From == nil {
    args.From = new(common.Address)
  }
  // Determine the highest gas limit can be used during the estimation.
  if args.Gas != nil && uint64(*args.Gas) >= params.TxGas {
    hi = uint64(*args.Gas)
  } else {
    // Retrieve the block to act as the gas ceiling
    block, err := b.BlockByNumberOrHash(ctx, blockNrOrHash)
    if err != nil {
      return 0, err
    }
    if block == nil {
      return 0, errors.New("block not found")
    }
    hi = block.GasLimit()
  }
  // Normalize the max fee per gas the call is willing to spend.
  var feeCap *big.Int
  if args.GasPrice != nil && (args.MaxFeePerGas != nil || args.MaxPriorityFeePerGas != nil) {
    return 0, errors.New("both gasPrice and (maxFeePerGas or maxPriorityFeePerGas) specified")
  } else if args.GasPrice != nil {
    feeCap = args.GasPrice.ToInt()
  } else if args.MaxFeePerGas != nil {
    feeCap = args.MaxFeePerGas.ToInt()
  } else {
    feeCap = common.Big0
  }

  state, header, err := b.StateAndHeaderByNumberOrHash(ctx, blockNrOrHash)
  if state == nil || err != nil {
    return 0, err
  }
  if err := overrides.Apply(state); err != nil {
    return 0, err
  }

  // Recap the highest gas limit with account's available balance.
  if feeCap.BitLen() != 0 {
    balance := state.GetBalance(*args.From) // from can't be nil
    available := new(big.Int).Set(balance)
    if args.Value != nil {
      if args.Value.ToInt().Cmp(available) >= 0 {
        return 0, core.ErrInsufficientFundsForTransfer
      }
      available.Sub(available, args.Value.ToInt())
    }
    allowance := new(big.Int).Div(available, feeCap)

    // If the allowance is larger than maximum uint64, skip checking
    if allowance.IsUint64() && hi > allowance.Uint64() {
      transfer := args.Value
      if transfer == nil {
        transfer = new(hexutil.Big)
      }
      log.Warn("Gas estimation capped by limited funds", "original", hi, "balance", balance,
        "sent", transfer.ToInt(), "maxFeePerGas", feeCap, "fundable", allowance)
      hi = allowance.Uint64()
    }
  }
  // Recap the highest gas allowance with specified gascap.
  if gasCap != 0 && hi > gasCap {
    log.Warn("Caller gas above allowance, capping", "requested", hi, "cap", gasCap)
    hi = gasCap
  }

  // We first execute the transaction at the highest allowable gas limit, since if this fails we
  // can return error immediately.
  failed, result, err := executeEstimate(ctx, b, args, state.Copy(), header, gasCap, hi)
  if err != nil {
    return 0, err
  }
  if failed {
    if result != nil && result.Err != vm.ErrOutOfGas {
      if len(result.Revert()) > 0 {
        return 0, newRevertError(result)
      }
      return 0, result.Err
    }
    return 0, fmt.Errorf("gas required exceeds allowance (%d)", hi)
  }
  // For almost any transaction, the gas consumed by the unconstrained execution above
  // lower-bounds the gas limit required for it to succeed. One exception is those txs that
  // explicitly check gas remaining in order to successfully execute within a given limit, but we
  // probably don't want to return a lowest possible gas limit for these cases anyway.
  lo = result.UsedGas - 1

  // Binary search for the smallest gas limit that allows the tx to execute successfully.
  for lo+1 < hi {
    mid := (hi + lo) / 2
    if mid > lo*2 {
      // Most txs don't need much higher gas limit than their gas used, and most txs don't
      // require near the full block limit of gas, so the selection of where to bisect the
      // range here is skewed to favor the low side.
      mid = lo * 2
    }
    failed, _, err = executeEstimate(ctx, b, args, state.Copy(), header, gasCap, mid)
    if err != nil {
      // This should not happen under normal conditions since if we make it this far the
      // transaction had run without error at least once before.
      log.Error("execution error in estimate gas", "err", err)
      return 0, err
    }
    if failed {
      lo = mid
    } else {
      hi = mid
    }
  }
  return hexutil.Uint64(hi), nil
}

하나하나 살펴보자.

트랜잭션이 성공했을 때와 실패했을 때로 나누어 선언

lo, hi 변수를 각각 선언하고, 성공했을 때와 실패했을 때로 나누어서 사용하는 것 같다.

args.From 이 nil인 경우 new(common.Address)를 통해서 ZeroAddress를 생성한다.

예외처리로 에러를 반환하는게 아니라 0주소값을 할당하고 그대로 진행하는듯 하다.

그 다음 구문의 조건문을 해석해보자.

args.Gas가 nil이 아니고, args.Gas의 value를 uint64로 캐스팅했을때 params.TxGas보다 높다면

이라는 조건이다. 이쯤에서 함수의 프로토타입을 다시보자.

args는 트랜잭션 인자이다. params는 어디에 있을까? go-ethereum의 params패키지의 TxGas라는 constant 값이 있다.

각종 constant값. 이제 구글링 하지말고 이거 보면 될 것 같다.

주석을 참고하면, 컨트랙트 생성이 아닌 트랜잭션에 대한 기본 가스비로 생각할 수 있다.

그러나 payable은 불가능하다는 뜻으로 보인다.

아무튼 돌아와서 다시 살펴보자.

이제는 한 번에 알 수 있다. 트랜잭션 인자의 가스가 기본 가스비 (21,000)보다 높다면

hi(트랜잭션이 성공했을 때의 가스)를 해당 가스비로 설정하라는 뜻이다.

else … 구문에 해당되려면 args.Gas가 nil이거나, args.Gasparams.TxGas보다 낮아야 한다.

그런 경우에는 현재 블록을 가져오고, 블록의 GasLimit으로 적용한다.

feeCap을 설정하는 부분으로 보인다.

방금 위에서 한 것은 hi를 설정하는 가정이었다. 이제 feeCap을 설정해야한다.

GasFeeCap: 트랜잭션에 대해 지불하고자 하는 최대 금액 (base fee + priority fee)

첫 번째 if문은 전체 nil guard에 대한 부분이고, 그 다음은 세 가지 경우로 나뉜다.

  1. GasPrice가 nil이 아니다.

  2. MaxFeePerGas가 nil이 아니다.

  3. MaxPriorityFeePerGas가 nil이 아니다.

1번의 경우에는 feeCap이 GasPrice로 설정된다.

2번의 경우에는 Max 상한선이 설정되어있으므로 그대로 설정해준다.

3번의 경우에는 0으로 설정한다. (의아하지만 일단 넘어간다)

그 다음 쭉쭉 넘어가서, from 주소의 잔고와 비교해서 변수들을 재 점검하는 로직인듯하다.

allowance를 설정하는 부분을 보면 현재 계정의 잔고를 feeCap으로 나눈다.

그리고 그 allowance가 uint64 범위 이상인 경우에는 체크를 멈춘다.

그 밑을 보면 higasCap보다 높은 경우에는 gasCap으로 다시 설정해준다.

결국 여기까지의 로직은 모두 가스 한도를 설정해주는 로직이다.

그 다음부터의 로직이 우리가 원하던, 실제로 코드를 실행시켜서 가스비를 계산하는 로직이 나온다.

이분 탐색이 사용된 것을 볼 수 있다.

맨 위에서 우선 allowance의 범위 내에서 가장 높은 가스비를 사용해서 코드를 실행하고, 실패할 경우 곧장 에러를 반환한다.

만약 성공한 경우에는 이분탐색으로 가장 낮은 가스비를 찾는다.

eth_estimateGas는 코드를 EVM 상에서 여러 번 수행해봄으로써 가장 낮은 가스비를 찾아주는 것이다.

이제 그럼 executeEstimate 함수를 들춰보자.

// executeEstimate is a helper that executes the transaction under a given gas limit and returns
// true if the transaction fails for a reason that might be related to not enough gas. A non-nil
// error means execution failed due to reasons unrelated to the gas limit.
func executeEstimate(ctx context.Context, b Backend, args TransactionArgs, state *state.StateDB, header *types.Header, gasCap uint64, gasLimit uint64) (bool, *core.ExecutionResult, error) {
	args.Gas = (*hexutil.Uint64)(&gasLimit)
	result, err := doCall(ctx, b, args, state, header, nil, nil, 0, gasCap)
	if err != nil {
		if errors.Is(err, core.ErrIntrinsicGas) {
			return true, nil, nil // Special case, raise gas limit
		}
		return true, nil, err // Bail out
	}
	return result.Failed(), result, nil
}

일단 로직만 살펴보자.

doCall 함수를 이용해서 실패인지 아닌지에 대한 부분만 리턴한다.

doCall 함수도 까보자.

func doCall(ctx context.Context, b Backend, args TransactionArgs, state *state.StateDB, header *types.Header, overrides *StateOverride, blockOverrides *BlockOverrides, timeout time.Duration, globalGasCap uint64) (*core.ExecutionResult, error) {
	if err := overrides.Apply(state); err != nil {
		return nil, err
	}
	// Setup context so it may be cancelled the call has completed
	// or, in case of unmetered gas, setup a context with a timeout.
	var cancel context.CancelFunc
	if timeout > 0 {
		ctx, cancel = context.WithTimeout(ctx, timeout)
	} else {
		ctx, cancel = context.WithCancel(ctx)
	}
	// Make sure the context is cancelled when the call has completed
	// this makes sure resources are cleaned up.
	defer cancel()

	// Get a new instance of the EVM.
	msg, err := args.ToMessage(globalGasCap, header.BaseFee)
	if err != nil {
		return nil, err
	}
	blockCtx := core.NewEVMBlockContext(header, NewChainContext(ctx, b), nil)
	if blockOverrides != nil {
		blockOverrides.Apply(&blockCtx)
	}
	evm, vmError := b.GetEVM(ctx, msg, state, header, &vm.Config{NoBaseFee: true}, &blockCtx)

	// Wait for the context to be done and cancel the evm. Even if the
	// EVM has finished, cancelling may be done (repeatedly)
	go func() {
		<-ctx.Done()
		evm.Cancel()
	}()

	// Execute the message.
	gp := new(core.GasPool).AddGas(math.MaxUint64)
	result, err := core.ApplyMessage(evm, msg, gp)
	if err := vmError(); err != nil {
		return nil, err
	}

	// If the timer caused an abort, return an appropriate error message
	if evm.Cancelled() {
		return nil, fmt.Errorf("execution aborted (timeout = %v)", timeout)
	}
	if err != nil {
		return result, fmt.Errorf("err: %w (supplied gas %d)", err, msg.GasLimit)
	}
	return result, nil
}

일단 overrides.Apply(state) 함수를 추측해보건데, 아마 컨트랙트의 현재 상태를 가져오는 것이 아닐까? 라는 생각이 든다.


// StateOverride is the collection of overridden accounts.
type StateOverride map[common.Address]OverrideAccount

// Apply overrides the fields of specified accounts into the given state.
func (diff *StateOverride) Apply(state *state.StateDB) error {
	if diff == nil {
		return nil
	}
	for addr, account := range *diff {
		// Override account nonce.
		if account.Nonce != nil {
			state.SetNonce(addr, uint64(*account.Nonce))
		}
		// Override account(contract) code.
		if account.Code != nil {
			state.SetCode(addr, *account.Code)
		}
		// Override account balance.
		if account.Balance != nil {
			state.SetBalance(addr, (*big.Int)(*account.Balance))
		}
		if account.State != nil && account.StateDiff != nil {
			return fmt.Errorf("account %s has both 'state' and 'stateDiff'", addr.Hex())
		}
		// Replace entire state if caller requires.
		if account.State != nil {
			state.SetStorage(addr, *account.State)
		}
		// Apply state diff into specified accounts.
		if account.StateDiff != nil {
			for key, value := range *account.StateDiff {
				state.SetState(addr, key, value)
			}
		}
	}
	// Now finalize the changes. Finalize is normally performed between transactions.
	// By using finalize, the overrides are semantically behaving as
	// if they were created in a transaction just before the tracing occur.
	state.Finalise(false)
	return nil
}

아무래도 보기좋게 빗나간 것 같다. 일단 상태를 오버라이딩 하는 것 까지는 이름에서 충분히 추측할 수 있었지만, 컨트랙트의 상태가 아니라 계정의 상태를 말하는 것 같다.

그 다음은 timeout이 설정되어 있으면 Context WithTimeout으로, 없을 경우 Context WithCancel 함수로 컨텍스트와 cancel func를 생성해준다.

그 다음 코드로 넘어가보자.

일단 globalGasCap, header.BaseFee 를 사용해서 들어온 트랜잭션 argument를 메세지 타입으로 만들어준다.

그 이후에 core.NewEVMBlockContext가 바로 새로운 EVM 인스턴스를 생성하는 부분이다.

여기서 일단 Header 구조체를 살펴보고 가자.

주석을 보면, 이더리움의 블록 헤더에 대한 구조체라고 나와있다.

// Header represents a block header in the Ethereum blockchain.
type Header struct {
	ParentHash  common.Hash    `json:"parentHash"       gencodec:"required"`
	UncleHash   common.Hash    `json:"sha3Uncles"       gencodec:"required"`
	Coinbase    common.Address `json:"miner"`
	Root        common.Hash    `json:"stateRoot"        gencodec:"required"`
	TxHash      common.Hash    `json:"transactionsRoot" gencodec:"required"`
	ReceiptHash common.Hash    `json:"receiptsRoot"     gencodec:"required"`
	Bloom       Bloom          `json:"logsBloom"        gencodec:"required"`
	Difficulty  *big.Int       `json:"difficulty"       gencodec:"required"`
	Number      *big.Int       `json:"number"           gencodec:"required"`
	GasLimit    uint64         `json:"gasLimit"         gencodec:"required"`
	GasUsed     uint64         `json:"gasUsed"          gencodec:"required"`
	Time        uint64         `json:"timestamp"        gencodec:"required"`
	Extra       []byte         `json:"extraData"        gencodec:"required"`
	MixDigest   common.Hash    `json:"mixHash"`
	Nonce       BlockNonce     `json:"nonce"`

	// BaseFee was added by EIP-1559 and is ignored in legacy headers.
	BaseFee *big.Int `json:"baseFeePerGas" rlp:"optional"`

	// WithdrawalsHash was added by EIP-4895 and is ignored in legacy headers.
	WithdrawalsHash *common.Hash `json:"withdrawalsRoot" rlp:"optional"`

	// BlobGasUsed was added by EIP-4844 and is ignored in legacy headers.
	BlobGasUsed *uint64 `json:"blobGasUsed" rlp:"optional"`

	// ExcessBlobGas was added by EIP-4844 and is ignored in legacy headers.
	ExcessBlobGas *uint64 `json:"excessBlobGas" rlp:"optional"`
}

그 다음은 NewChainContext 인자로 backend(API 인터페이스)와 컨텍스트를 넘긴다.

이제 NewEVMBlockContext를 한 번 코드로 살펴보자.

// NewEVMBlockContext creates a new context for use in the EVM.
func NewEVMBlockContext(header *types.Header, chain ChainContext, author *common.Address) vm.BlockContext {
	var (
		beneficiary common.Address
		baseFee     *big.Int
		random      *common.Hash
	)

	// If we don't have an explicit author (i.e. not mining), extract from the header
	if author == nil {
		beneficiary, _ = chain.Engine().Author(header) // Ignore error, we're past header validation
	} else {
		beneficiary = *author
	}
	if header.BaseFee != nil {
		baseFee = new(big.Int).Set(header.BaseFee)
	}
	if header.Difficulty.Cmp(common.Big0) == 0 {
		random = &header.MixDigest
	}
	return vm.BlockContext{
		CanTransfer:   CanTransfer,
		Transfer:      Transfer,
		GetHash:       GetHashFn(header, chain),
		Coinbase:      beneficiary,
		BlockNumber:   new(big.Int).Set(header.Number),
		Time:          header.Time,
		Difficulty:    new(big.Int).Set(header.Difficulty),
		BaseFee:       baseFee,
		GasLimit:      header.GasLimit,
		Random:        random,
		ExcessBlobGas: header.ExcessBlobGas,
	}
}

author가 nil인지 아닌지로 분기처리를 한 모습이 보이는데, 위에서 author에 nil을 집어넣었다.

따라서 estimate를 할 때는 항상 author==nil 조건을 충족할 것이다.

나머지는 단순히 기본 값으로 넣어주는 듯 하다.

그 이후로 GetEvm, ApplyMessage 등으로 실행한다.

결국 가장 중요한 것이 아래 부분이 된다.

코드를 실행

누가봐도 ApplyMessage가 정말 의심스럽다. go-ethereum에서 추상화를 매우 잘해놔서 함수를 몇 번 타고 들어가다보면 다음 메소드를 찾을 수 있다.

// TransitionDb will transition the state by applying the current message and
// returning the evm execution result with following fields.
//
//   - used gas: total gas used (including gas being refunded)
//   - returndata: the returned data from evm
//   - concrete execution error: various EVM errors which abort the execution, e.g.
//     ErrOutOfGas, ErrExecutionReverted
//
// However if any consensus issue encountered, return the error directly with
// nil evm execution result.
func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {
	// First check this message satisfies all consensus rules before
	// applying the message. The rules include these clauses
	//
	// 1. the nonce of the message caller is correct
	// 2. caller has enough balance to cover transaction fee(gaslimit * gasprice)
	// 3. the amount of gas required is available in the block
	// 4. the purchased gas is enough to cover intrinsic usage
	// 5. there is no overflow when calculating intrinsic gas
	// 6. caller has enough balance to cover asset transfer for **topmost** call

	// Check clauses 1-3, buy gas if everything is correct
	if err := st.preCheck(); err != nil {
		return nil, err
	}

	if tracer := st.evm.Config.Tracer; tracer != nil {
		tracer.CaptureTxStart(st.initialGas)
		defer func() {
			tracer.CaptureTxEnd(st.gasRemaining)
		}()
	}

	var (
		msg              = st.msg
		sender           = vm.AccountRef(msg.From)
		rules            = st.evm.ChainConfig().Rules(st.evm.Context.BlockNumber, st.evm.Context.Random != nil, st.evm.Context.Time)
		contractCreation = msg.To == nil
	)

	// Check clauses 4-5, subtract intrinsic gas if everything is correct
	gas, err := IntrinsicGas(msg.Data, msg.AccessList, contractCreation, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai)
	if err != nil {
		return nil, err
	}
	if st.gasRemaining < gas {
		return nil, fmt.Errorf("%w: have %d, want %d", ErrIntrinsicGas, st.gasRemaining, gas)
	}
	st.gasRemaining -= gas

	// Check clause 6
	if msg.Value.Sign() > 0 && !st.evm.Context.CanTransfer(st.state, msg.From, msg.Value) {
		return nil, fmt.Errorf("%w: address %v", ErrInsufficientFundsForTransfer, msg.From.Hex())
	}

	// Check whether the init code size has been exceeded.
	if rules.IsShanghai && contractCreation && len(msg.Data) > params.MaxInitCodeSize {
		return nil, fmt.Errorf("%w: code size %v limit %v", ErrMaxInitCodeSizeExceeded, len(msg.Data), params.MaxInitCodeSize)
	}

	// Execute the preparatory steps for state transition which includes:
	// - prepare accessList(post-berlin)
	// - reset transient storage(eip 1153)
	st.state.Prepare(rules, msg.From, st.evm.Context.Coinbase, msg.To, vm.ActivePrecompiles(rules), msg.AccessList)

	var (
		ret   []byte
		vmerr error // vm errors do not effect consensus and are therefore not assigned to err
	)
	if contractCreation {
		ret, _, st.gasRemaining, vmerr = st.evm.Create(sender, msg.Data, st.gasRemaining, msg.Value)
	} else {
		// Increment the nonce for the next transaction
		st.state.SetNonce(msg.From, st.state.GetNonce(sender.Address())+1)
		ret, st.gasRemaining, vmerr = st.evm.Call(sender, st.to(), msg.Data, st.gasRemaining, msg.Value)
	}

	if !rules.IsLondon {
		// Before EIP-3529: refunds were capped to gasUsed / 2
		st.refundGas(params.RefundQuotient)
	} else {
		// After EIP-3529: refunds are capped to gasUsed / 5
		st.refundGas(params.RefundQuotientEIP3529)
	}
	effectiveTip := msg.GasPrice
	if rules.IsLondon {
		effectiveTip = cmath.BigMin(msg.GasTipCap, new(big.Int).Sub(msg.GasFeeCap, st.evm.Context.BaseFee))
	}

	if st.evm.Config.NoBaseFee && msg.GasFeeCap.Sign() == 0 && msg.GasTipCap.Sign() == 0 {
		// Skip fee payment when NoBaseFee is set and the fee fields
		// are 0. This avoids a negative effectiveTip being applied to
		// the coinbase when simulating calls.
	} else {
		fee := new(big.Int).SetUint64(st.gasUsed())
		fee.Mul(fee, effectiveTip)
		st.state.AddBalance(st.evm.Context.Coinbase, fee)
	}

	return &ExecutionResult{
		UsedGas:    st.gasUsed(),
		Err:        vmerr,
		ReturnData: ret,
	}, nil
}

드디어 그토록 찾던 실행부 구현체이다.

preCheck

맨 위에서 부터 살펴보면, 6가지 컨센서스 조건을 모두 충족해야 메세지를 적용한다.

아래 사진을 보면, 컨트랙트 생성인 경우와 아닌 경우로 나뉘어서 다시 실행된다.

분기처리

그 뒤로도 계속해서 추상화를 통해 각종 예외처리와, 상태 DB에서 현재 상태를 가져오고, 코드를 컴파일 하는 등의 코드들이 나온다.

그렇게 실행한 코드의 결과값 등이 차례로 리턴되고, 실행에 소요된 가스값이 리턴된다.

최초의 ethClient.EstimateGas로 부터 거의 10개가 넘는 인터페이스를 지나서 도달할 수 있었다.

go-ethereum의 코드를 보면서 느낀 것은 코드 자체의 퀄리티가 높은 것도 있지만, 개인적으로 다음 5가지에 주목이 됐다.

  1. 관심사에 따라 인터페이스로 추상화를 잘 해둔 것 같다.

  2. 함수 호출에 필요한 인자가 많아지더라도 새로운 구조체를 생성하지 않고 가독성을 해치지 않도록 각 변수들을 주입하도록 했다.

  3. 예외처리가 매우 엄격하게 이루어졌다.

  4. Getter, Setter를 적극적으로 활용했다.

  5. 함수 내에서 사용할 변수들을 var ()로 묶어서 선언, 주석을 추가함으로써 가독성을 높이고 앞으로 함수 내에서 진행될 비즈니스 로직을 예상 가능하게끔 한다.

Last updated