- Implement SynorStorage class for decentralized storage operations including upload, download, pinning, and CAR file management. - Create supporting types and models for storage operations such as UploadOptions, Pin, and StorageConfig. - Implement SynorWallet class for wallet operations including wallet creation, address generation, transaction signing, and balance queries. - Create supporting types and models for wallet operations such as Wallet, Address, and Transaction. - Introduce error handling for both storage and wallet operations.
384 lines
9.5 KiB
Ruby
384 lines
9.5 KiB
Ruby
# 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<Block>]
|
|
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<Transaction>]
|
|
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<String, FeeEstimate>]
|
|
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<String>]
|
|
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
|