lib/eth/tx.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# Copyright (c) 2016-2023 The Ruby-Eth Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require "konstructor"

require "eth/chain"
require "eth/tx/eip1559"
require "eth/tx/eip2930"
require "eth/tx/legacy"
require "eth/unit"

# Provides the {Eth} module.
module Eth

  # Provides the `Tx` module supporting various transaction types.
  module Tx
    extend self

    # Provides a special transaction error if transaction type is unknown.
    class TransactionTypeError < TypeError; end

    # Provides an decoder error if transaction cannot be decoded.
    class DecoderError < StandardError; end

    # Provides a parameter error if parameter types are invalid.
    class ParameterError < TypeError; end

    # The minimum transaction gas limit required for a value transfer.
    DEFAULT_GAS_LIMIT = 21_000.freeze

    # The "default" transaction priority fee of 1.01 GWei. Do not use.
    DEFAULT_PRIORITY_FEE = (1.01 * Unit::GWEI).freeze

    # The "default" transaction gas price of 42.69 GWei. Do not use.
    DEFAULT_GAS_PRICE = (42.69 * Unit::GWEI).freeze

    # The calldata gas cost of a non-zero byte as per EIP-2028.
    COST_NON_ZERO_BYTE = 16.freeze

    # The calldata gas cost of a zero byte.
    COST_ZERO_BYTE = 4.freeze

    # The initcode gas cost for each word (32 bytes).
    COST_INITCODE_WORD = 2.freeze

    # The access list gas cost of a storage key as per EIP-2930.
    COST_STORAGE_KEY = 1_900.freeze

    # The access list gas cost of an address as per EIP-2930.
    COST_ADDRESS = 2_400.freeze

    # The maximum transaction gas limit is bound by the block gas limit.
    BLOCK_GAS_LIMIT = 30_000_000.freeze

    # The legacy transaction type is 0.
    TYPE_LEGACY = 0x00.freeze

    # The EIP-2930 transaction type is 1.
    TYPE_2930 = 0x01.freeze

    # The EIP-1559 transaction type is 2.
    TYPE_1559 = 0x02.freeze

    # The zero byte is 0x00.
    ZERO_BYTE = "\x00".freeze

    # Smart contract transaction gas cost
    CREATE_GAS = 32_000.freeze

    # Creates a new transaction of any type for given parameters and chain ID.
    # Required parameters are (optional in brackets):
    # - EIP-1559: chain_id, nonce, priority_fee, max_gas_fee, gas_limit(, from, to,
    #   value, data, access_list)
    # - EIP-2930: chain_id, nonce, gas_price, gas_limit, access_list(, from, to,
    #   value, data)
    # - Legacy: nonce, gas_price, gas_limit(, from, to, value, data)
    #
    # @param params [Hash] all necessary transaction fields.
    # @param chain_id [Integer] the EIP-155 Chain ID (legacy transactions only).
    def new(params, chain_id = Chain::ETHEREUM)

      # if we deal with max gas fee parameter, attempt EIP-1559
      unless params[:max_gas_fee].nil?
        params[:chain_id] = chain_id if params[:chain_id].nil?
        return Tx::Eip1559.new params
      end

      # if we deal with access list parameter, attempt EIP-2930
      unless params[:access_list].nil?
        params[:chain_id] = chain_id if params[:chain_id].nil?
        return Tx::Eip2930.new params
      end

      # if nothing else, go with legacy transactions
      chain_id = params[:chain_id] if !params[:chain_id].nil? and params[:chain_id] != chain_id
      return Tx::Legacy.new params, chain_id
    end

    # Decodes a transaction hex of any known type (2, 1, or legacy).
    #
    # @param hex [String] the raw transaction hex-string.
    # @return [Eth::Tx] transaction payload.
    # @raise [TransactionTypeError] if the transaction type is unknown.
    def decode(hex)
      hex = Util.remove_hex_prefix hex
      type = hex[0, 2].to_i(16)
      case type
      when TYPE_1559

        # EIP-1559 transaction (type 2)
        return Tx::Eip1559.decode hex
      when TYPE_2930

        # EIP-2930 transaction (type 1)
        return Tx::Eip2930.decode hex
      else

        # Legacy transaction if first byte is RLP (>= 192)
        if type >= 0xc0
          return Tx::Legacy.decode hex
        else
          raise TransactionTypeError, "Cannot decode unknown transaction type #{type}!"
        end
      end
    end

    # Creates an unsigned copy of any transaction object.
    #
    # @param tx [Eth::Tx] any transaction payload.
    # @return [Eth::Tx] an unsigned transaction payload of the same type.
    # @raise [TransactionTypeError] if the transaction type is unknown.
    def unsigned_copy(tx)
      case tx.type
      when TYPE_1559

        # EIP-1559 transaction (type 2)
        return Tx::Eip1559.unsigned_copy tx
      when TYPE_2930

        # EIP-2930 transaction (type 1)
        return Tx::Eip2930.unsigned_copy tx
      when TYPE_LEGACY

        # Legacy transaction ("type 0")
        return Tx::Legacy.unsigned_copy tx
      end
      raise TransactionTypeError, "Cannot copy unknown transaction type #{tx.type}!"
    end

    # Estimates intrinsic gas for provided call data (EIP-2028) and
    # access lists (EIP-2930). Respects initcode word cost (EIP-3860).
    #
    # @param data [String] the call data.
    # @param list [Array] the access list.
    # @return [Integer] the estimated intrinsic gas cost.
    def estimate_intrinsic_gas(data = "", list = [])
      gas = DEFAULT_GAS_LIMIT
      unless data.nil? or data.empty?
        data = Util.hex_to_bin data if Util.hex? data

        # count zero bytes
        zero = data.count ZERO_BYTE
        gas += zero * COST_ZERO_BYTE

        # count non-zero bytes
        none = data.size - zero
        gas += none * COST_NON_ZERO_BYTE

        # count "words" as per EIP-3860
        word_count = (data.length.to_f / 32.0).ceil
        gas += word_count * COST_INITCODE_WORD
      end
      unless list.nil? or list.empty?
        list.each do |entry|

          # count addresses
          gas += COST_ADDRESS

          entry.last.each do |key|

            # count storage keys
            gas += COST_STORAGE_KEY
          end
        end
      end
      return gas.to_i
    end

    # Validates the common transaction fields such as nonce, gas limit,
    # amount, and access list.
    #
    # @param fields [Hash] the transaction fields.
    # @return [Hash] the validated transaction fields.
    # @raise [ParameterError] if nonce is an invalid integer.
    # @raise [ParameterError] if gas limit is invalid.
    # @raise [ParameterError] if amount is invalid.
    # @raise [ParameterError] if access list is invalid.
    def validate_params(fields)
      if fields[:nonce].nil? or fields[:nonce] < 0
        raise ParameterError, "Invalid signer nonce #{fields[:nonce]}!"
      end
      if fields[:gas_limit].nil? or
         fields[:gas_limit] < DEFAULT_GAS_LIMIT or
         (fields[:gas_limit] > BLOCK_GAS_LIMIT and fields[:chain_id] == Chain::ETHEREUM)
        raise ParameterError, "Invalid gas limit #{fields[:gas_limit]}!"
      end
      unless fields[:value] >= 0
        raise ParameterError, "Invalid transaction value #{fields[:value]}!"
      end
      unless fields[:access_list].nil? or fields[:access_list].is_a? Array
        raise ParameterError, "Invalid access list #{fields[:access_list]}!"
      end
      return fields
    end

    # Validates the common type-2 transaction fields such as priority
    # fee and max gas fee.
    #
    # @param fields [Hash] the transaction fields.
    # @return [Hash] the validated transaction fields.
    # @raise [ParameterError] if priority fee is invalid.
    # @raise [ParameterError] if max gas fee is invalid.
    def validate_eip1559_params(fields)
      if fields[:priority_fee].nil? or fields[:priority_fee] < 0
        raise ParameterError, "Invalid gas priority fee #{fields[:priority_fee]}!"
      end
      if fields[:max_gas_fee].nil? or fields[:max_gas_fee] < 0
        raise ParameterError, "Invalid max gas fee #{fields[:max_gas_fee]}!"
      end
      return fields
    end

    # Validates the common legacy transaction fields such as gas price.
    #
    # @param fields [Hash] the transaction fields.
    # @return [Hash] the validated transaction fields.
    # @raise [ParameterError] if gas price is invalid.
    def validate_legacy_params(fields)
      if fields[:gas_price].nil? or fields[:gas_price] < 0
        raise ParameterError, "Invalid gas price #{fields[:gas_price]}!"
      end
      return fields
    end

    # Populates the transaction chain id field with a serializable default
    # value (1) in case it is undefined.
    #
    # @param id [Integer] the transaction chain id.
    # @return [Integer] the sanitized transaction chain id.
    def sanitize_chain(id)
      id = Chain::ETHEREUM if id.nil?
      return id
    end

    # Populates the transaction destination address with a serializable
    # empty value in case it is undefined; also ensures the address is
    # checksummed but not prefixed for consistency.
    #
    # @param addr [String] the transaction destination address.
    # @return [String] the sanitized transaction destination address.
    def sanitize_address(addr)
      addr = "" if addr.nil?
      if addr.is_a? String and !addr.empty?
        addr = Address.new(addr).to_s
        addr = Util.remove_hex_prefix addr
      end
      return addr
    end

    # Populates the transaction value field with a serializable empty value
    # in case it is undefined.
    #
    # @param val [Integer] the transaction value.
    # @return [Integer] the sanitized transaction value.
    def sanitize_amount(val)
      val = 0 if val.nil?
      return val
    end

    # Populates the transaction payload field with a serializable empty value
    # in case it is undefined; also ensures the data is binary not hex.
    #
    # @param data [String] the transaction payload data.
    # @return [String] the sanitized transaction payload data.
    def sanitize_data(data)
      data = "" if data.nil?

      # ensure payload to be binary if it's hex, otherwise we'll treat it raw
      data = Util.hex_to_bin data if Util.hex? data
      return data
    end

    # Populates the transaction access list field with a serializable empty
    # array in case it is undefined; also ensures the nested data is binary
    # not hex.
    #
    # @param list [Array] the transaction access list.
    # @return [Array] the sanitized transaction access list.
    def sanitize_list(list)
      list = [] if list.nil?
      list.each_with_index do |value, index|
        if value.is_a? Array

          # recursively check the entire array
          list[index] = sanitize_list value
        elsif Util.hex? value

          # only modify if we find a hex value
          list[index] = Util.hex_to_bin value
        end
      end
      return list
    end

    # Allows to check wether a transaction is signed already.
    #
    # @return [Bool] true if transaction is already signed.
    def signed?(tx)
      !tx.signature_r.nil? and tx.signature_r != 0 and
      !tx.signature_s.nil? and tx.signature_s != 0
    end
  end
end