marekkirejczyk/ethereum.rb

View on GitHub
lib/ethereum/contract.rb

Summary

Maintainability
D
2 days
Test Coverage
require 'forwardable'

module Ethereum
  class Contract

    attr_reader :address
    attr_accessor :key
    attr_accessor :gas_limit, :gas_price, :nonce
    attr_accessor :code, :name, :abi, :class_object, :sender, :deployment, :client
    attr_accessor :events, :functions, :constructor_inputs
    attr_accessor :call_raw_proxy, :call_proxy, :transact_proxy, :transact_and_wait_proxy
    attr_accessor :new_filter_proxy, :get_filter_logs_proxy, :get_filter_change_proxy

    def initialize(name, code, abi, client = Ethereum::Singleton.instance)
      @name = name
      @code = code
      @abi = abi
      @constructor_inputs, @functions, @events = Ethereum::Abi.parse_abi(abi)
      @formatter = Ethereum::Formatter.new
      @client = client
      @sender = client.default_account
      @encoder = Encoder.new
      @decoder = Decoder.new
      @gas_limit = @client.gas_limit
      @gas_price = @client.gas_price
    end

    # Creates a contract wrapper.
    # This method attempts to instantiate a contract object from a Solidity source file, from a Truffle
    # artifacts file, or from explicit API and bytecode.
    # - If *:file* is present, the method compiles it and looks up the contract factory at *:contract_index*
    #   (0 if not provided). *:abi* and *:code* are ignored.
    # - If *:truffle* is present, the method looks up the Truffle artifacts data for *:name* and uses
    #   those data to build a contract instance.
    # - Otherwise, the method uses *:name*, *:code*, and *:abi* to build the contract instance.
    #
    # @param opts [Hash] Options to the method.
    # @option opts [String] :file Path to the Solidity source that contains the contract code.
    # @option opts [Ethereum::Singleton] :client The client to use.
    # @option opts [String] :code The hex representation of the contract's bytecode.
    # @option opts [Array,String] :abi The contract's ABI; a string is assumed to contain a JSON representation
    #  of the ABI.
    # @option opts [String] :address The contract's address; if not present and +:truffle+ is present,
    #  the method attempts to determine the address from the artifacts' +networks+ key and the client's
    #  network id.
    # @option opts [String] :name The contract name.
    # @option opts [Integer] :contract_index The index of the contract data in the compiled file.
    # @option opts [Hash] :truffle If this parameter is present, the method uses Truffle information to
    #  generate the contract wrapper.
    #  - *:paths* An array of strings containing the list of paths where to look up Truffle artifacts files.
    #    See also {#find_truffle_artifacts}.
    #
    # @return [Ethereum::Contract] Returns a contract wrapper.

    def self.create(file: nil, client: Ethereum::Singleton.instance, code: nil, abi: nil, address: nil, name: nil, contract_index: nil, truffle: nil)
      contract = nil
      if file.present?
        contracts = Ethereum::Initializer.new(file, client).build_all
        raise "No contracts compiled" if contracts.empty?
        if contract_index
          contract = contracts[contract_index].class_object.new
        else
          contract = contracts.first.class_object.new
        end
      else
        if truffle.present? && truffle.is_a?(Hash)
          artifacts = find_truffle_artifacts(name, (truffle[:paths].is_a?(Array)) ? truffle[:paths] : [])
          if artifacts
            abi = artifacts['abi']
            # The truffle artifacts store bytecodes with a 0x tag, which we need to remove
            # this may need to be 'deployedBytecode'
            code_key = artifacts['bytecode'].present? ? 'bytecode' : 'unlinked_binary'
            code = (artifacts[code_key].start_with?('0x')) ? artifacts[code_key][2, artifacts[code_key].length] : artifacts[code_key]
            unless address
              address = if client
                          network_id = client.net_version['result']
                          (artifacts['networks'][network_id]) ? artifacts['networks'][network_id]['address'] : nil
                        else
                          nil
                        end
            end
          else
            abi = nil
            code = nil
          end
        else
          abi = abi.is_a?(String) ? JSON.parse(abi) : abi.map(&:deep_stringify_keys)
        end
        contract = Ethereum::Contract.new(name, code, abi, client)
        contract.build
        contract = contract.class_object.new
      end
      contract.address = address
      contract
    end

    def address=(addr)
      @address = addr
      @events.each do |event|
        event.set_address(addr)
        event.set_client(@client)
      end
    end

    def deploy_payload(params)
      if @constructor_inputs.present?
        raise ArgumentError, "Wrong number of arguments in a constructor" and return if params.length != @constructor_inputs.length
      end
      deploy_arguments = @encoder.encode_arguments(@constructor_inputs, params)
      "0x" + @code + deploy_arguments
    end

    def deploy_args(params)
      add_gas_options_args({from: sender, data: deploy_payload(params)})
    end

    def send_transaction(tx_args)
        @client.eth_send_transaction(tx_args)["result"]
    end

    def send_raw_transaction(payload, to = nil)
      Eth.configure { |c| c.chain_id = @client.net_version["result"].to_i }
      @nonce ||= @client.get_nonce(key.address)
      args = {
        from: key.address,
        value: 0,
        data: payload,
        nonce: @nonce,
        gas_limit: gas_limit,
        gas_price: gas_price
      }
      args[:to] = to if to
      tx = Eth::Tx.new(args)
      tx.sign key
      @client.eth_send_raw_transaction(tx.hex)["result"]
    end

    def deploy(*params)
      if key
        tx = send_raw_transaction(deploy_payload(params))
      else
        tx = send_transaction(deploy_args(params))
      end
      tx_failed = tx.nil? || tx == "0x0000000000000000000000000000000000000000000000000000000000000000"
      raise IOError, "Failed to deploy, did you unlock #{sender} account? Transaction hash: #{tx}" if tx_failed
      @deployment = Ethereum::Deployment.new(tx, @client)
    end

    def deploy_and_wait(*params, **args, &block)
      deploy(*params)
      @deployment.wait_for_deployment(**args, &block)
      self.events.each do |event|
        event.set_address(@address)
        event.set_client(@client)
      end
      @address = @deployment.contract_address
    end

    def estimate(*params)
      result = @client.eth_estimate_gas(deploy_args(params))
      @decoder.decode_int(result["result"])
    end

    def call_payload(fun, args)
      "0x" + fun.signature + (@encoder.encode_arguments(fun.inputs, args).presence || "0"*64)
    end

    def call_args(fun, args)
      add_gas_options_args({to: @address, from: @sender, data: call_payload(fun, args)})
    end

    def call_raw(fun, *args)
      raw_result = @client.eth_call(call_args(fun, args))["result"]
      output = @decoder.decode_arguments(fun.outputs, raw_result)
      return {data: call_payload(fun, args), raw: raw_result, formatted: output}
    end

    def call(fun, *args)
      output = call_raw(fun, *args)[:formatted]
      if output.length == 1
        return output[0]
      else
        return output
      end
    end

    def transact(fun, *args)
      if key
        tx = send_raw_transaction(call_payload(fun, args), address)
      else
        tx = send_transaction(call_args(fun, args))
      end
      return Ethereum::Transaction.new(tx, @client, call_payload(fun, args), args)
    end

    def transact_and_wait(fun, *args)
      tx = transact(fun, *args)
      tx.wait_for_miner
      return tx
    end

    def create_filter(evt, **params)
      params[:to_block] ||= "latest"
      params[:from_block] ||= "0x0"
      params[:address] ||= @address
      params[:topics] = @encoder.ensure_prefix(evt.signature)
      payload = {topics: [params[:topics]], fromBlock: params[:from_block], toBlock: params[:to_block], address: @encoder.ensure_prefix(params[:address])}
      filter_id = @client.eth_new_filter(payload)
      return @decoder.decode_int(filter_id["result"])
    end

    def parse_filter_data(evt, logs)
      formatter = Ethereum::Formatter.new
      collection = []
      logs["result"].each do |result|
        inputs = evt.input_types
        outputs = inputs.zip(result["topics"][1..-1])
        data = {blockNumber: result["blockNumber"].hex, transactionHash: result["transactionHash"], blockHash: result["blockHash"], transactionIndex: result["transactionIndex"].hex, topics: []}
        outputs.each do |output|
          data[:topics] << formatter.from_payload(output)
        end
        collection << data
      end
      return collection
    end

    def get_filter_logs(evt, filter_id)
      parse_filter_data evt, @client.eth_get_filter_logs(filter_id)
    end

    def get_filter_changes(evt, filter_id)
      parse_filter_data evt, @client.eth_get_filter_changes(filter_id)
    end

    def function_name(fun)
      count = functions.select {|x| x.name == fun.name }.count
      name = (count == 1) ? "#{fun.name.underscore}" : "#{fun.name.underscore}__#{fun.inputs.collect {|x| x.type}.join("__")}"
      name.to_sym
    end

    def build
      class_name = @name.camelize
      parent = self
      create_function_proxies
      create_event_proxies
      class_methods = Class.new do
        extend Forwardable
        def_delegators :parent, :deploy_payload, :deploy_args, :call_payload, :call_args
        def_delegators :parent, :signed_deploy, :key, :key=
        def_delegators :parent, :gas_limit, :gas_price, :gas_limit=, :gas_price=, :nonce, :nonce=
        def_delegators :parent, :abi, :deployment, :events
        def_delegators :parent, :estimate, :deploy, :deploy_and_wait
        def_delegators :parent, :address, :address=, :sender, :sender=
        def_delegator :parent, :call_raw_proxy, :call_raw
        def_delegator :parent, :call_proxy, :call
        def_delegator :parent, :transact_proxy, :transact
        def_delegator :parent, :transact_and_wait_proxy, :transact_and_wait
        def_delegator :parent, :new_filter_proxy, :new_filter
        def_delegator :parent, :get_filter_logs_proxy, :get_filter_logs
        def_delegator :parent, :get_filter_change_proxy, :get_filter_changes
        define_method :parent do
          parent
        end
      end
      Ethereum::Contract.send(:remove_const, class_name) if Ethereum::Contract.const_defined?(class_name, false)
      Ethereum::Contract.const_set(class_name, class_methods)
      @class_object = class_methods
    end

    # Get the list of paths where to look up Truffle artifacts files.
    #
    # @return [Array<String>] Returns the array containing the list of lookup paths.

    def self.truffle_paths()
      @truffle_paths = [] unless @truffle_paths
      @truffle_paths
    end

    # Set the list of paths where to look up Truffle artifacts files.
    #
    # @param paths [Array<String>, nil] The array containing the list of lookup paths; a `nil` value is
    #  converted to the empty array.

    def self.truffle_paths=(paths)
      @truffle_paths = (paths.is_a?(Array)) ? paths : []
    end

    # Looks up and loads a Truffle artifacts file.
    # This method iterates over the `truffle_path` elements, looking for an artifact file in the
    # `build/contracts` subdirectory.
    #
    # @param name [String] The name of the contract whose artifacts to look up.
    # @param paths [Array<String>] An additional list of paths to look up; this list, if present, is
    #  prepended to the `truffle_path`.
    #
    # @return [Hash,nil] Returns a hash containing the parsed JSON from the artifacts file; if no file
    #  was found, returns `nil`.

    def self.find_truffle_artifacts(name, paths = [])
      subpath = File.join('build', 'contracts', "#{name}.json")

      found = paths.concat(truffle_paths).find { |p| File.file?(File.join(p, subpath)) }
      if (found)
        JSON.parse(IO.read(File.join(found, subpath)))
      else
        nil
      end
    end

    private
      def add_gas_options_args(args)
        args[:gas] = @client.int_to_hex(gas_limit) if gas_limit.present?
        args[:gasPrice] = @client.int_to_hex(gas_price) if gas_price.present?
        args
      end

      def create_function_proxies
        parent = self
        call_raw_proxy, call_proxy, transact_proxy, transact_and_wait_proxy = Class.new, Class.new, Class.new, Class.new
        @functions.each do |fun|
          call_raw_proxy.send(:define_method, parent.function_name(fun)) { |*args| parent.call_raw(fun, *args) }
          call_proxy.send(:define_method, parent.function_name(fun)) { |*args| parent.call(fun, *args) }
          transact_proxy.send(:define_method, parent.function_name(fun)) { |*args| parent.transact(fun, *args) }
          transact_and_wait_proxy.send(:define_method, parent.function_name(fun)) { |*args| parent.transact_and_wait(fun, *args) }
        end
        @call_raw_proxy, @call_proxy, @transact_proxy, @transact_and_wait_proxy =  call_raw_proxy.new, call_proxy.new, transact_proxy.new, transact_and_wait_proxy.new
      end

      def create_event_proxies
        parent = self
        new_filter_proxy, get_filter_logs_proxy, get_filter_change_proxy = Class.new, Class.new, Class.new
        events.each do |evt|
          new_filter_proxy.send(:define_method, evt.name.underscore) { |*args| parent.create_filter(evt, *args) }
          get_filter_logs_proxy.send(:define_method, evt.name.underscore) { |*args| parent.get_filter_logs(evt, *args) }
          get_filter_change_proxy.send(:define_method, evt.name.underscore) { |*args| parent.get_filter_changes(evt, *args) }
        end
        @new_filter_proxy, @get_filter_logs_proxy, @get_filter_change_proxy = new_filter_proxy.new, get_filter_logs_proxy.new, get_filter_change_proxy.new
      end
  end
end