# frozen_string_literal: true require "faraday" require "json" module SynorRpc # Synor RPC SDK Client # # @example # client = SynorRpc::Client.new(api_key: 'your-api-key') # # # Get latest block # block = client.get_latest_block # # # Get transaction # tx = client.get_transaction(txid) # class Client attr_reader :config def initialize(api_key: nil, **options) @config = Config.new(api_key: api_key, **options) raise ArgumentError, "API key is required" unless @config.api_key @conn = Faraday.new(url: @config.base_url) do |f| f.request :json f.response :json f.options.timeout = @config.timeout f.headers["Authorization"] = "Bearer #{@config.api_key}" f.headers["X-SDK-Version"] = "ruby/#{VERSION}" end @closed = false @subscriptions = {} @ws = nil end # ==================== Block Operations ==================== # Get the latest block # # @return [Block] def get_latest_block check_closed! response = with_retry { @conn.get("/blocks/latest") } Block.from_hash(response.body) end # Get a block by hash or height # # @param hash_or_height [String, Integer] # @return [Block] def get_block(hash_or_height) check_closed! response = with_retry { @conn.get("/blocks/#{hash_or_height}") } Block.from_hash(response.body) end # Get a block header # # @param hash_or_height [String, Integer] # @return [BlockHeader] def get_block_header(hash_or_height) check_closed! response = with_retry { @conn.get("/blocks/#{hash_or_height}/header") } BlockHeader.from_hash(response.body) end # Get a range of blocks # # @param start_height [Integer] # @param end_height [Integer] # @return [Array] def get_blocks(start_height, end_height) check_closed! response = with_retry do @conn.get("/blocks", start: start_height, end: end_height) end response.body.map { |b| Block.from_hash(b) } end # ==================== Transaction Operations ==================== # Get a transaction by ID # # @param txid [String] # @return [Transaction] def get_transaction(txid) check_closed! response = with_retry { @conn.get("/transactions/#{txid}") } Transaction.from_hash(response.body) end # Get raw transaction hex # # @param txid [String] # @return [String] def get_raw_transaction(txid) check_closed! response = with_retry { @conn.get("/transactions/#{txid}/raw") } response.body["hex"] end # Send a raw transaction # # @param hex [String] # @return [SubmitResult] def send_raw_transaction(hex) check_closed! response = with_retry { @conn.post("/transactions/send", { hex: hex }) } SubmitResult.from_hash(response.body) end # Decode a raw transaction # # @param hex [String] # @return [Transaction] def decode_raw_transaction(hex) check_closed! response = with_retry { @conn.post("/transactions/decode", { hex: hex }) } Transaction.from_hash(response.body) end # Get transactions for an address # # @param address [String] # @param limit [Integer] # @param offset [Integer] # @return [Array] def get_address_transactions(address, limit: 20, offset: 0) check_closed! response = with_retry do @conn.get("/addresses/#{address}/transactions", limit: limit, offset: offset) end response.body.map { |t| Transaction.from_hash(t) } end # ==================== Fee Estimation ==================== # Estimate fee # # @param priority [String] # @return [FeeEstimate] def estimate_fee(priority: Priority::MEDIUM) check_closed! response = with_retry { @conn.get("/fees/estimate", priority: priority) } FeeEstimate.from_hash(response.body) end # Get all fee estimates # # @return [Hash] def get_all_fee_estimates check_closed! response = with_retry { @conn.get("/fees/estimates") } response.body.to_h { |e| [e["priority"], FeeEstimate.from_hash(e)] } end # ==================== Chain Information ==================== # Get chain info # # @return [ChainInfo] def get_chain_info check_closed! response = with_retry { @conn.get("/chain/info") } ChainInfo.from_hash(response.body) end # Get mempool info # # @return [MempoolInfo] def get_mempool_info check_closed! response = with_retry { @conn.get("/mempool/info") } MempoolInfo.from_hash(response.body) end # Get mempool transaction IDs # # @param limit [Integer] # @return [Array] def get_mempool_transactions(limit: 100) check_closed! response = with_retry { @conn.get("/mempool/transactions", limit: limit) } response.body end # ==================== Subscriptions ==================== # Subscribe to new blocks # # @yield [Block] Called for each new block # @return [Subscription] def subscribe_blocks(&block) check_closed! raise ArgumentError, "Block required" unless block_given? ensure_websocket_connected subscription_id = SecureRandom.uuid @subscriptions[subscription_id] = { channel: "blocks", callback: ->(data) { block.call(Block.from_hash(data)) } } send_ws_message({ type: "subscribe", channel: "blocks", subscription_id: subscription_id }) Subscription.new(id: subscription_id, channel: "blocks") do @subscriptions.delete(subscription_id) send_ws_message({ type: "unsubscribe", subscription_id: subscription_id }) end end # Subscribe to address transactions # # @param address [String] # @yield [Transaction] Called for each transaction # @return [Subscription] def subscribe_address(address, &block) check_closed! raise ArgumentError, "Block required" unless block_given? ensure_websocket_connected subscription_id = SecureRandom.uuid @subscriptions[subscription_id] = { channel: "address:#{address}", callback: ->(data) { block.call(Transaction.from_hash(data)) } } send_ws_message({ type: "subscribe", channel: "address", address: address, subscription_id: subscription_id }) Subscription.new(id: subscription_id, channel: "address:#{address}") do @subscriptions.delete(subscription_id) send_ws_message({ type: "unsubscribe", subscription_id: subscription_id }) end end # Subscribe to mempool transactions # # @yield [Transaction] Called for each transaction # @return [Subscription] def subscribe_mempool(&block) check_closed! raise ArgumentError, "Block required" unless block_given? ensure_websocket_connected subscription_id = SecureRandom.uuid @subscriptions[subscription_id] = { channel: "mempool", callback: ->(data) { block.call(Transaction.from_hash(data)) } } send_ws_message({ type: "subscribe", channel: "mempool", subscription_id: subscription_id }) Subscription.new(id: subscription_id, channel: "mempool") do @subscriptions.delete(subscription_id) send_ws_message({ type: "unsubscribe", subscription_id: subscription_id }) end end # ==================== Lifecycle ==================== def close @closed = true @ws&.close @conn.close if @conn.respond_to?(:close) end def closed? @closed end private def check_closed! raise ClientClosedError, "Client has been closed" if @closed end def with_retry attempts = 0 begin attempts += 1 response = yield handle_response(response) response rescue Faraday::Error, ApiError => e if attempts < @config.retries sleep(attempts) retry end raise end end def handle_response(response) return if response.success? error_message = response.body["error"] || response.body["message"] || "Unknown error" raise ApiError.new(error_message, status_code: response.status) end def ensure_websocket_connected return if @ws && @ws_connected require "websocket-client-simple" @ws = WebSocket::Client::Simple.connect(@config.ws_url, headers: { "Authorization" => "Bearer #{@config.api_key}" }) @ws.on :message do |msg| handle_ws_message(msg.data) end @ws.on :open do @ws_connected = true end @ws.on :close do @ws_connected = false end @ws.on :error do |e| puts "WebSocket error: #{e.message}" if @config.debug end # Wait for connection sleep(0.1) until @ws_connected || @closed end def send_ws_message(message) @ws&.send(JSON.generate(message)) end def handle_ws_message(data) message = JSON.parse(data) subscription_id = message["subscription_id"] if subscription_id && @subscriptions[subscription_id] @subscriptions[subscription_id][:callback].call(message["data"]) end rescue JSON::ParserError # Skip malformed messages end end end