# frozen_string_literal: true require "faraday" require "json" require "uri" module SynorContract # Synor Contract SDK client for Ruby. # Smart contract deployment, interaction, and event handling. class Client attr_reader :closed def initialize(config) @config = config @closed = false @conn = Faraday.new(url: config.endpoint) do |f| f.request :json f.response :json f.options.timeout = config.timeout f.headers["Authorization"] = "Bearer #{config.api_key}" f.headers["Content-Type"] = "application/json" f.headers["X-SDK-Version"] = "ruby/#{VERSION}" end end # ==================== Contract Deployment ==================== def deploy(options) body = { bytecode: options.bytecode } body[:abi] = options.abi.map(&:to_h) if options.abi body[:constructor_args] = options.constructor_args if options.constructor_args body[:value] = options.value if options.value body[:gas_limit] = options.gas_limit if options.gas_limit body[:gas_price] = options.gas_price if options.gas_price body[:nonce] = options.nonce if options.nonce response = post("/contract/deploy", body) parse_deployment_result(response) end def deploy_create2(options, salt:) body = { bytecode: options.bytecode, salt: salt } body[:abi] = options.abi.map(&:to_h) if options.abi body[:constructor_args] = options.constructor_args if options.constructor_args body[:value] = options.value if options.value body[:gas_limit] = options.gas_limit if options.gas_limit body[:gas_price] = options.gas_price if options.gas_price response = post("/contract/deploy/create2", body) parse_deployment_result(response) end def predict_address(bytecode:, salt:, deployer: nil) body = { bytecode: bytecode, salt: salt } body[:deployer] = deployer if deployer response = post("/contract/predict-address", body) response["address"] end # ==================== Contract Interaction ==================== def call(options) body = { contract: options.contract, method: options.method, args: options.args, abi: options.abi.map(&:to_h) } post("/contract/call", body) end def send(options) body = { contract: options.contract, method: options.method, args: options.args, abi: options.abi.map(&:to_h) } body[:value] = options.value if options.value body[:gas_limit] = options.gas_limit if options.gas_limit body[:gas_price] = options.gas_price if options.gas_price body[:nonce] = options.nonce if options.nonce response = post("/contract/send", body) parse_transaction_result(response) end # ==================== Events ==================== def get_events(filter) body = { contract: filter.contract } body[:event] = filter.event if filter.event body[:from_block] = filter.from_block if filter.from_block body[:to_block] = filter.to_block if filter.to_block body[:topics] = filter.topics if filter.topics body[:abi] = filter.abi.map(&:to_h) if filter.abi response = post("/contract/events", body) response.map { |e| parse_decoded_event(e) } end def get_logs(contract, from_block: nil, to_block: nil) path = "/contract/logs?contract=#{encode(contract)}" path += "&from_block=#{from_block}" if from_block path += "&to_block=#{to_block}" if to_block response = get(path) response.map { |l| parse_event_log(l) } end def decode_logs(logs:, abi:) body = { logs: logs.map(&:to_h), abi: abi.map(&:to_h) } response = post("/contract/decode-logs", body) response.map { |e| parse_decoded_event(e) } end # ==================== ABI Utilities ==================== def encode_call(options) body = { method: options.method, args: options.args, abi: options.abi.map(&:to_h) } response = post("/contract/encode", body) response["data"] end def decode_result(options) body = { data: options.data, method: options.method, abi: options.abi.map(&:to_h) } response = post("/contract/decode", body) response["result"] end def get_selector(signature) response = get("/contract/selector?signature=#{encode(signature)}") response["selector"] end # ==================== Gas Estimation ==================== def estimate_gas(options) body = { contract: options.contract, method: options.method, args: options.args, abi: options.abi.map(&:to_h) } body[:value] = options.value if options.value response = post("/contract/estimate-gas", body) parse_gas_estimation(response) end # ==================== Contract Information ==================== def get_bytecode(address) response = get("/contract/#{encode(address)}/bytecode") parse_bytecode_info(response) end def verify(options) body = { address: options.address, source_code: options.source_code, compiler_version: options.compiler_version } body[:constructor_args] = options.constructor_args if options.constructor_args body[:optimization] = options.optimization if options.optimization body[:optimization_runs] = options.optimization_runs if options.optimization_runs body[:license] = options.license if options.license response = post("/contract/verify", body) parse_verification_result(response) end def get_verification_status(address) response = get("/contract/#{encode(address)}/verification") parse_verification_result(response) end # ==================== Multicall ==================== def multicall(requests) body = { calls: requests.map(&:to_h) } response = post("/contract/multicall", body) response.map { |r| parse_multicall_result(r) } end # ==================== Storage ==================== def read_storage(options) path = "/contract/storage?contract=#{encode(options.contract)}&slot=#{encode(options.slot)}" path += "&block=#{options.block_number}" if options.block_number response = get(path) response["value"] end # ==================== Lifecycle ==================== def health_check response = get("/health") response["status"] == "healthy" rescue StandardError false end def close @closed = true @conn.close if @conn.respond_to?(:close) end private def get(path, params = {}) execute { @conn.get(path, params).body } end def post(path, body) execute { @conn.post(path, body).body } end def execute raise ClientClosedError, "Client has been closed" if @closed last_error = nil @config.retries.times do |attempt| begin response = yield check_error(response) if response.is_a?(Hash) return response rescue StandardError => e last_error = e sleep(2**attempt) if attempt < @config.retries - 1 end end raise last_error end def check_error(response) return unless response["error"] || (response["code"] && response["message"]) message = response["message"] || response["error"] || "Unknown error" code = response["code"] status = response["status_code"] || 0 raise HttpError.new(message, status_code: status, code: code) end def encode(str) URI.encode_www_form_component(str.to_s) end def parse_deployment_result(data) DeploymentResult.new( contract_address: data["contract_address"], transaction_hash: data["transaction_hash"], deployer: data["deployer"], gas_used: data["gas_used"], block_number: data["block_number"], block_hash: data["block_hash"] ) end def parse_transaction_result(data) TransactionResult.new( transaction_hash: data["transaction_hash"], block_number: data["block_number"], block_hash: data["block_hash"], gas_used: data["gas_used"], effective_gas_price: data["effective_gas_price"], status: data["status"], logs: (data["logs"] || []).map { |l| parse_event_log(l) } ) end def parse_event_log(data) EventLog.new( address: data["address"], topics: data["topics"], data: data["data"], block_number: data["block_number"], transaction_hash: data["transaction_hash"], log_index: data["log_index"], block_hash: data["block_hash"], removed: data["removed"] ) end def parse_decoded_event(data) DecodedEvent.new( name: data["name"], signature: data["signature"], args: data["args"], log: data["log"] ? parse_event_log(data["log"]) : nil ) end def parse_gas_estimation(data) GasEstimation.new( gas_limit: data["gas_limit"], gas_price: data["gas_price"], max_fee_per_gas: data["max_fee_per_gas"], max_priority_fee_per_gas: data["max_priority_fee_per_gas"], estimated_cost: data["estimated_cost"] ) end def parse_bytecode_info(data) BytecodeInfo.new( bytecode: data["bytecode"], deployed_bytecode: data["deployed_bytecode"], size: data["size"], is_contract: data["is_contract"] ) end def parse_verification_result(data) VerificationResult.new( verified: data["verified"], address: data["address"], compiler_version: data["compiler_version"], optimization: data["optimization"], optimization_runs: data["optimization_runs"], license: data["license"], abi: data["abi"]&.map { |e| parse_abi_entry(e) }, source_code: data["source_code"] ) end def parse_abi_entry(data) AbiEntry.new( type: data["type"], name: data["name"], inputs: data["inputs"]&.map { |p| parse_abi_parameter(p) }, outputs: data["outputs"]&.map { |p| parse_abi_parameter(p) }, state_mutability: data["stateMutability"], anonymous: data["anonymous"] ) end def parse_abi_parameter(data) AbiParameter.new( name: data["name"], type: data["type"], indexed: data["indexed"], components: data["components"]&.map { |c| parse_abi_parameter(c) } ) end def parse_multicall_result(data) MulticallResult.new( success: data["success"], result: data["result"], error: data["error"] ) end end end